Files
goclaw/internal/store/agent_store.go
T
Kai (Tam Nhu) Tran 30708ae79d feat(providers): support Codex OAuth pools with inherited routing defaults
* feat(auth): support named chatgpt oauth providers

- add provider-scoped ChatGPT OAuth routes and CLI support

- persist refresh tokens per provider and reject provider-type collisions

- wire provider OAuth setup flows in the dashboard and setup UI

Refs #448

* feat(agent): add chatgpt oauth account routing

- add agent other_config routing for manual and round-robin selection

- reuse routed provider resolution across resolver and pending loaders

- add router, parser, and agent advanced dialog coverage for multi-account use

Refs #448

* docs(api): describe chatgpt oauth routing

- document named-provider ChatGPT OAuth auth routes

- describe agent-side account routing and round-robin behavior

- update OpenAPI agent config schema and provider type enum

Refs #448

* fix(store): add missing agent key context helpers

* feat(ui): clarify chatgpt oauth account setup and routing

* docs(providers): align chatgpt oauth alias examples

* feat(agent): add codex pool activity dashboard

* fix(providers): harden codex oauth alias setup

* feat(codex-pool): improve routing dashboard UX

- redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence

- add clearer selection, attention, and recent-proof states for pool members

- make the lower panels fill the remaining desktop viewport while staying responsive

* fix(store): resolve context helper merge duplication

* feat(oauth): add codex pool quota and observation APIs

- add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers

- teach codex routing to surface pool activity, observation metadata, and quota-aware readiness

- extend tests and HTTP docs/OpenAPI for the new pool monitoring flows

* feat(web): add codex pool quota monitor and controls

- add provider quota fetching, readiness badges, and live routing evidence on the account pool page

- redesign pool setup and activity panels for multi-account management with localized copy updates

- keep the live monitor internally scrollable and compact the account cards for better viewport fit

* fix(web): clarify pool routing labels

- rename the recent request badge from Direct to Selected

- restore compact quota bars in the live pool cards

* feat(codex-pool): add runtime health dashboard

- derive per-provider success and failure health from routed Codex traces

- surface routing, quota, and recent request evidence in the pool UI

- align provider alias guidance and owner access with the dashboard role model

* docs(auth): document tenant scoping and key roles

* fix(auth): harden tenant and codex pool access control

* fix(providers): align codex pool runtime defaults

* feat(ui): tighten codex pool responsive layout

* feat(chatgpt-oauth): refine codex pool management UX

* feat(chatgpt-oauth): surface quota bars on provider pages

- add compact quota bars to Codex provider rows and provider detail

- fetch quota only for ready visible provider rows and ready detail aliases

- fix managed-member detail visibility and tighten provider locale copy
2026-03-27 09:35:57 +07:00

537 lines
18 KiB
Go

