package db import ( "context" "database/sql" "encoding/json" "errors" "fmt" "strings" "time" ) // ---------- LLM profiles ---------- type LLMProfile struct { ID int64 `json:"id"` Name string `json:"name"` Format string `json:"format"` BaseURL string `json:"base_url,omitempty"` Proxy string `json:"proxy,omitempty"` // LLM outbound proxy (http/https/socks5); empty = use environment variables Model string `json:"model"` APIKey string `json:"-"` // never serialized to UI APIKeyHint string `json:"api_key_hint,omitempty"` RatePerSecond float64 `json:"rate_per_second"` RatePerMinute float64 `json:"rate_per_minute"` // ContextWindowK is the model's context window in K tokens, used to size // compaction thresholds. 0 = use a 200K default; capped at 1000 (1M). ContextWindowK int `json:"context_window_k"` // ThinkingType independently controls the thinking "switch" (thinking.type): // "" = do not send (default); "disabled" = explicitly off; "enabled" = on. // Decoupled from ReasoningEffort. ThinkingType string `json:"thinking_type"` // ReasoningEffort independently controls the thinking "intensity": // "" = do not send (default); "low"/"medium"/"high"/"xhigh"/"max" = the // corresponding intensity. See agent.Config.NewProvider. ReasoningEffort string `json:"reasoning_effort"` IsDefault bool `json:"is_default"` // Priority orders the failover chain: higher goes first. The ACTIVE profile // (IsDefault) always heads the chain regardless of this value. Priority int `json:"priority"` // PoolExclude=true keeps this profile out of the failover chain — it stays // usable when an agent/task binds it explicitly, it just never gets picked up // as a fallback target. PoolExclude bool `json:"pool_exclude"` // Streaming selects the wire protocol: true (default) = streaming (SSE); // false = real non-streaming (stream:false, single JSON response via // Provider.Complete). Non-streaming sidesteps flaky gateway SSE at the cost of // live in-run progress. Maps to agent.Config.Stream. Streaming bool `json:"streaming"` // MaxTokens caps a single reply's output in tokens. 0 = send no cap and let // the endpoint's own default apply (the historical behaviour). Unlike // ContextWindowK — the model's total capacity, used locally to size compaction // — this value travels with every request. MaxTokens int `json:"max_tokens"` // MaxTokensField picks the request key carrying MaxTokens, for format // "openai" only: "" = max_tokens (default); "max_completion_tokens" = the // newer key, which OpenAI's reasoning models require and whose budget covers // reasoning tokens plus visible output. Ignored by anthropic and // openai-responses, which name the field themselves. MaxTokensField string `json:"max_tokens_field"` // SessionHeaderKey, when non-empty, names a custom HTTP header sent on every // request built from this profile; its value is the current run's session id // (chat conversation / worker intent). For gateways that key prompt caching // or sticky routing off a session-id header. "" = not sent. Maps to // agent.Config.SessionHeaderKey. SessionHeaderKey string `json:"session_header_key"` // UserAgent, when non-empty, overrides the User-Agent header on every outbound // request built from this profile. "" = don't override (keep the default UA); // old profiles behave exactly as before. Maps to agent.Config.UserAgent. UserAgent string `json:"user_agent"` // Cost ledger: EUR per 1M tokens (0 = unset; cost is computed only when a // price is set). Set through the same profile API/SaveProfile path. PriceInputPerM float64 `json:"price_input_per_m,omitempty"` PriceOutputPerM float64 `json:"price_output_per_m,omitempty"` PriceCacheReadPerM float64 `json:"price_cache_read_per_m,omitempty"` PriceCacheWritePerM float64 `json:"price_cache_write_per_m,omitempty"` // Retry overrides this profile's share of the retry ladder. Zero value = // inherit the global policy (LLMRetryPolicy), so an untouched profile behaves // exactly as before. See RetryOverride. Retry RetryOverride `json:"retry"` } // RetryOverride is one profile's optional override of the three retry layers // that are per-endpoint: connect (connection setup) / empty (empty response) / // same-provider safety window (stream). Each rule's zero value means "inherit // the global policy"; see RetryRule for the -1 / 0 / >0 semantics. type RetryOverride struct { Connect RetryRule `json:"connect"` Empty RetryRule `json:"empty"` Stream RetryRule `json:"stream"` } // profileCols is the read column list (hint variant, no api key) shared by the // list query; profileColsKey is the same with api_key for the single-row loads. const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)` const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')` const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')` // scanProfile reads one row in the profileCols / profileColsKey column order. The // 7th column lands in APIKeyHint or APIKey depending on which list the caller used. func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error { return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into, &p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming, &p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey, &p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS, &p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS, &p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS, &p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM, &p.UserAgent) } func (d *DB) ListProfiles() ([]*LLMProfile, error) { rows, err := d.Query(`SELECT ` + profileCols + ` FROM llm_profiles ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []*LLMProfile for rows.Next() { var p LLMProfile if err := scanProfile(rows, &p.APIKeyHint, &p); err != nil { return nil, err } out = append(out, &p) } return out, rows.Err() } // ActiveProfile returns the default (active) profile with its api key, or nil. func (d *DB) ActiveProfile() (*LLMProfile, error) { var p LLMProfile err := scanProfile(d.QueryRow(`SELECT `+profileColsKey+` FROM llm_profiles WHERE is_default LIMIT 1`), &p.APIKey, &p) if err == sql.ErrNoRows { return nil, nil } return &p, err } // ProfileByID returns one profile with its api key by id, or nil if not found. // Used to run a task on a specific (non-default) LLM profile. func (d *DB) ProfileByID(id int64) (*LLMProfile, error) { var p LLMProfile err := scanProfile(d.QueryRow(`SELECT `+profileColsKey+` FROM llm_profiles WHERE id=$1`, id), &p.APIKey, &p) if err == sql.ErrNoRows { return nil, nil } return &p, err } // PoolProfiles returns the failover chain in run order, api keys included: the // active profile first, then every other usable profile that isn't excluded, by // priority DESC (id ASC to stay stable). Profiles without an api key can't serve // a request — except Ollama ('ollama'), which is a keyless local server — so // they never enter the chain. The ordering IS the policy — callers walk the // slice front to back. func (d *DB) PoolProfiles() ([]*LLMProfile, error) { rows, err := d.Query(`SELECT ` + profileColsKey + ` FROM llm_profiles WHERE (COALESCE(api_key,'') <> '' OR format='ollama') AND (is_default OR NOT pool_exclude) ORDER BY is_default DESC, priority DESC, id ASC`) if err != nil { return nil, err } defer rows.Close() var out []*LLMProfile for rows.Next() { var p LLMProfile if err := scanProfile(rows, &p.APIKey, &p); err != nil { return nil, err } out = append(out, &p) } return out, rows.Err() } // SaveProfile inserts (id==0) or updates a profile. Empty apiKey on update keeps existing. func (d *DB) SaveProfile(p *LLMProfile) (int64, error) { // Pasted keys can carry surrounding whitespace; every reader that goes // through ConfigFrom trims, so store the trimmed form or the raw value // leaks into paths that assign it directly (the llm/test fallback). p.APIKey = strings.TrimSpace(p.APIKey) hint := p.APIKeyHint if len(p.APIKey) >= 4 { hint = "…" + p.APIKey[len(p.APIKey)-4:] } r := p.Retry.Clamped() if p.ID == 0 { var id int64 err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms,user_agent) VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) RETURNING id`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent).Scan(&id) if err == nil { _, err = d.saveProfilePrices(id, p) } return id, err } if p.APIKey == "" { _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22,user_agent=$23 WHERE id=$24`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID) if err == nil { _, err = d.saveProfilePrices(p.ID, p) } return p.ID, err } _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24,user_agent=$25 WHERE id=$26`, p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey, r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID) if err == nil { _, err = d.saveProfilePrices(p.ID, p) } return p.ID, err } // saveProfilePrices writes the cost-ledger columns (kept out of the three main // statements so the pricing surface stays one small, auditable write). func (d *DB) saveProfilePrices(id int64, p *LLMProfile) (sql.Result, error) { return d.Exec(`UPDATE llm_profiles SET price_input_per_m=($1),price_output_per_m=($2),price_cache_read_per_m=($3),price_cache_write_per_m=($4) WHERE id=$5`, p.PriceInputPerM, p.PriceOutputPerM, p.PriceCacheReadPerM, p.PriceCacheWritePerM, id) } var ( ErrActiveLLMProfileDelete = errors.New("cannot delete the active LLM profile; activate another profile first") ErrLLMProfileReferencesChanged = errors.New("LLM profile references changed while deleting; retry the request") ErrLLMProfileNotFound = errors.New("LLM profile not found") ) func (d *DB) DeleteProfile(id int64) error { return d.DeleteProfileContext(context.Background(), id) } // DeleteProfileContext removes a non-default profile while preserving the task // failover cursor. Reference changes are retried because a task, agent, or // conversation may start pointing at the profile between the initial scan and // the profile row lock. A bound prevents a continuously changing workload from // keeping an HTTP request alive forever. func (d *DB) DeleteProfileContext(ctx context.Context, id int64) error { const ( maxAttempts = 8 maxDuration = 15 * time.Second ) ctx, cancel := context.WithTimeout(ctx, maxDuration) defer cancel() for attempt := 0; attempt < maxAttempts; attempt++ { if err := ctx.Err(); err != nil { return err } retry, err := d.deleteProfile(ctx, id) if err != nil || !retry { return err } } return fmt.Errorf("%w: profile %d", ErrLLMProfileReferencesChanged, id) } func (d *DB) deleteProfile(ctx context.Context, id int64) (bool, error) { tx, err := d.BeginTx(ctx, nil) if err != nil { return false, err } defer tx.Rollback() // Reference setters lock their task/agent/conversation row before an // llm_profiles row can be locked by a foreign-key check. Keep deletion in the // same order: the old profile -> child order deadlocked with a setter's child // -> profile order. Sorting also gives concurrent profile deletions a stable // order when their chains overlap multiple tasks. lockedTasks, err := lockProfileReferenceRows(ctx, tx, `SELECT t.id FROM tasks t WHERE t.llm_profile_id=$1 OR t.active_llm_profile_id=$1 OR EXISTS ( SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=$1 ) ORDER BY t.id FOR UPDATE OF t`, id) if err != nil { return false, err } lockedAgents, err := lockProfileReferenceRows(ctx, tx, `SELECT id FROM agents WHERE llm_profile_id=$1 ORDER BY id FOR UPDATE`, id) if err != nil { return false, err } lockedConversations, err := lockProfileReferenceRows(ctx, tx, `SELECT id FROM conversations WHERE llm_profile_id=$1 ORDER BY id FOR UPDATE`, id) if err != nil { return false, err } var isDefault bool if err := tx.QueryRowContext(ctx, `SELECT is_default FROM llm_profiles WHERE id=$1 FOR UPDATE`, id).Scan(&isDefault); err != nil { if err == sql.ErrNoRows { return false, ErrLLMProfileNotFound } return false, err } if isDefault { return false, ErrActiveLLMProfileDelete } type affectedTask struct { id int64 position int wasActive bool } // A task may have committed a new reference after the first statement took // its snapshot but before this transaction acquired the profile lock. The // profile lock now prevents further references; retry if that committed task // was not part of the task-first lock set. Never acquire a new task lock while // holding the profile lock, because that would recreate the inversion. rows, err := tx.QueryContext(ctx, `SELECT ref_kind, ref_id FROM ( SELECT 'task'::text AS ref_kind, t.id AS ref_id FROM tasks t WHERE t.llm_profile_id=$1 OR t.active_llm_profile_id=$1 OR EXISTS ( SELECT 1 FROM task_llm_profiles x WHERE x.task_id=t.id AND x.profile_id=$1 ) UNION ALL SELECT 'agent', a.id FROM agents a WHERE a.llm_profile_id=$1 UNION ALL SELECT 'conversation', c.id FROM conversations c WHERE c.llm_profile_id=$1 ) refs ORDER BY ref_kind, ref_id`, id) if err != nil { return false, err } for rows.Next() { var ( kind string rowID int64 ) if err := rows.Scan(&kind, &rowID); err != nil { rows.Close() return false, err } locked := false switch kind { case "task": _, locked = lockedTasks[rowID] case "agent": _, locked = lockedAgents[rowID] case "conversation": _, locked = lockedConversations[rowID] } if !locked { rows.Close() return true, nil } } if err := rows.Err(); err != nil { rows.Close() return false, err } if err := rows.Close(); err != nil { return false, err } rows, err = tx.QueryContext(ctx, `SELECT x.task_id, x.position, COALESCE(t.active_llm_profile_id=$1, false) FROM task_llm_profiles x JOIN tasks t ON t.id=x.task_id WHERE x.profile_id=$1 ORDER BY x.task_id`, id) if err != nil { return false, err } var affected []affectedTask for rows.Next() { var task affectedTask if err := rows.Scan(&task.id, &task.position, &task.wasActive); err != nil { rows.Close() return false, err } affected = append(affected, task) } if err := rows.Err(); err != nil { rows.Close() return false, err } if err := rows.Close(); err != nil { return false, err } if _, err := tx.ExecContext(ctx, `DELETE FROM llm_profiles WHERE id=$1`, id); err != nil { return false, err } // Only a successor after the deleted cursor is eligible. If there is none, // clear the explicit chain so the task falls back to its Agent/global provider; // profiles before a manually selected cursor must never be revived. for _, task := range affected { if !task.wasActive { if _, err := tx.ExecContext(ctx, `UPDATE tasks SET llm_chain_revision=llm_chain_revision+1 WHERE id=$1`, task.id); err != nil { return false, err } continue } var next int64 err := tx.QueryRowContext(ctx, `SELECT profile_id FROM task_llm_profiles WHERE task_id=$1 AND position>$2 AND status='ready' ORDER BY position LIMIT 1`, task.id, task.position).Scan(&next) if err != nil && err != sql.ErrNoRows { return false, err } if err == sql.ErrNoRows { if _, err := tx.ExecContext(ctx, `DELETE FROM task_llm_profiles WHERE task_id=$1`, task.id); err != nil { return false, err } if _, err := tx.ExecContext(ctx, `UPDATE tasks SET active_llm_profile_id=NULL, llm_profile_id=NULL, llm_chain_revision=llm_chain_revision+1 WHERE id=$1`, task.id); err != nil { return false, err } continue } if _, err := tx.ExecContext(ctx, `UPDATE tasks SET active_llm_profile_id=$2, llm_profile_id=$2, llm_chain_revision=llm_chain_revision+1 WHERE id=$1`, task.id, next); err != nil { return false, err } } if err := tx.Commit(); err != nil { return false, err } return false, nil } func lockProfileReferenceRows(ctx context.Context, tx *sql.Tx, query string, profileID int64) (map[int64]struct{}, error) { rows, err := tx.QueryContext(ctx, query, profileID) if err != nil { return nil, err } defer rows.Close() locked := make(map[int64]struct{}) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } locked[id] = struct{}{} } if err := rows.Err(); err != nil { return nil, err } return locked, nil } // SetActiveProfile makes one profile the global default (single-default invariant). func (d *DB) SetActiveProfile(id int64) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`UPDATE llm_profiles SET is_default=false WHERE is_default`); err != nil { return err } res, err := tx.Exec(`UPDATE llm_profiles SET is_default=true WHERE id=$1`, id) if err != nil { return err } if n, _ := res.RowsAffected(); n != 1 { return ErrLLMProfileNotFound } return tx.Commit() } // ---------- Agents / prompts ---------- type Agent struct { ID int64 `json:"id"` Key string `json:"key"` Name string `json:"name"` Description string `json:"description"` Role string `json:"role"` Builtin bool `json:"builtin"` Enabled bool `json:"enabled"` LLMProfileID *int64 `json:"llm_profile_id"` // bound LLM profile; nil = follow the task/conversation pin, then fall back to the globally active one MaxTurns int `json:"max_turns"` // max turns per run; 0 = unlimited RunSecs int `json:"run_seconds"` // worker per-run wall-clock limit (seconds); 0 = unlimited WebSearch bool `json:"web_search"` // whether web search is enabled (gated by the system-wide switch) InteractiveShell bool `json:"interactive_shell"` // whether the interactive shell is enabled (persistent PTY session tool family) WrapupPrompt string `json:"wrapup_prompt"` // wrap-up prompt (settlement prompt on timeout/step exhaustion); empty = use the built-in code default WrapupMaxTurns int `json:"wrapup_max_turns"` // the wrap-up phase's own turn budget; 0 = use the built-in code default (per agent) // Task-level timeout wrap-up prompt (a separate set from per-run; only used by // worker/planner); empty/0 = use the built-in code default. TaskTimeoutWrapupPrompt string `json:"task_timeout_wrapup_prompt"` TaskTimeoutWrapupMaxTurns int `json:"task_timeout_wrapup_max_turns"` // P3 trigger post-processing policy (only meaningful for custom agents): // TriggerRunMode serial|parallel — serial queueing / each trigger spawns its own concurrent conversation // TriggerMergeMode by_task|all|none — serial only: merge by task / merge all / no merge // TriggerMaxParallel — per-agent concurrency cap for parallel only; 0 = unlimited TriggerRunMode string `json:"trigger_run_mode"` TriggerMergeMode string `json:"trigger_merge_mode"` TriggerMaxParallel int `json:"trigger_max_parallel"` } const agentCols = `id,key,name,COALESCE(description,''),role,builtin,enabled,COALESCE(max_turns,0),COALESCE(run_seconds,600),COALESCE(web_search,false),COALESCE(interactive_shell,false),COALESCE(wrapup_prompt,''),COALESCE(wrapup_max_turns,0),COALESCE(task_timeout_wrapup_prompt,''),COALESCE(task_timeout_wrapup_max_turns,0),COALESCE(trigger_run_mode,'serial'),COALESCE(trigger_merge_mode,'all'),COALESCE(trigger_max_parallel,5),llm_profile_id` func scanAgent(sc interface{ Scan(...any) error }) (*Agent, error) { var a Agent var prof sql.NullInt64 // llm_profile_id is nullable: NULL when unbound err := sc.Scan(&a.ID, &a.Key, &a.Name, &a.Description, &a.Role, &a.Builtin, &a.Enabled, &a.MaxTurns, &a.RunSecs, &a.WebSearch, &a.InteractiveShell, &a.WrapupPrompt, &a.WrapupMaxTurns, &a.TaskTimeoutWrapupPrompt, &a.TaskTimeoutWrapupMaxTurns, &a.TriggerRunMode, &a.TriggerMergeMode, &a.TriggerMaxParallel, &prof) if err == nil && prof.Valid { v := prof.Int64 a.LLMProfileID = &v } return &a, err } func (d *DB) ListAgents() ([]*Agent, error) { rows, err := d.Query(`SELECT ` + agentCols + ` FROM agents ORDER BY id`) if err != nil { return nil, err } defer rows.Close() var out []*Agent for rows.Next() { a, err := scanAgent(rows) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } func (d *DB) GetAgentByKey(key string) (*Agent, error) { a, err := scanAgent(d.QueryRow(`SELECT `+agentCols+` FROM agents WHERE key=$1`, key)) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } return a, nil } // AgentBindingCounts returns per-agent binding counts in a few grouped queries // (NO N+1): visible MCP servers and skills keyed by agent id, and bound tools // keyed by agent key (tools.agents is a JSONB array of agent keys). Missing keys // mean zero. Used to show "MCP N · Skill N · Tool N" on the agent cards. func (d *DB) AgentBindingCounts() (mcp map[int64]int, skill map[int64]int, tools map[string]int, err error) { mcp, skill, tools = map[int64]int{}, map[int64]int{}, map[string]int{} byID := func(q string, into map[int64]int) error { rows, e := d.Query(q) if e != nil { return e } defer rows.Close() for rows.Next() { var id int64 var n int if e := rows.Scan(&id, &n); e != nil { return e } into[id] = n } return rows.Err() } if err = byID(`SELECT agent_id, count(DISTINCT resource_id) FROM agent_visibility WHERE resource_kind='mcp' AND enabled GROUP BY agent_id`, mcp); err != nil { return } if err = byID(`SELECT agent_id, count(*) FROM agent_skill_visibility WHERE enabled GROUP BY agent_id`, skill); err != nil { return } rows, e := d.Query(`SELECT elem, count(*) FROM tools, jsonb_array_elements_text(agents) AS elem GROUP BY elem`) if e != nil { err = e return } defer rows.Close() for rows.Next() { var k string var n int if e := rows.Scan(&k, &n); e != nil { err = e return } tools[k] = n } err = rows.Err() return } // CreateAgent inserts a custom (builtin=false) conversational agent with role // 'assistant'. Returns the new row. Callers validate key/name upstream; the DB // enforces key charset + uniqueness and the role check constraint. func (d *DB) CreateAgent(key, name, description string) (*Agent, error) { a, err := scanAgent(d.QueryRow(` INSERT INTO agents(key, name, description, role, builtin, enabled) VALUES ($1, $2, NULLIF($3,''), 'assistant', false, true) RETURNING `+agentCols, key, name, description)) if err != nil { return nil, err } return a, nil } // UpdateAgentMeta updates a custom agent's display name + description. Built-in // agents are left untouched (guarded by the caller / the builtin flag). func (d *DB) UpdateAgentMeta(key, name, description string) error { _, err := d.Exec(`UPDATE agents SET name=$2, description=NULLIF($3,'') WHERE key=$1 AND builtin=false`, key, name, description) return err } // DeleteAgent removes a custom agent. Built-in agents are protected by the // builtin=false guard. agent_prompts / agent_prompt_vars / visibility rows cascade // via FK; tools.agents bindings for the key are cleaned by the caller. func (d *DB) DeleteAgent(key string) error { _, err := d.Exec(`DELETE FROM agents WHERE key=$1 AND builtin=false`, key) return err } // SetAgentMaxTurns updates an agent's max_turns (0 = unlimited). func (d *DB) SetAgentMaxTurns(key string, maxTurns int) error { if maxTurns < 0 { maxTurns = 0 } _, err := d.Exec(`UPDATE agents SET max_turns=$1 WHERE key=$2`, maxTurns, key) return err } // SetAgentLLMProfile binds an agent to a specific LLM profile (id != nil), or clears // the binding (id == nil) so the agent follows the task/conversation pin, else the // global active profile. Precedence at runtime: agent binding → task/conv pin → active. func (d *DB) SetAgentLLMProfile(key string, id *int64) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() // DeleteProfile locks reference rows before the profile row. Keep the same // order here so a concurrent rebind cannot form a child/profile deadlock. var agentID int64 if err := tx.QueryRow(`SELECT id FROM agents WHERE key=$1 FOR UPDATE`, key).Scan(&agentID); err != nil { if err == sql.ErrNoRows { // Preserve the previous UPDATE semantics: an unknown key is a no-op. return tx.Commit() } return err } if err := lockLLMProfileForReference(tx, id); err != nil { return err } if _, err := tx.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE id=$2`, id, agentID); err != nil { return err } return tx.Commit() } // lockLLMProfileForReference makes the profile side of the shared lock-order // protocol explicit. Callers must already own the referencing child row. func lockLLMProfileForReference(tx *sql.Tx, profileID *int64) error { if profileID == nil { return nil } var lockedID int64 if err := tx.QueryRow(`SELECT id FROM llm_profiles WHERE id=$1 FOR KEY SHARE`, *profileID).Scan(&lockedID); err != nil { if err == sql.ErrNoRows { return ErrLLMProfileNotFound } return err } return nil } // SetAgentWebSearch toggles whether an agent uses network search (still gated by // the global web-search master switch + backend/key config). func (d *DB) SetAgentWebSearch(key string, on bool) error { _, err := d.Exec(`UPDATE agents SET web_search=$1 WHERE key=$2`, on, key) return err } // SetAgentInteractiveShell toggles whether an agent gets the interactive shell // (persistent PTY session) tool family + Bash prompt linkage (see // docs/interactive-shell-design.md §14.2). func (d *DB) SetAgentInteractiveShell(key string, on bool) error { _, err := d.Exec(`UPDATE agents SET interactive_shell=$1 WHERE key=$2`, on, key) return err } // SetAgentWrapupPrompt stores an agent's wrap-up (settlement) prompt. Empty string // means "use the code built-in default" — resolved at runtime by resolveWrapup. func (d *DB) SetAgentWrapupPrompt(key, prompt string) error { _, err := d.Exec(`UPDATE agents SET wrapup_prompt=$1 WHERE key=$2`, prompt, key) return err } // SetAgentWrapupMaxTurns stores the wrap-up phase's own turn budget. 0 means "use // the code built-in default" — resolved at runtime by resolveWrapupTurns. func (d *DB) SetAgentWrapupMaxTurns(key string, n int) error { _, err := d.Exec(`UPDATE agents SET wrapup_max_turns=$1 WHERE key=$2`, n, key) return err } // SetAgentTaskTimeoutWrapup stores an agent's task-timeout wrap-up prompt (empty = // use code built-in default; only worker/planner have one) and its turn budget // (0 = default). Resolved at runtime by resolveTaskTimeoutWrapup / …Turns. func (d *DB) SetAgentTaskTimeoutWrapup(key, prompt string, maxTurns int) error { _, err := d.Exec(`UPDATE agents SET task_timeout_wrapup_prompt=$1, task_timeout_wrapup_max_turns=$2 WHERE key=$3`, prompt, maxTurns, key) return err } // SetAgentRunSeconds updates an agent's run_seconds wall-clock budget (0 = unlimited). func (d *DB) SetAgentRunSeconds(key string, runSecs int) error { if runSecs < 0 { runSecs = 0 } _, err := d.Exec(`UPDATE agents SET run_seconds=$1 WHERE key=$2`, runSecs, key) return err } // SetAgentTriggerBehavior stores an agent's P3 trigger post-processing policy: // runMode(serial|parallel) / mergeMode(by_task|all|none) / maxParallel (for parallel; 0 = unlimited). // Enums are whitelist-validated; invalid values fall back to defaults, so dirty // data cannot throw off the scheduling pump. func (d *DB) SetAgentTriggerBehavior(key, runMode, mergeMode string, maxParallel int) error { switch runMode { case "serial", "parallel": default: runMode = "serial" } switch mergeMode { case "by_task", "all", "none": default: mergeMode = "by_task" } if maxParallel < 0 { maxParallel = 0 } _, err := d.Exec(`UPDATE agents SET trigger_run_mode=$1, trigger_merge_mode=$2, trigger_max_parallel=$3 WHERE key=$4`, runMode, mergeMode, maxParallel, key) return err } type PromptVar struct { Name string `json:"name"` Description string `json:"description"` Example string `json:"example"` Source string `json:"source"` } func (d *DB) PromptVars(agentID int64) ([]PromptVar, error) { rows, err := d.Query(`SELECT var_name,COALESCE(description,''),COALESCE(example,''),source FROM agent_prompt_vars WHERE agent_id=$1 ORDER BY var_name`, agentID) if err != nil { return nil, err } defer rows.Close() out := []PromptVar{} for rows.Next() { var v PromptVar if err := rows.Scan(&v.Name, &v.Description, &v.Example, &v.Source); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // CurrentPrompt returns the agent's active template text ("" if none set yet). func (d *DB) CurrentPrompt(agentID int64) (string, error) { var tmpl sql.NullString err := d.QueryRow(`SELECT p.template_text FROM agents a JOIN agent_prompts p ON p.id=a.current_prompt_id WHERE a.id=$1`, agentID).Scan(&tmpl) if err == sql.ErrNoRows { return "", nil } return tmpl.String, err } // SeedPromptIfEmpty writes the code-default template as the agent's first prompt // version ONLY when it has none yet (current_prompt_id IS NULL). Mirrors // SeedTool's first-insert-only philosophy: a user's edited prompt is never // clobbered on restart. Idempotent — a no-op once any version exists. func (d *DB) SeedPromptIfEmpty(agentID int64, tmpl string) error { var cur sql.NullInt64 if err := d.QueryRow(`SELECT current_prompt_id FROM agents WHERE id=$1`, agentID).Scan(&cur); err != nil { return err } if cur.Valid { return nil // already seeded or user-edited → leave it } _, err := d.SavePrompt(agentID, tmpl, "built-in default", "system") return err } // ResetPromptToDefault appends the code-default template as a new version and // points current at it — the explicit "restore to built-in default" action. func (d *DB) ResetPromptToDefault(agentID int64, tmpl string) (int, error) { return d.SavePrompt(agentID, tmpl, "restore to built-in default", "system") } // SavePrompt appends a new version and points current_prompt_id at it. func (d *DB) SavePrompt(agentID int64, template, note, by string) (int, error) { tx, err := d.Begin() if err != nil { return 0, err } defer tx.Rollback() var ver int if err := tx.QueryRow(`SELECT COALESCE(max(version),0)+1 FROM agent_prompts WHERE agent_id=$1`, agentID).Scan(&ver); err != nil { return 0, err } var pid int64 if err := tx.QueryRow(`INSERT INTO agent_prompts(agent_id,version,template_text,note,updated_by) VALUES ($1,$2,$3,NULLIF($4,''),NULLIF($5,'')) RETURNING id`, agentID, ver, template, note, by).Scan(&pid); err != nil { return 0, err } if _, err := tx.Exec(`UPDATE agents SET current_prompt_id=$1 WHERE id=$2`, pid, agentID); err != nil { return 0, err } return ver, tx.Commit() } type PromptVersion struct { Version int `json:"version"` Template string `json:"template_text"` Note string `json:"note,omitempty"` CreatedAt time.Time `json:"ts"` } func (d *DB) ListPromptVersions(agentID int64) ([]PromptVersion, error) { rows, err := d.Query(`SELECT version,template_text,COALESCE(note,''),created_at FROM agent_prompts WHERE agent_id=$1 ORDER BY version DESC`, agentID) if err != nil { return nil, err } defer rows.Close() out := []PromptVersion{} for rows.Next() { var v PromptVersion if err := rows.Scan(&v.Version, &v.Template, &v.Note, &v.CreatedAt); err != nil { return nil, err } out = append(out, v) } return out, rows.Err() } // ---------- MCP servers ---------- type MCPServer struct { ID int64 `json:"id"` Name string `json:"name"` Transport string `json:"transport"` Command string `json:"command,omitempty"` Args json.RawMessage `json:"args"` Env json.RawMessage `json:"env"` URL string `json:"url,omitempty"` Enabled bool `json:"enabled"` Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108) TimeoutSeconds int `json:"timeout_seconds"` // http: per-request timeout in seconds; 0 = default 120 LastConnectAt *time.Time `json:"last_connect_at,omitempty"` LastConnectOK *bool `json:"last_connect_ok,omitempty"` LastConnectErr *string `json:"last_connect_err,omitempty"` Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache) } func (d *DB) ListMCP() ([]*MCPServer, error) { rows, err := d.Query(`SELECT id,name,transport,COALESCE(command,''),args,env,COALESCE(url,''),enabled,insecure,COALESCE(timeout_seconds,120),last_connect_at,last_connect_ok,last_connect_err FROM mcp_servers ORDER BY id`) if err != nil { return nil, err } var out []*MCPServer for rows.Next() { var m MCPServer var args, env []byte if err := rows.Scan(&m.ID, &m.Name, &m.Transport, &m.Command, &args, &env, &m.URL, &m.Enabled, &m.Insecure, &m.TimeoutSeconds, &m.LastConnectAt, &m.LastConnectOK, &m.LastConnectErr); err != nil { rows.Close() return nil, err } m.Args, m.Env = json.RawMessage(args), json.RawMessage(env) out = append(out, &m) } if err := rows.Err(); err != nil { rows.Close() return nil, err } rows.Close() // free the connection before the per-server tool-cache queries below // Attach each server's cached tool names (best-effort; empty until discovered). for _, m := range out { m.Tools, _ = d.MCPToolNames(m.ID) } return out, nil } // MCPToolNames returns the cached tool names for a server (empty until discovered). func (d *DB) MCPToolNames(serverID int64) ([]string, error) { rows, err := d.Query(`SELECT tool_name FROM mcp_tools_cache WHERE server_id=$1 ORDER BY tool_name`, serverID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var n string if err := rows.Scan(&n); err != nil { return nil, err } out = append(out, n) } return out, rows.Err() } // MCPTool is one cached tool of an MCP server (name + description). type MCPTool struct { Name string `json:"name"` Description string `json:"description"` } // MCPToolsDetailed returns the cached tools (name + description) for a server. func (d *DB) MCPToolsDetailed(serverID int64) ([]MCPTool, error) { rows, err := d.Query(`SELECT tool_name, COALESCE(description,'') FROM mcp_tools_cache WHERE server_id=$1 ORDER BY tool_name`, serverID) if err != nil { return nil, err } defer rows.Close() var out []MCPTool for rows.Next() { var t MCPTool if err := rows.Scan(&t.Name, &t.Description); err != nil { return nil, err } out = append(out, t) } return out, rows.Err() } // SaveMCPTools replaces the cached tool list for a server (called after discovery). func (d *DB) SaveMCPTools(serverID int64, tools []MCPTool) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM mcp_tools_cache WHERE server_id=$1`, serverID); err != nil { return err } for _, t := range tools { if _, err := tx.Exec(`INSERT INTO mcp_tools_cache(server_id, tool_name, description) VALUES ($1,$2,$3) ON CONFLICT (server_id, tool_name) DO UPDATE SET description=EXCLUDED.description`, serverID, t.Name, t.Description); err != nil { return err } } return tx.Commit() } // UpdateMCPHealth records the outcome of one connect attempt. The WHERE clause // skips the write when nothing changed, so per-run wiring does not amplify writes. func (d *DB) UpdateMCPHealth(id int64, ok bool, errMsg string) error { _, err := d.Exec(`UPDATE mcp_servers SET last_connect_at = now(), last_connect_ok = $2, last_connect_err = NULLIF($3, '') WHERE id = $1 AND (last_connect_ok IS DISTINCT FROM $2 OR last_connect_err IS DISTINCT FROM NULLIF($3, '') OR last_connect_at IS NULL)`, id, ok, errMsg) return err } func (d *DB) SaveMCP(m *MCPServer) (int64, error) { args, env := string(m.Args), string(m.Env) if args == "" { args = "[]" } if env == "" { env = "{}" } timeout := m.TimeoutSeconds if timeout <= 0 { timeout = 120 } if m.ID == 0 { var id int64 err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled,insecure,timeout_seconds) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7,$8,$9) RETURNING id`, m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, timeout).Scan(&id) return id, err } _, err := d.Exec(`UPDATE mcp_servers SET name=$1,transport=$2,command=NULLIF($3,''),args=$4,env=$5,url=NULLIF($6,''),enabled=$7,insecure=$8,timeout_seconds=$9 WHERE id=$10`, m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, timeout, m.ID) return m.ID, err } func (d *DB) DeleteMCP(id int64) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM agent_visibility WHERE resource_kind='mcp' AND resource_id=$1`, id); err != nil { return err } if _, err := tx.Exec(`DELETE FROM mcp_servers WHERE id=$1`, id); err != nil { return err } return tx.Commit() } // ---------- Skill visibility (agent × skill_name) ---------- // AgentSkillNames returns the skill directory names visible to an agent. func (d *DB) AgentSkillNames(agentID int64) ([]string, error) { rows, err := d.Query(`SELECT skill_name FROM agent_skill_visibility WHERE agent_id=$1 AND enabled ORDER BY skill_name`, agentID) if err != nil { return nil, err } defer rows.Close() var out []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, err } out = append(out, name) } return out, rows.Err() } // SkillAgents returns the agent IDs that can see a skill. func (d *DB) SkillAgents(skillName string) ([]int64, error) { rows, err := d.Query(`SELECT agent_id FROM agent_skill_visibility WHERE skill_name=$1 AND enabled`, skillName) if err != nil { return nil, err } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // SetAgentSkillVisibility replaces all skill visibility for an agent. func (d *DB) SetAgentSkillVisibility(agentID int64, names []string) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM agent_skill_visibility WHERE agent_id=$1`, agentID); err != nil { return err } for _, name := range names { if _, err := tx.Exec(`INSERT INTO agent_skill_visibility(agent_id,skill_name,enabled) VALUES ($1,$2,true) ON CONFLICT (agent_id,skill_name) DO UPDATE SET enabled=true`, agentID, name); err != nil { return err } } return tx.Commit() } // ToggleSkillVisibility sets one (agent, skill_name) visibility on/off. func (d *DB) ToggleSkillVisibility(agentID int64, skillName string, on bool) error { if on { _, err := d.Exec(`INSERT INTO agent_skill_visibility(agent_id,skill_name,enabled) VALUES ($1,$2,true) ON CONFLICT (agent_id,skill_name) DO UPDATE SET enabled=true`, agentID, skillName) return err } _, err := d.Exec(`DELETE FROM agent_skill_visibility WHERE agent_id=$1 AND skill_name=$2`, agentID, skillName) return err } // DeleteSkillVisibility removes all visibility rows for a skill (called on skill delete). func (d *DB) DeleteSkillVisibility(skillName string) error { _, err := d.Exec(`DELETE FROM agent_skill_visibility WHERE skill_name=$1`, skillName) return err } // ---------- Visibility (agent × mcp) ---------- // AgentVisible returns the resource ids of a kind visible to an agent. func (d *DB) AgentVisible(agentID int64, kind string) ([]int64, error) { rows, err := d.Query(`SELECT resource_id FROM agent_visibility WHERE agent_id=$1 AND resource_kind=$2 AND enabled`, agentID, kind) if err != nil { return nil, err } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // ResourceAgents returns the agent ids that can see a resource. func (d *DB) ResourceAgents(kind string, resourceID int64) ([]int64, error) { rows, err := d.Query(`SELECT agent_id FROM agent_visibility WHERE resource_kind=$1 AND resource_id=$2 AND enabled`, kind, resourceID) if err != nil { return nil, err } defer rows.Close() var out []int64 for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, err } out = append(out, id) } return out, rows.Err() } // SetAgentVisibilityKind replaces the full set of visible resources of a kind for // an agent (agent-side bulk write). Bidirectional with the resource-side view — // both read/write the same agent_visibility rows. func (d *DB) SetAgentVisibilityKind(agentID int64, kind string, resourceIDs []int64) error { tx, err := d.Begin() if err != nil { return err } defer tx.Rollback() if _, err := tx.Exec(`DELETE FROM agent_visibility WHERE agent_id=$1 AND resource_kind=$2`, agentID, kind); err != nil { return err } for _, rid := range resourceIDs { if _, err := tx.Exec(`INSERT INTO agent_visibility(agent_id,resource_kind,resource_id,enabled) VALUES ($1,$2,$3,true) ON CONFLICT (agent_id,resource_kind,resource_id,mcp_tool_name) DO UPDATE SET enabled=true`, agentID, kind, rid); err != nil { return err } } return tx.Commit() } // ToggleVisibility sets one (agent, kind, resource) visibility on/off (idempotent). func (d *DB) ToggleVisibility(agentID int64, kind string, resourceID int64, on bool) error { if on { _, err := d.Exec(`INSERT INTO agent_visibility(agent_id,resource_kind,resource_id,enabled) VALUES ($1,$2,$3,true) ON CONFLICT (agent_id,resource_kind,resource_id,mcp_tool_name) DO UPDATE SET enabled=true`, agentID, kind, resourceID) return err } _, err := d.Exec(`DELETE FROM agent_visibility WHERE agent_id=$1 AND resource_kind=$2 AND resource_id=$3`, agentID, kind, resourceID) return err }