mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
810 lines
28 KiB
Go
810 lines
28 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
)
|
|
|
|
// 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" db:"tenant_id"`
|
|
AgentKey string `json:"agent_key" db:"agent_key"`
|
|
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
|
Frontmatter string `json:"frontmatter,omitempty" db:"frontmatter"` // short expertise summary (NOT other_config.description which is the summoning prompt)
|
|
OwnerID string `json:"owner_id" db:"owner_id"`
|
|
Provider string `json:"provider" db:"provider"`
|
|
Model string `json:"model" db:"model"`
|
|
ContextWindow int `json:"context_window" db:"context_window"`
|
|
MaxToolIterations int `json:"max_tool_iterations" db:"max_tool_iterations"`
|
|
Workspace string `json:"workspace" db:"workspace"`
|
|
RestrictToWorkspace bool `json:"restrict_to_workspace" db:"restrict_to_workspace"`
|
|
AgentType string `json:"agent_type" db:"agent_type"` // "open" or "predefined"
|
|
IsDefault bool `json:"is_default" db:"is_default"`
|
|
Status string `json:"status" db:"status"`
|
|
|
|
// Budget: optional monthly spending limit in cents (nil = unlimited)
|
|
BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty" db:"budget_monthly_cents"`
|
|
|
|
// Per-agent JSONB config (nullable — nil means "use global defaults")
|
|
ToolsConfig json.RawMessage `json:"tools_config,omitempty" db:"tools_config"`
|
|
SandboxConfig json.RawMessage `json:"sandbox_config,omitempty" db:"sandbox_config"`
|
|
SubagentsConfig json.RawMessage `json:"subagents_config,omitempty" db:"subagents_config"`
|
|
MemoryConfig json.RawMessage `json:"memory_config,omitempty" db:"memory_config"`
|
|
CompactionConfig json.RawMessage `json:"compaction_config,omitempty" db:"compaction_config"`
|
|
ContextPruning json.RawMessage `json:"context_pruning,omitempty" db:"context_pruning"`
|
|
OtherConfig json.RawMessage `json:"other_config,omitempty" db:"other_config"` // extensibility bag for future fields
|
|
|
|
// Promoted from other_config (migration 000037 v3)
|
|
Emoji string `json:"emoji" db:"emoji"`
|
|
AgentDescription string `json:"agent_description" db:"agent_description"`
|
|
ThinkingLevel string `json:"thinking_level" db:"thinking_level"`
|
|
MaxTokens int `json:"max_tokens" db:"max_tokens"`
|
|
SelfEvolve bool `json:"self_evolve" db:"self_evolve"`
|
|
SkillEvolve bool `json:"skill_evolve" db:"skill_evolve"`
|
|
SkillNudgeInterval int `json:"skill_nudge_interval" db:"skill_nudge_interval"`
|
|
ReasoningConfig json.RawMessage `json:"reasoning_config,omitempty" db:"reasoning_config"`
|
|
WorkspaceSharing json.RawMessage `json:"workspace_sharing,omitempty" db:"workspace_sharing"`
|
|
ChatGPTOAuthRouting json.RawMessage `json:"chatgpt_oauth_routing,omitempty" db:"chatgpt_oauth_routing"`
|
|
ModelFallback json.RawMessage `json:"model_fallback,omitempty" db:"model_fallback"`
|
|
ShellDenyGroups json.RawMessage `json:"shell_deny_groups,omitempty" db:"shell_deny_groups"`
|
|
KGDedupConfig json.RawMessage `json:"kg_dedup_config,omitempty" db:"kg_dedup_config"`
|
|
}
|
|
|
|
// 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 the normalized reasoning effort from other_config JSONB.
|
|
// Missing config defaults to "off" to match the dashboard and docs.
|
|
func (a *AgentData) ParseThinkingLevel() string {
|
|
return a.ParseReasoningConfig().Effort
|
|
}
|
|
|
|
// ParseReasoningConfig reads advanced reasoning settings from the dedicated
|
|
// reasoning_config column with ThinkingLevel as legacy fallback.
|
|
func (a *AgentData) ParseReasoningConfig() AgentReasoningConfig {
|
|
cfg := AgentReasoningConfig{
|
|
OverrideMode: ReasoningOverrideInherit,
|
|
Effort: "off",
|
|
Fallback: ReasoningFallbackDowngrade,
|
|
Source: ReasoningSourceUnset,
|
|
}
|
|
|
|
var reasoning struct {
|
|
OverrideMode string `json:"override_mode"`
|
|
Effort string `json:"effort"`
|
|
Fallback string `json:"fallback"`
|
|
}
|
|
explicitInherit := false
|
|
if len(a.ReasoningConfig) > 2 && json.Unmarshal(a.ReasoningConfig, &reasoning) == nil {
|
|
if reasoning.OverrideMode == ReasoningOverrideInherit {
|
|
explicitInherit = true
|
|
} else {
|
|
cfg.OverrideMode = ReasoningOverrideCustom
|
|
cfg.Source = ReasoningSourceAdvanced
|
|
if effort := normalizeReasoningEffort(reasoning.Effort); effort != "" {
|
|
cfg.Effort = effort
|
|
}
|
|
cfg.Fallback = normalizeReasoningFallback(reasoning.Fallback)
|
|
}
|
|
}
|
|
|
|
if !explicitInherit && a.ThinkingLevel != "" {
|
|
if effort := normalizeReasoningEffort(a.ThinkingLevel); effort != "" {
|
|
if cfg.Source == ReasoningSourceUnset {
|
|
cfg.OverrideMode = ReasoningOverrideCustom
|
|
cfg.Source = ReasoningSourceLegacy
|
|
cfg.Effort = effort
|
|
} else if cfg.Effort == "off" {
|
|
cfg.Effort = effort
|
|
}
|
|
}
|
|
}
|
|
|
|
return cfg
|
|
}
|
|
|
|
// ParseMaxTokens returns per-agent max_tokens. 0 means use provider default.
|
|
func (a *AgentData) ParseMaxTokens() int { return a.MaxTokens }
|
|
|
|
// ParseSelfEvolve returns whether predefined agents can update their SOUL.md through chat.
|
|
func (a *AgentData) ParseSelfEvolve() bool { return a.SelfEvolve }
|
|
|
|
// ParseSkillEvolve returns whether the agent's skill learning loop is enabled.
|
|
func (a *AgentData) ParseSkillEvolve() bool { return a.SkillEvolve }
|
|
|
|
// ParseAllowImageGeneration returns whether the native image_generation tool
|
|
// is allowed for this agent. Defaults to true (enabled) when not set in
|
|
// other_config, so existing agents automatically get image generation with
|
|
// Codex providers. Operators can explicitly disable it by setting
|
|
// other_config.allow_image_generation = false.
|
|
// No DB column — code-only default to avoid a migration for a feature flag.
|
|
func (a *AgentData) ParseAllowImageGeneration() bool {
|
|
if len(a.OtherConfig) <= 2 {
|
|
return true // default: enabled
|
|
}
|
|
var bag struct {
|
|
AllowImageGeneration *bool `json:"allow_image_generation"`
|
|
}
|
|
if json.Unmarshal(a.OtherConfig, &bag) != nil {
|
|
return true // malformed config → default: enabled
|
|
}
|
|
if bag.AllowImageGeneration == nil {
|
|
return true // not set → default: enabled
|
|
}
|
|
return *bag.AllowImageGeneration
|
|
}
|
|
|
|
// ParseInboundDebounceMs returns the per-agent inbound debounce override.
|
|
// Missing or malformed config means "inherit global gateway.inbound_debounce_ms".
|
|
func (a *AgentData) ParseInboundDebounceMs() (int, bool) {
|
|
if a == nil {
|
|
return 0, false
|
|
}
|
|
return ParseInboundDebounceMsFromOtherConfig(a.OtherConfig)
|
|
}
|
|
|
|
func ParseInboundDebounceMsFromOtherConfig(raw json.RawMessage) (int, bool) {
|
|
if len(raw) <= 2 {
|
|
return 0, false
|
|
}
|
|
var bag struct {
|
|
InboundDebounceMs *int `json:"inbound_debounce_ms"`
|
|
}
|
|
if json.Unmarshal(raw, &bag) != nil || bag.InboundDebounceMs == nil {
|
|
return 0, false
|
|
}
|
|
return *bag.InboundDebounceMs, true
|
|
}
|
|
|
|
// validPromptModes is the set of allowed prompt_mode values.
|
|
var validPromptModes = map[string]bool{
|
|
"full": true, "task": true, "minimal": true, "none": true,
|
|
}
|
|
|
|
// ParsePromptMode returns the configured prompt mode from OtherConfig JSONB.
|
|
// Returns "" (defaults to "full") if not set or invalid.
|
|
func (a *AgentData) ParsePromptMode() string {
|
|
if len(a.OtherConfig) == 0 {
|
|
return ""
|
|
}
|
|
var bag map[string]json.RawMessage
|
|
if json.Unmarshal(a.OtherConfig, &bag) != nil {
|
|
return ""
|
|
}
|
|
raw, ok := bag["prompt_mode"]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var mode string
|
|
if json.Unmarshal(raw, &mode) != nil {
|
|
return ""
|
|
}
|
|
if !validPromptModes[mode] {
|
|
return "" // invalid mode → default to full
|
|
}
|
|
return mode
|
|
}
|
|
|
|
// ParsePinnedSkills returns per-agent pinned skill names from OtherConfig JSONB.
|
|
// Max 10 enforced. Returns nil if not set.
|
|
func (a *AgentData) ParsePinnedSkills() []string {
|
|
if len(a.OtherConfig) == 0 {
|
|
return nil
|
|
}
|
|
var bag map[string]json.RawMessage
|
|
if json.Unmarshal(a.OtherConfig, &bag) != nil {
|
|
return nil
|
|
}
|
|
raw, ok := bag["pinned_skills"]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var names []string
|
|
if json.Unmarshal(raw, &names) != nil {
|
|
return nil
|
|
}
|
|
// Filter empty strings
|
|
var result []string
|
|
for _, n := range names {
|
|
if n != "" {
|
|
result = append(result, n)
|
|
}
|
|
}
|
|
if len(result) > 10 {
|
|
result = result[:10]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ParseSkillNudgeInterval returns the tool-call interval for skill creation reminders.
|
|
// Returns 15 (default) when column is 0 (unset).
|
|
func (a *AgentData) ParseSkillNudgeInterval() int {
|
|
if a.SkillNudgeInterval <= 0 {
|
|
return 15
|
|
}
|
|
return a.SkillNudgeInterval
|
|
}
|
|
|
|
// normalizeReasoningEffort delegates to providers.NormalizeReasoningEffort (DRY).
|
|
func normalizeReasoningEffort(value string) string {
|
|
return providers.NormalizeReasoningEffort(value)
|
|
}
|
|
|
|
// normalizeReasoningFallback delegates to providers.NormalizeReasoningFallback (DRY).
|
|
func normalizeReasoningFallback(value string) string {
|
|
return providers.NormalizeReasoningFallback(value)
|
|
}
|
|
|
|
// 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" db:"-"`
|
|
SharedGroup bool `json:"shared_group" db:"-"`
|
|
SharedUsers []string `json:"shared_users,omitempty" db:"-"`
|
|
ShareMemory bool `json:"share_memory" db:"-"`
|
|
ShareKnowledgeGraph bool `json:"share_knowledge_graph" db:"-"`
|
|
ShareSessions bool `json:"share_sessions" db:"-"`
|
|
}
|
|
|
|
const (
|
|
ReasoningSourceUnset = "unset"
|
|
ReasoningSourceLegacy = "thinking_level"
|
|
ReasoningSourceAdvanced = "reasoning"
|
|
ReasoningSourceProviderDefault = "provider_default"
|
|
// Reasoning fallback constants — canonical definitions in providers package.
|
|
ReasoningFallbackDowngrade = providers.ReasoningFallbackDowngrade
|
|
ReasoningFallbackDisable = providers.ReasoningFallbackDisable
|
|
ReasoningFallbackProviderDefault = providers.ReasoningFallbackProviderDefault
|
|
ReasoningOverrideInherit = "inherit"
|
|
ReasoningOverrideCustom = "custom"
|
|
)
|
|
|
|
type AgentReasoningConfig struct {
|
|
OverrideMode string `json:"override_mode,omitempty" db:"-"`
|
|
Effort string `json:"effort,omitempty" db:"-"`
|
|
Fallback string `json:"fallback,omitempty" db:"-"`
|
|
Source string `json:"-" db:"-"`
|
|
}
|
|
|
|
// ResolveEffectiveReasoningConfig applies provider-owned defaults unless the agent
|
|
// has an explicit custom reasoning override.
|
|
func ResolveEffectiveReasoningConfig(
|
|
providerDefaults *ProviderReasoningConfig,
|
|
agentConfig AgentReasoningConfig,
|
|
) AgentReasoningConfig {
|
|
if agentConfig.OverrideMode == "" {
|
|
agentConfig.OverrideMode = ReasoningOverrideInherit
|
|
}
|
|
if agentConfig.Fallback == "" {
|
|
agentConfig.Fallback = ReasoningFallbackDowngrade
|
|
}
|
|
if agentConfig.Effort == "" {
|
|
agentConfig.Effort = "off"
|
|
}
|
|
|
|
if agentConfig.OverrideMode == ReasoningOverrideCustom {
|
|
return agentConfig
|
|
}
|
|
|
|
if providerDefaults == nil {
|
|
return AgentReasoningConfig{
|
|
OverrideMode: ReasoningOverrideInherit,
|
|
Effort: "off",
|
|
Fallback: ReasoningFallbackDowngrade,
|
|
Source: ReasoningSourceUnset,
|
|
}
|
|
}
|
|
|
|
return AgentReasoningConfig{
|
|
OverrideMode: ReasoningOverrideInherit,
|
|
Effort: providerDefaults.Effort,
|
|
Fallback: providerDefaults.Fallback,
|
|
Source: ReasoningSourceProviderDefault,
|
|
}
|
|
}
|
|
|
|
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" db:"-"`
|
|
Strategy string `json:"strategy,omitempty" db:"-"`
|
|
ExtraProviderNames []string `json:"extra_provider_names,omitempty" db:"-"`
|
|
}
|
|
|
|
// ParseWorkspaceSharing reads workspace sharing config from the dedicated column.
|
|
// Returns nil if not configured or all fields are default (isolation enabled).
|
|
func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig {
|
|
if len(a.WorkspaceSharing) <= 2 {
|
|
return nil
|
|
}
|
|
var ws WorkspaceSharingConfig
|
|
if json.Unmarshal(a.WorkspaceSharing, &ws) != nil {
|
|
return nil
|
|
}
|
|
if !ws.SharedDM && !ws.SharedGroup && len(ws.SharedUsers) == 0 && !ws.ShareMemory && !ws.ShareKnowledgeGraph && !ws.ShareSessions {
|
|
return nil
|
|
}
|
|
return &ws
|
|
}
|
|
|
|
// ParseChatGPTOAuthRouting reads chatgpt_oauth_routing from the dedicated column.
|
|
// Returns nil when no routing is configured.
|
|
func (a *AgentData) ParseChatGPTOAuthRouting() *ChatGPTOAuthRoutingConfig {
|
|
if len(a.ChatGPTOAuthRouting) <= 2 {
|
|
return nil
|
|
}
|
|
var raw ChatGPTOAuthRoutingConfig
|
|
if json.Unmarshal(a.ChatGPTOAuthRouting, &raw) != nil {
|
|
return nil
|
|
}
|
|
explicitOverrideMode := strings.TrimSpace(raw.OverrideMode) != ""
|
|
explicitStrategy := strings.TrimSpace(raw.Strategy) != ""
|
|
explicitExtras := raw.ExtraProviderNames != nil
|
|
routing := normalizeChatGPTOAuthRoutingConfig(&raw)
|
|
if routing == nil {
|
|
if !explicitOverrideMode && !explicitStrategy && !explicitExtras {
|
|
return nil
|
|
}
|
|
overrideMode := ChatGPTOAuthOverrideCustom
|
|
if explicitOverrideMode {
|
|
overrideMode = normalizeChatGPTOAuthOverrideMode(raw.OverrideMode)
|
|
}
|
|
extraProviderNames := normalizeProviderNames(raw.ExtraProviderNames)
|
|
if explicitExtras && extraProviderNames == nil {
|
|
extraProviderNames = []string{}
|
|
}
|
|
return &ChatGPTOAuthRoutingConfig{
|
|
OverrideMode: overrideMode,
|
|
Strategy: normalizeChatGPTOAuthStrategy(raw.Strategy),
|
|
ExtraProviderNames: extraProviderNames,
|
|
}
|
|
}
|
|
if explicitExtras && routing.ExtraProviderNames == nil {
|
|
routing.ExtraProviderNames = []string{}
|
|
}
|
|
if explicitOverrideMode {
|
|
return routing
|
|
}
|
|
if explicitStrategy || explicitExtras {
|
|
routing.OverrideMode = ChatGPTOAuthOverrideCustom
|
|
return routing
|
|
}
|
|
routing.OverrideMode = ""
|
|
if routing.Strategy == ChatGPTOAuthStrategyPriority && len(routing.ExtraProviderNames) == 0 {
|
|
return nil
|
|
}
|
|
return routing
|
|
}
|
|
|
|
const (
|
|
ModelFallbackStrategyPriority = "priority_order"
|
|
)
|
|
|
|
type ModelFallbackCandidate struct {
|
|
Provider string `json:"provider,omitempty" db:"-"`
|
|
Model string `json:"model,omitempty" db:"-"`
|
|
}
|
|
|
|
type ModelFallbackConfig struct {
|
|
Enabled bool `json:"enabled,omitempty" db:"-"`
|
|
Strategy string `json:"strategy,omitempty" db:"-"`
|
|
Candidates []ModelFallbackCandidate `json:"candidates,omitempty" db:"-"`
|
|
MaxAttempts int `json:"max_attempts,omitempty" db:"-"`
|
|
CooldownEnabled *bool `json:"cooldown_enabled,omitempty" db:"-"`
|
|
}
|
|
|
|
func (a *AgentData) ParseModelFallback() *ModelFallbackConfig {
|
|
if len(a.ModelFallback) <= 2 {
|
|
return nil
|
|
}
|
|
var raw ModelFallbackConfig
|
|
if json.Unmarshal(a.ModelFallback, &raw) != nil || !raw.Enabled {
|
|
return nil
|
|
}
|
|
cfg := NormalizeModelFallbackConfig(&raw)
|
|
if cfg == nil || len(cfg.Candidates) == 0 {
|
|
return nil
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
func NormalizeModelFallbackConfig(cfg *ModelFallbackConfig) *ModelFallbackConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
out := &ModelFallbackConfig{
|
|
Enabled: cfg.Enabled,
|
|
Strategy: cfg.Strategy,
|
|
MaxAttempts: cfg.MaxAttempts,
|
|
CooldownEnabled: cfg.CooldownEnabled,
|
|
}
|
|
if out.Strategy == "" {
|
|
out.Strategy = ModelFallbackStrategyPriority
|
|
}
|
|
if out.Strategy != ModelFallbackStrategyPriority {
|
|
out.Strategy = ModelFallbackStrategyPriority
|
|
}
|
|
seen := make(map[string]bool, len(cfg.Candidates))
|
|
for _, c := range cfg.Candidates {
|
|
c.Provider = strings.TrimSpace(c.Provider)
|
|
c.Model = strings.TrimSpace(c.Model)
|
|
if c.Provider == "" || c.Model == "" {
|
|
continue
|
|
}
|
|
key := c.Provider + "\x00" + c.Model
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
out.Candidates = append(out.Candidates, c)
|
|
}
|
|
if out.MaxAttempts < 0 {
|
|
out.MaxAttempts = 0
|
|
}
|
|
return out
|
|
}
|
|
|
|
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 cfg.ExtraProviderNames != nil && routing.ExtraProviderNames == nil {
|
|
routing.ExtraProviderNames = []string{}
|
|
}
|
|
if routing.OverrideMode == "" && routing.Strategy == ChatGPTOAuthStrategyPriority && 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 PublicChatGPTOAuthStrategy(value string) string {
|
|
if value == ChatGPTOAuthStrategyRoundRobin {
|
|
return ChatGPTOAuthStrategyRoundRobin
|
|
}
|
|
return ChatGPTOAuthStrategyPriority
|
|
}
|
|
|
|
func PublicChatGPTOAuthRouting(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
clone := CloneChatGPTOAuthRoutingConfig(cfg)
|
|
clone.Strategy = PublicChatGPTOAuthStrategy(clone.Strategy)
|
|
return clone
|
|
}
|
|
|
|
func CloneChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
clone := *cfg
|
|
clone.ExtraProviderNames = slices.Clone(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 normalizedAgent.ExtraProviderNames != nil &&
|
|
len(normalizedAgent.ExtraProviderNames) == 0 &&
|
|
effective.Strategy != ChatGPTOAuthStrategyRoundRobin {
|
|
effective.ExtraProviderNames = slices.Clone(normalizedAgent.ExtraProviderNames)
|
|
} else {
|
|
effective.ExtraProviderNames = slices.Clone(normalizedDefaults.ExtraProviderNames)
|
|
}
|
|
}
|
|
if effective.Strategy == ChatGPTOAuthStrategyPriority &&
|
|
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 reads shell deny group toggles from the dedicated column.
|
|
// Returns nil if not configured (all defaults apply).
|
|
func (a *AgentData) ParseShellDenyGroups() map[string]bool {
|
|
if len(a.ShellDenyGroups) <= 2 {
|
|
return nil
|
|
}
|
|
var groups map[string]bool
|
|
if json.Unmarshal(a.ShellDenyGroups, &groups) != nil || len(groups) == 0 {
|
|
return nil
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// AgentShareData represents an agent share grant.
|
|
type AgentShareData struct {
|
|
BaseModel
|
|
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
Role string `json:"role" db:"role"`
|
|
GrantedBy string `json:"granted_by" db:"granted_by"`
|
|
}
|
|
|
|
// AgentContextFileData represents an agent-level context file (SOUL.md, IDENTITY.md, etc).
|
|
type AgentContextFileData struct {
|
|
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
|
FileName string `json:"file_name" db:"file_name"`
|
|
Content string `json:"content" db:"content"`
|
|
}
|
|
|
|
// UserContextFileData represents a per-user context file.
|
|
type UserContextFileData struct {
|
|
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
FileName string `json:"file_name" db:"file_name"`
|
|
Content string `json:"content" db:"content"`
|
|
}
|
|
|
|
// UserAgentOverrideData represents per-user agent overrides.
|
|
type UserAgentOverrideData struct {
|
|
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
|
UserID string `json:"user_id" db:"user_id"`
|
|
Provider string `json:"provider,omitempty" db:"provider"`
|
|
Model string `json:"model,omitempty" db:"model"`
|
|
}
|
|
|
|
// 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
|
|
// ResetStuckSummoning flips rows with status='summoning' to 'summon_failed'.
|
|
// Called at startup to recover from crashes where summon goroutine died mid-flight.
|
|
ResetStuckSummoning(ctx context.Context) (int64, error)
|
|
}
|
|
|
|
// 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)
|
|
// ListUserContextFilesByName returns all per-user copies of fileName across all users of agentID.
|
|
// Used for bulk targeted updates (e.g. updating Name: in IDENTITY.md on agent rename).
|
|
ListUserContextFilesByName(ctx context.Context, agentID uuid.UUID, fileName 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
|
|
// MigrateUserDataOnMerge moves per-user data from oldUserIDs to newUserID when contacts are merged.
|
|
// Covers: user_context_files, user_agent_overrides, user_agent_profiles, memory_documents/chunks.
|
|
// On conflict, keeps the newest by updated_at. Best-effort per table.
|
|
MigrateUserDataOnMerge(ctx context.Context, oldUserIDs []string, newUserID 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" db:"user_id"`
|
|
FirstSeenAt *string `json:"first_seen_at,omitempty" db:"first_seen_at"`
|
|
LastSeenAt *string `json:"last_seen_at,omitempty" db:"last_seen_at"`
|
|
FileCount int `json:"file_count" db:"file_count"`
|
|
Metadata map[string]string `json:"metadata,omitempty" db:"-"`
|
|
}
|