package db import ( "context" "database/sql" "encoding/json" "errors" "fmt" "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 出站代理(http/https/socks5);空=用环境变量 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 独立控制思考「开关」(thinking.type):"" = 不发送(默认); // "disabled" = 显式关闭; "enabled" = 开启. 与 ReasoningEffort 解耦. ThinkingType string `json:"thinking_type"` // ReasoningEffort 独立控制思考「强度」:"" = 不发送(默认); // "low"/"medium"/"high"/"xhigh"/"max" = 对应强度. 见 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"` // 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) / 空响应(empty) / 同 provider 安全窗口 // (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 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 // 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) } 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 keyed profile that isn't excluded, by // priority DESC (id ASC to stay stable). Profiles without an api key can't serve // a request, 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,'') <> '' 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) { 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) 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) 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).Scan(&id) 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 WHERE id=$23`, 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.ID) 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 WHERE id=$25`, 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.ID) return p.ID, err } 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"` // 绑定的 LLM 配置;nil=跟随任务/会话 pin,再回退全局激活 MaxTurns int `json:"max_turns"` // 单次运行最大轮次;0=不限制 RunSecs int `json:"run_seconds"` // worker 单次运行墙钟上限(秒);0=不限制 WebSearch bool `json:"web_search"` // 是否启用网络搜索(受系统全局开关门控) InteractiveShell bool `json:"interactive_shell"` // 是否启用交互式 shell(持久 PTY 会话工具族) WrapupPrompt string `json:"wrapup_prompt"` // 收尾提示词(超时/步数耗尽时的 settlement 提示);空=用代码内置默认 WrapupMaxTurns int `json:"wrapup_max_turns"` // 收尾阶段自身的轮数预算;0=用代码内置默认(按 agent) // 任务级超时收尾词(与 per-run 两套;仅 worker/planner 用);空/0=用代码内置默认。 TaskTimeoutWrapupPrompt string `json:"task_timeout_wrapup_prompt"` TaskTimeoutWrapupMaxTurns int `json:"task_timeout_wrapup_max_turns"` // P3 触发后处理策略(仅自定义 agent 有意义): // TriggerRunMode serial|parallel — 串行排队 / 每次触发各自并发一个会话 // TriggerMergeMode by_task|all|none — 仅 serial 用:同任务合并 / 全部合并 / 不合并 // TriggerMaxParallel — 仅 parallel 用的每 agent 并发上限;0=不限 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 可空:未绑定时为 NULL 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 · 工具 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 // (持久 PTY 会话) tool family + Bash 提示词联动(见 docs/交互式shell设计.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策略: // runMode(serial|parallel) / mergeMode(by_task|all|none) / maxParallel(parallel 用,0=不限)。 // 枚举做白名单校验,非法值回落默认,避免脏数据把调度 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, "内置默认", "system") return err } // ResetPromptToDefault appends the code-default template as a new version and // points current at it — the explicit "恢复为内置默认" action. func (d *DB) ResetPromptToDefault(agentID int64, tmpl string) (int, error) { return d.SavePrompt(agentID, tmpl, "恢复为内置默认", "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) 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 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); 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() } func (d *DB) SaveMCP(m *MCPServer) (int64, error) { args, env := string(m.Args), string(m.Env) if args == "" { args = "[]" } if env == "" { env = "{}" } if m.ID == 0 { var id int64 err := d.QueryRow(`INSERT INTO mcp_servers(name,transport,command,args,env,url,enabled,insecure) VALUES ($1,$2,NULLIF($3,''),$4,$5,NULLIF($6,''),$7,$8) RETURNING id`, m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure).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 WHERE id=$9`, m.Name, m.Transport, m.Command, args, env, m.URL, m.Enabled, m.Insecure, 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 }