package store
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
)
// sanitizeToolCallPrefix strips characters not in [a-z0-9_{}] from the prefix.
// This matches the UI-side regex and prevents injection via direct API calls.
func sanitizeToolCallPrefix(s string) string {
if s == "" {
return ""
}
var b strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '{' || r == '}' {
b.WriteRune(r)
}
}
return b.String()
}
// Agent type constants.
const (
AgentTypeOpen = "open" // per-user context files, seeded on first chat
AgentTypePredefined = "predefined" // shared agent-level context files
)
// Agent status constants.
const (
AgentStatusActive = "active"
AgentStatusInactive = "inactive"
AgentStatusSummoning = "summoning"
AgentStatusSummonFailed = "summon_failed"
)
// AgentData represents an agent in the database.
type AgentData struct {
BaseModel
TenantID uuid.UUID `json:"tenant_id"`
AgentKey string `json:"agent_key"`
DisplayName string `json:"display_name,omitempty"`
Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt)
OwnerID string `json:"owner_id"`
Provider string `json:"provider"`
Model string `json:"model"`
ContextWindow int `json:"context_window"`
MaxToolIterations int `json:"max_tool_iterations"`
Workspace string `json:"workspace"`
RestrictToWorkspace bool `json:"restrict_to_workspace"`
AgentType string `json:"agent_type"` // "open" or "predefined"
IsDefault bool `json:"is_default"`
Status string `json:"status"`
// Budget: optional monthly spending limit in cents (nil = unlimited)
BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty"`
// Per-agent JSONB config (nullable — nil means "use global defaults")
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"`
SubagentsConfig json.RawMessage `json:"subagents_config,omitempty"`
MemoryConfig json.RawMessage `json:"memory_config,omitempty"`
CompactionConfig json.RawMessage `json:"compaction_config,omitempty"`
ContextPruning json.RawMessage `json:"context_pruning,omitempty"`
OtherConfig json.RawMessage `json:"other_config,omitempty"`
}
// ParseToolsConfig returns per-agent tool policy, or nil if not configured.
func (a *AgentData) ParseToolsConfig() *config.ToolPolicySpec {
if len(a.ToolsConfig) == 0 {
return nil
}
var c config.ToolPolicySpec
if json.Unmarshal(a.ToolsConfig, &c) != nil {
return nil
}
// Backward compat: migrate old "toolPrefix" key to "toolCallPrefix"
if c.ToolCallPrefix == "" {
var raw map[string]json.RawMessage
if json.Unmarshal(a.ToolsConfig, &raw) == nil {
if v, ok := raw["toolPrefix"]; ok {
var s string
if json.Unmarshal(v, &s) == nil && s != "" {
c.ToolCallPrefix = s
}
}
}
}
// Sanitize: only allow [a-z0-9_{}] to prevent injection via API bypass.
c.ToolCallPrefix = sanitizeToolCallPrefix(c.ToolCallPrefix)
return &c
}
// ParseSubagentsConfig returns per-agent subagent config, or nil if not configured.
func (a *AgentData) ParseSubagentsConfig() *config.SubagentsConfig {
if len(a.SubagentsConfig) == 0 {
return nil
}
var c config.SubagentsConfig
if json.Unmarshal(a.SubagentsConfig, &c) != nil {
return nil
}
return &c
}
// ParseCompactionConfig returns per-agent compaction config, or nil if not configured.
func (a *AgentData) ParseCompactionConfig() *config.CompactionConfig {
if len(a.CompactionConfig) == 0 {
return nil
}
var c config.CompactionConfig
if json.Unmarshal(a.CompactionConfig, &c) != nil {
return nil
}
return &c
}
// ParseContextPruning returns per-agent context pruning config, or nil if not configured.
func (a *AgentData) ParseContextPruning() *config.ContextPruningConfig {
if len(a.ContextPruning) == 0 {
return nil
}
var c config.ContextPruningConfig
if json.Unmarshal(a.ContextPruning, &c) != nil {
return nil
}
return &c
}
// ParseSandboxConfig returns per-agent sandbox config, or nil if not configured.
func (a *AgentData) ParseSandboxConfig() *config.SandboxConfig {
if len(a.SandboxConfig) == 0 {
return nil
}
var c config.SandboxConfig
if json.Unmarshal(a.SandboxConfig, &c) != nil {
return nil
}
return &c
}
// ParseMemoryConfig returns per-agent memory config, or nil if not configured.
func (a *AgentData) ParseMemoryConfig() *config.MemoryConfig {
if len(a.MemoryConfig) == 0 {
return nil
}
var c config.MemoryConfig
if json.Unmarshal(a.MemoryConfig, &c) != nil {
return nil
}
return &c
}
// ParseThinkingLevel extracts thinking_level from other_config JSONB.
// Returns "low" if not configured (default thinking mode).
func (a *AgentData) ParseThinkingLevel() string {
if len(a.OtherConfig) == 0 {
return "low"
}
var cfg struct {
ThinkingLevel string `json:"thinking_level"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return "low"
}
if cfg.ThinkingLevel == "" {
return "low"
}
return cfg.ThinkingLevel
}
// ParseMaxTokens extracts max_tokens from other_config JSONB.
// Returns 0 if not configured (caller should apply default).
func (a *AgentData) ParseMaxTokens() int {
if len(a.OtherConfig) == 0 {
return 0
}
var cfg struct {
MaxTokens int `json:"max_tokens"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return 0
}
return cfg.MaxTokens
}
// ParseSelfEvolve extracts self_evolve from other_config JSONB.
// When true, predefined agents can update their SOUL.md (style/tone) through chat.
func (a *AgentData) ParseSelfEvolve() bool {
if len(a.OtherConfig) == 0 {
return false
}
var cfg struct {
SelfEvolve bool `json:"self_evolve"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return false
}
return cfg.SelfEvolve
}
// ParseSkillEvolve extracts skill_evolve from other_config JSONB.
// When true, the agent's learning loop is enabled: system prompt includes skill
// creation guidance, and the loop injects nudges at tool count milestones.
func (a *AgentData) ParseSkillEvolve() bool {
if len(a.OtherConfig) == 0 {
return false
}
var cfg struct {
SkillEvolve bool `json:"skill_evolve"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return false
}
return cfg.SkillEvolve
}
// ParseSkillNudgeInterval extracts skill_nudge_interval from other_config JSONB.
// Returns the interval (in tool calls) at which the loop injects a skill creation reminder.
// Default 15 when not set. Explicitly 0 disables mid-loop nudges (system prompt guidance still shown).
func (a *AgentData) ParseSkillNudgeInterval() int {
if len(a.OtherConfig) == 0 {
return 15
}
var cfg struct {
SkillNudgeInterval *int `json:"skill_nudge_interval"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil {
return 15
}
if cfg.SkillNudgeInterval == nil {
return 15
}
return *cfg.SkillNudgeInterval
}
// WorkspaceSharingConfig controls per-user workspace isolation.
// When shared_dm/shared_group is true, users share the base workspace directory
// instead of each getting an isolated subfolder.
type WorkspaceSharingConfig struct {
SharedDM bool `json:"shared_dm"`
SharedGroup bool `json:"shared_group"`
SharedUsers []string `json:"shared_users,omitempty"`
ShareMemory bool `json:"share_memory"`
ShareKnowledgeGraph bool `json:"share_knowledge_graph"`
}
const (
ChatGPTOAuthStrategyManual = "manual" // legacy alias
ChatGPTOAuthStrategyPrimaryFirst = "primary_first"
ChatGPTOAuthStrategyRoundRobin = "round_robin"
ChatGPTOAuthStrategyPriority = "priority_order"
)
const (
ChatGPTOAuthOverrideInherit = "inherit"
ChatGPTOAuthOverrideCustom = "custom"
)
// ChatGPTOAuthRoutingConfig controls optional multi-account selection for agents
// whose primary provider is a ChatGPT OAuth-backed provider.
type ChatGPTOAuthRoutingConfig struct {
OverrideMode string `json:"override_mode,omitempty"`
Strategy string `json:"strategy,omitempty"`
ExtraProviderNames []string `json:"extra_provider_names,omitempty"`
}
// ParseWorkspaceSharing extracts workspace_sharing from other_config JSONB.
// Returns nil if not configured or all fields are default (isolation enabled).
func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig {
if len(a.OtherConfig) == 0 {
return nil
}
var cfg struct {
WS *WorkspaceSharingConfig `json:"workspace_sharing"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.WS == nil {
return nil
}
if !cfg.WS.SharedDM && !cfg.WS.SharedGroup && len(cfg.WS.SharedUsers) == 0 && !cfg.WS.ShareMemory && !cfg.WS.ShareKnowledgeGraph {
return nil
}
return cfg.WS
}
// ParseChatGPTOAuthRouting extracts chatgpt_oauth_routing from other_config JSONB.
// Returns nil when no routing is configured.
func (a *AgentData) ParseChatGPTOAuthRouting() *ChatGPTOAuthRoutingConfig {
if len(a.OtherConfig) == 0 {
return nil
}
var cfg struct {
Routing *ChatGPTOAuthRoutingConfig `json:"chatgpt_oauth_routing"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.Routing == nil {
return nil
}
explicitOverrideMode := strings.TrimSpace(cfg.Routing.OverrideMode) != ""
explicitStrategy := strings.TrimSpace(cfg.Routing.Strategy) != ""
explicitExtras := cfg.Routing.ExtraProviderNames != nil
routing := normalizeChatGPTOAuthRoutingConfig(cfg.Routing)
if routing == nil {
if !explicitOverrideMode && !explicitStrategy && !explicitExtras {
return nil
}
overrideMode := ChatGPTOAuthOverrideCustom
if explicitOverrideMode {
overrideMode = normalizeChatGPTOAuthOverrideMode(cfg.Routing.OverrideMode)
}
return &ChatGPTOAuthRoutingConfig{
OverrideMode: overrideMode,
Strategy: normalizeChatGPTOAuthStrategy(cfg.Routing.Strategy),
ExtraProviderNames: normalizeProviderNames(cfg.Routing.ExtraProviderNames),
}
}
if explicitOverrideMode {
return routing
}
if explicitStrategy || explicitExtras {
routing.OverrideMode = ChatGPTOAuthOverrideCustom
return routing
}
routing.OverrideMode = ""
if routing.Strategy == ChatGPTOAuthStrategyPrimaryFirst && len(routing.ExtraProviderNames) == 0 {
return nil
}
return routing
}
func normalizeChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
if cfg == nil {
return nil
}
routing := &ChatGPTOAuthRoutingConfig{
OverrideMode: normalizeChatGPTOAuthOverrideMode(cfg.OverrideMode),
Strategy: normalizeChatGPTOAuthStrategy(cfg.Strategy),
ExtraProviderNames: normalizeProviderNames(cfg.ExtraProviderNames),
}
if routing.OverrideMode == "" && routing.Strategy == ChatGPTOAuthStrategyPrimaryFirst && len(routing.ExtraProviderNames) == 0 {
return nil
}
return routing
}
func normalizeChatGPTOAuthOverrideMode(value string) string {
switch value {
case ChatGPTOAuthOverrideInherit:
return ChatGPTOAuthOverrideInherit
case "", ChatGPTOAuthOverrideCustom:
return ChatGPTOAuthOverrideCustom
default:
return ChatGPTOAuthOverrideCustom
}
}
func normalizeChatGPTOAuthStrategy(value string) string {
switch value {
case "", ChatGPTOAuthStrategyManual, ChatGPTOAuthStrategyPrimaryFirst:
return ChatGPTOAuthStrategyPrimaryFirst
case ChatGPTOAuthStrategyRoundRobin, ChatGPTOAuthStrategyPriority:
return value
default:
return ChatGPTOAuthStrategyPrimaryFirst
}
}
func CloneChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
if cfg == nil {
return nil
}
clone := *cfg
clone.ExtraProviderNames = append([]string(nil), cfg.ExtraProviderNames...)
return &clone
}
func ResolveEffectiveChatGPTOAuthRouting(defaults, agentRouting *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
normalizedDefaults := normalizeChatGPTOAuthRoutingConfig(defaults)
normalizedAgent := normalizeChatGPTOAuthRoutingConfig(agentRouting)
if normalizedAgent == nil {
return CloneChatGPTOAuthRoutingConfig(normalizedDefaults)
}
if normalizedAgent.OverrideMode == ChatGPTOAuthOverrideInherit {
return CloneChatGPTOAuthRoutingConfig(normalizedDefaults)
}
effective := CloneChatGPTOAuthRoutingConfig(normalizedAgent)
if effective == nil {
return nil
}
effective.OverrideMode = ""
if normalizedDefaults != nil && len(normalizedDefaults.ExtraProviderNames) > 0 {
if effective.Strategy == ChatGPTOAuthStrategyPrimaryFirst &&
len(normalizedAgent.ExtraProviderNames) == 0 {
effective.ExtraProviderNames = nil
} else {
effective.ExtraProviderNames = append([]string(nil), normalizedDefaults.ExtraProviderNames...)
}
}
if effective.Strategy == ChatGPTOAuthStrategyPrimaryFirst &&
len(effective.ExtraProviderNames) == 0 &&
normalizedAgent.OverrideMode != ChatGPTOAuthOverrideCustom {
return nil
}
return effective
}
func normalizeProviderNames(names []string) []string {
if len(names) == 0 {
return nil
}
seen := make(map[string]bool, len(names))
out := make([]string, 0, len(names))
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
if len(out) == 0 {
return nil
}
return out
}
// ParseShellDenyGroups extracts shell_deny_groups from other_config JSONB.
// Returns nil if not configured (all defaults apply).
func (a *AgentData) ParseShellDenyGroups() map[string]bool {
if len(a.OtherConfig) == 0 {
return nil
}
var cfg struct {
ShellDenyGroups map[string]bool `json:"shell_deny_groups"`
}
if json.Unmarshal(a.OtherConfig, &cfg) != nil || len(cfg.ShellDenyGroups) == 0 {
return nil
}
return cfg.ShellDenyGroups
}
// AgentShareData represents an agent share grant.
type AgentShareData struct {
BaseModel
AgentID uuid.UUID `json:"agent_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
GrantedBy string `json:"granted_by"`
}
// AgentContextFileData represents an agent-level context file (SOUL.md, IDENTITY.md, etc).
type AgentContextFileData struct {
AgentID uuid.UUID `json:"agent_id"`
FileName string `json:"file_name"`
Content string `json:"content"`
}
// UserContextFileData represents a per-user context file.
type UserContextFileData struct {
AgentID uuid.UUID `json:"agent_id"`
UserID string `json:"user_id"`
FileName string `json:"file_name"`
Content string `json:"content"`
}
// UserAgentOverrideData represents per-user agent overrides.
type UserAgentOverrideData struct {
AgentID uuid.UUID `json:"agent_id"`
UserID string `json:"user_id"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
}
// AgentCRUDStore manages core agent CRUD operations.
type AgentCRUDStore interface {
Create(ctx context.Context, agent *AgentData) error
GetByKey(ctx context.Context, agentKey string) (*AgentData, error)
GetByID(ctx context.Context, id uuid.UUID) (*AgentData, error)
GetByIDUnscoped(ctx context.Context, id uuid.UUID) (*AgentData, error)
GetByKeys(ctx context.Context, keys []string) ([]AgentData, error)
GetByIDs(ctx context.Context, ids []uuid.UUID) ([]AgentData, error)
Update(ctx context.Context, id uuid.UUID, updates map[string]any) error
Delete(ctx context.Context, id uuid.UUID) error
List(ctx context.Context, ownerID string) ([]AgentData, error)
GetDefault(ctx context.Context) (*AgentData, error) // agent with is_default=true, or first available
}
// AgentAccessStore manages agent sharing and access control.
type AgentAccessStore interface {
ShareAgent(ctx context.Context, agentID uuid.UUID, userID, role, grantedBy string) error
RevokeShare(ctx context.Context, agentID uuid.UUID, userID string) error
ListShares(ctx context.Context, agentID uuid.UUID) ([]AgentShareData, error)
CanAccess(ctx context.Context, agentID uuid.UUID, userID string) (bool, string, error) // (allowed, role, err)
ListAccessible(ctx context.Context, userID string) ([]AgentData, error)
}
// AgentContextStore manages agent-level and per-user context files and overrides.
type AgentContextStore interface {
GetAgentContextFiles(ctx context.Context, agentID uuid.UUID) ([]AgentContextFileData, error)
SetAgentContextFile(ctx context.Context, agentID uuid.UUID, fileName, content string) error
PropagateContextFile(ctx context.Context, agentID uuid.UUID, fileName string) (int, error)
GetUserContextFiles(ctx context.Context, agentID uuid.UUID, userID string) ([]UserContextFileData, error)
SetUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName, content string) error
DeleteUserContextFile(ctx context.Context, agentID uuid.UUID, userID, fileName string) error
GetUserOverride(ctx context.Context, agentID uuid.UUID, userID string) (*UserAgentOverrideData, error)
SetUserOverride(ctx context.Context, override *UserAgentOverrideData) error
}
// AgentProfileStore manages user-agent profiles and instances.
type AgentProfileStore interface {
GetOrCreateUserProfile(ctx context.Context, agentID uuid.UUID, userID, workspace, channel string) (isNew bool, effectiveWorkspace string, err error)
EnsureUserProfile(ctx context.Context, agentID uuid.UUID, userID string) error
ListUserInstances(ctx context.Context, agentID uuid.UUID) ([]UserInstanceData, error)
UpdateUserProfileMetadata(ctx context.Context, agentID uuid.UUID, userID string, metadata map[string]string) error
}
// AgentStore composes all agent sub-interfaces for backward compatibility.
// New code should depend on the specific sub-interface it needs.
type AgentStore interface {
AgentCRUDStore
AgentAccessStore
AgentContextStore
AgentProfileStore
}
// UserInstanceData represents a user instance for a predefined agent.
type UserInstanceData struct {
UserID string `json:"user_id"`
FirstSeenAt *string `json:"first_seen_at,omitempty"`
LastSeenAt *string `json:"last_seen_at,omitempty"`
FileCount int `json:"file_count"`
Metadata map[string]string `json:"metadata,omitempty"`
}