Files
ARTEX/db/config.go
BaaRai 28e6618c71 feat: surface MCP server connect health to operators and agents
Every connect attempt (discovery, refresh, and the per-agent-run wiring) now
records its outcome on the server row: last_connect_at/ok/err, written through
a change-deduped update so per-run wiring does not amplify writes. The MCP page
shows a health chip per card (ok with time, or the failure reason), and a new
read-only mcp_status tool (bound to auto, worker and pentest) lets a worker
notice that a server it expected is down and say so instead of silently
degrading while the cached tool count keeps the card looking healthy.

Closes #38
2026-10-11 22:50:00 +02:00

1112 lines
42 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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"`
// 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
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"` // 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
}