diff --git a/AGENTS.md b/AGENTS.md index e80a1eeb..8f809065 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,7 +102,7 @@ elsewhere; regenerate from `internal/mcp/` when tools change): update_check(check_id, enabled) — enable or disable a health check list_checks(entity_slug, enabled) — list health checks with verdict, probe kind list_executions(entity_slug, status, limit=25) — cursor-paginated execution history - list_entity_sessions(entity_slug) — active Nomos sessions linked to an entity + list_entity_sessions(entity_slug) — active agent sessions linked to an entity get_dashboard_summary() — fleet overview: counts, health, signals, approvals list_approvals(status, entity_slug, limit) — list pending/recent approvals; filter by status (pending, approved, denied) or entity decide_approval(approval_id, decision) — approve or deny a pending execution; calls the same API endpoint as the Approve button in the UI @@ -186,10 +186,10 @@ The DB is the truth. The old wiki files were archived at `archive/knowledge/` ## 6. Acting on the homelab -- **Read state**: use MCP tools. Nomos (the AI agent) is the primary - operator interface — it routes to the MCP tool list in §3 for - observe/orient/decide/act. -- **Actions** (restart, logs, apt, pct exec, or anything else): Nomos calls +- **Read state**: use MCP tools. dsh (DeepSeek Harness, the TypeScript agent + sidecar that replaced Nomos) is the primary operator interface — it routes + to the MCP tool list in §3 for observe/orient/decide/act. +- **Actions** (restart, logs, apt, pct exec, or anything else): dsh calls `run` (the general execution primitive) via MCP. `reversible_low`/read-only actions execute immediately; `config_mutation` and `destructive` actions are queued for operator approval via the App button in the control-room UI or via diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 240e25e9..ffeb5ac7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ repo, see [.agents/dev/CONTRIBUTING.md](.agents/dev/CONTRIBUTING.md). there); this repo is backend-only since the hexagonal refactor Phase 1 ```bash -# Start dependencies (Postgres + Redis). api/nomos require a shared bearer +# Start dependencies (Postgres + Redis). api requires a shared bearer # token — no dev-open bypass — so set one even for local dev. OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d @@ -46,7 +46,6 @@ before the split need one manual reinstall. ``` cmd/oikos/ Single-binary entry point -cmd/nomos/ Nomos MCP client gateway cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) internal/ All Go packages core/ Hexagon core ([ADR 0016](docs/adr/0016-hexagonal-ports-adapters.md)): @@ -79,7 +78,6 @@ compose/ Dockerfiles + Caddy config scripts/ Deploy, watchdog, rollback checks/ Host health-check scripts run over SSH by the scheduler tools/ Client auto-setup scripts (checks) -nomos/ Nomos config, persona, skills .agents/ Agent instruction files + skills plans/ Design documents docs/adr/ Architecture decision records diff --git a/README.md b/README.md index 23d7740b..c174762d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # Oikos Agentic homelab operating system written in Go. Single binary (`cmd/oikos`), -Docker-deployed on mac-mini, with a standalone Nomos MCP agent gateway -(`cmd/nomos`). Manages the **hubris** Proxmox homelab autonomously — observes -state, classifies actions against policy, executes approved procedures over SSH, -learns from outcomes, and escalates when uncertain. +Docker-deployed on mac-mini. Manages the **hubris** Proxmox homelab +autonomously — observes state, classifies actions against policy, executes +approved procedures over SSH, learns from outcomes, and escalates when +uncertain. The agent runtime is [dsh](https://github.com/deepseek-ai/deepseek-harness) +(DeepSeek Harness, TypeScript sidecar) — it replaced the Nomos gateway and +talks to this backend over MCP. **For agents running on enrolled clients:** start with [AGENTS.md](AGENTS.md). **For client machines:** see [CLIENTS.md](CLIENTS.md). @@ -13,12 +15,12 @@ learns from outcomes, and escalates when uncertain. ## Quick start ```bash -# Dev stack (postgres + api + scheduler). The api/nomos -# services need a shared token — every route requires a real bearer +# Dev stack (postgres + api + scheduler). The api +# service needs a token — every route requires a real bearer # credential, there's no dev-open bypass. OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile dev up -d -# Full stack (adds Nomos agent gateway) +# Full stack (adds execution worker + Infisical) OIKOS_MCP_BEARER_TOKEN=dev-token docker compose --profile full up -d # Build standalone binary @@ -39,8 +41,8 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa ┌──────────────────────────────────┐ │ mac-mini (Docker) │ │ │ - Workstation ─── │ nomos (8092) ──MCP── api (8090) │ - (mesh) │ MCP gateway REST + MCP │ + Workstation ─── │ dsh (3080) ──MCP── api (8090) │ + (mesh) │ agent runtime REST + MCP │ │ │ │ scheduler ─── postgres │ │ (observe) (Timescale) │ @@ -51,7 +53,7 @@ OIKOS_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disa |-----------|------|------| | `oikos api` | 8090 | REST API + MCP server (tool list in [AGENTS.md §3](AGENTS.md#3-the-mcp-server)) | | `oikos scheduler` | — | Probe runner, signal lifecycle, metrics | -| `nomos serve` | 8092 | MCP client gateway, query routing | +| dsh | 3080 | Agent runtime (DeepSeek Harness sidecar, own workspace) | ## Phases @@ -110,7 +112,7 @@ The control-room SPA and the Wails desktop wrapper live in their own repo, [dtoro/oikos-web](https://git.hubris.network/dtoro/oikos-web) (local checkout `~/Projects/oikos-web`) — extracted in Phase 1 of [plans/2026-08-15-hexagonal-architecture.md](plans/2026-08-15-hexagonal-architecture.md). -The SPA talks to `api`/`nomos` over HTTP with a bearer token entered on +The SPA talks to `api` over HTTP with a bearer token entered on first launch. It deploys as its own compose project publishing `8091:80`; the outer Caddy (LXC 121) targets that published port, so serving and auth are unchanged from the pre-split stack. @@ -119,7 +121,6 @@ are unchanged from the pre-split stack. ``` cmd/oikos/ Go entry point — single binary -cmd/nomos/ Nomos MCP client gateway cmd/webhook/ Gitea deploy-webhook receiver (push-to-deploy on mac-mini) internal/ Go packages (actuator, checkdefaults, config, core, db, domain, httpapi, knowledge, learning, mcp, observability, @@ -133,7 +134,6 @@ checks/ Host health-check scripts run over SSH by the scheduler tools/ Client auto-setup scripts (checks) ssh/ Deploy keys + authorized_keys management vps/ Caddy/TURN config templates for the netbird VPS -nomos/ Nomos config, persona, skills .agents/ Agent instruction files, shared conventions, skills plans/ Design documents (active + done) docs/adr/ Architecture decision records diff --git a/cmd/nomos/agent.go b/cmd/nomos/agent.go deleted file mode 100644 index b7968816..00000000 --- a/cmd/nomos/agent.go +++ /dev/null @@ -1,1002 +0,0 @@ -package main - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "strings" - "time" - - "github.com/dtoro/oikos/internal/nomos/assent" - "github.com/dtoro/oikos/internal/nomos/messagequeue" - "github.com/dtoro/oikos/internal/nomos/retrycap" - "github.com/dtoro/oikos/internal/nomos/session" - "github.com/dtoro/oikos/internal/nomos/turngate" - "github.com/google/uuid" - "github.com/openai/openai-go" - "github.com/openai/openai-go/option" - "github.com/openai/openai-go/shared" -) - -// maxIterations bounds one chat turn's tool-calling loop. Provisioning a -// service is a long chain (research → plan → run → per-step -// install/verify run calls), so this must be generous; a full deploy with the -// decomposed pct_create flow can legitimately need many steps. On exhaustion -// the loop now produces a real summary (finalSummary) rather than a dead end. -const maxIterations = 40 -const maxLLMRetries = 3 - -// historyWindowSize bounds how many of a session's most recent persisted -// messages are replayed into the LLM's context on each turn — see -// store.go's getRecentMessages for why this exists (fix A2 of -// plans/2026-07-11-nomos-agent-code-review.md: unbounded history replay was -// a real, observed-in-production cost/latency/eventual-context-limit risk). -// 30 is a fixed-window choice, not token-budget-aware: simplest option that -// still keeps roughly the current task's working context, at the cost of -// occasionally dropping something a very long task still needed — the -// system note injected when truncation happens tells the model to check -// upsert_knowledge/search_knowledge rather than assume something didn't -// happen. A token-aware trim or LLM-summarize-on-drop are documented -// stretch options if a fixed window proves insufficient in practice. -const historyWindowSize = 30 - -var refusalDenylist = []string{ - "我没有相关信息", - "您可以尝试问我其它问题", - "我无法", - "抱歉,我无法", - "关于这个问题,我没有", -} - -type agent struct { - clients *mcpClientPool // one MCP client PER SESSION, not shared — see mcpClientPool's doc comment - system string - provider *openai.Client - model string - store *session.Store - agentID uuid.UUID - reqOpts []option.RequestOption - apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals - apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass) - httpClient *http.Client - // gate serializes turns per session (at most one in-flight turn per - // sessionID). See turngate.go and plan 2026-08-03 F1. - gate *turngate.TurnGate - // queue holds operator messages that arrived while a turn was already - // running; they are auto-run when the gate frees (plan 2026-08-03 F2). - // See messagequeue.go. - queue *messagequeue.MessageQueue -} - -func newAgent(ctx context.Context, clients *mcpClientPool, st *session.Store, agentSlug string, openrouterAPIKey string) (*agent, error) { - system := loadSoul() - apiKey := openrouterAPIKey - model := os.Getenv("NOMOS_MODEL") - if model == "" { - // v4-pro over v4-flash: the flash tier over-narrates, occasionally - // emits canned refusals, and is unreliable at multi-step tool use — - // exactly the agentic provisioning path the operator needs to work. - model = "deepseek/deepseek-v4-pro" - } - - provider := openai.NewClient( - option.WithBaseURL("https://openrouter.ai/api/v1"), - option.WithAPIKey(apiKey), - ) - - agentID := st.ResolveAgentID(ctx, agentSlug) - if agentID == uuid.Nil { - slog.Warn("nomos: agent entity not found; tool-call activity will not be logged", "slug", agentSlug) - } - - // OpenRouter provider routing. data_collection=deny pins to zero-data- - // retention providers (privacy: conversations + tool results transit - // OpenRouter); require_parameters ensures the routed provider actually - // supports tool calling. NOMOS_PROVIDER_SORT (price|throughput|latency) - // and Exacto tool-accuracy routing are opt-in — the latter via a model - // suffix in NOMOS_MODEL (e.g. "deepseek/deepseek-v4-flash:exacto"), so an - // unsupported value never silently breaks the confirmed routing below. - providerRouting := map[string]any{ - "data_collection": "deny", - "require_parameters": true, - } - if sort := os.Getenv("NOMOS_PROVIDER_SORT"); sort != "" { - providerRouting["sort"] = sort - } - reqOpts := []option.RequestOption{option.WithJSONSet("provider", providerRouting)} - - // Derive the oikos HTTP API base from the MCP URL (e.g. - // "http://api:8090/mcp?session_id=..." -> "http://api:8090"). Used for - // chat-assent approvals, which call the same decision endpoint the UI's - // Approve button calls. - mcpURL := os.Getenv("NOMOS_MCP_URL") - apiBase := "" - if idx := strings.Index(mcpURL, "/mcp"); idx > 0 { - apiBase = mcpURL[:idx] - } - - return &agent{ - clients: clients, - system: system, - provider: &provider, - model: model, - store: st, - agentID: agentID, - reqOpts: reqOpts, - apiBase: apiBase, - apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"), - httpClient: &http.Client{Timeout: 15 * time.Second}, - gate: turngate.New(), - queue: messagequeue.New(), - }, nil -} - -func loadSoul() string { - paths := []string{"/app/nomos/SOUL.md", "nomos/SOUL.md"} - for _, p := range paths { - if data, err := os.ReadFile(p); err == nil { - return string(data) - } - } - return `You are Nomos, the steward of the oikos — the AI agent for the hubris homelab. -You have access to MCP tools to query topology, health, knowledge, and request -gated mutations through run. Be concise. Prefer tools over guessing.` -} - -// assentWindowDuration is how long after an operator approves a plan that -// config_mutation commands auto-run without re-approval. The operator -// approved the plan; the agent should execute it end-to-end without -// stopping every step to re-ask. Destructive actions still always need -// explicit typed confirmation regardless of the window. -const assentWindowDuration = 30 * time.Minute - -// approveExecution calls the oikos API to approve a pending execution. -// Returns true + new status on success, false on any error. -func (a *agent) approveExecution(ctx context.Context, execID string) (bool, string, error) { - if a.apiBase == "" || a.apiToken == "" { - return false, "", fmt.Errorf("nomos: apiBase or apiToken not configured") - } - body, _ := json.Marshal(map[string]string{"status": "approved"}) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, - a.apiBase+"/api/v1/approvals/"+execID+"/decision", bytes.NewReader(body)) - if err != nil { - return false, "", fmt.Errorf("nomos: create approval request: %w", err) - } - req.Header.Set("Authorization", "Bearer "+a.apiToken) - req.Header.Set("Content-Type", "application/json") - resp, err := a.httpClient.Do(req) - if err != nil { - return false, "", fmt.Errorf("nomos: approval request failed: %w", err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, "", fmt.Errorf("nomos: approval %s not found", execID) - } - if resp.StatusCode != http.StatusOK { - return false, "", fmt.Errorf("nomos: approval %s returned %d", execID, resp.StatusCode) - } - var result struct { - Status string `json:"status"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return false, "", fmt.Errorf("nomos: decode approval response: %w", err) - } - return true, result.Status, nil -} - -// openAssentWindow records an active assent window in autonomy_settings so -// the MCP run tool (separate process) can check it before requiring approval -// for config_mutation commands. Key is scoped to this agent's UUID AND this -// session/task — see store.go's assentWindowActive for why: without the -// session dimension, approving one task's plan would silently auto-run -// unapproved actions in any other concurrently-running task. -func (a *agent) openAssentWindow(ctx context.Context, sessionID string) { - if a.store == nil || a.agentID == uuid.Nil || sessionID == "" { - return - } - key := session.AssentWindowKey(a.agentID, sessionID) - expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339) - _, err := a.store.Exec(ctx, - `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2`, key, expires) - if err != nil { - slog.Warn("nomos: openAssentWindow", "error", err) - } else { - slog.Info("nomos: assent window opened", "agent", a.agentID, "session", sessionID, "expires", expires) - } -} - -type toolDef struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"inputSchema"` -} - -type agentEvent struct { - Type string `json:"type"` - Data any `json:"data,omitempty"` - SessionID string `json:"session_id,omitempty"` - Iteration int `json:"iteration,omitempty"` - // IsThinking marks text/text_delta events that carry the model's internal - // reasoning (text produced before tool calls in the same iteration), as - // distinct from the final response text. The frontend renders these as - // collapsible thinking blocks separated from the response. - IsThinking bool `json:"is_thinking,omitempty"` -} - -func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) { - a.chatWith(ctx, sessionID, message, "", emit) -} - -// chatWith is chat() with an optional system-injected note appended after the -// replayed history. The auto-continuation worker uses it to resume a session -// with a finished execution's result ("execution X completed: … — continue the -// plan") without persisting a fake user turn. message is normally the new user -// message; for a worker continuation it is empty and systemInject carries the -// note. -func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) { - correlationID := uuid.New().String() - - // emitError emits an error event followed by a done event. The done - // event is CRITICAL on every terminal path: the frontend's - // onComplete handler (chat.ts) treats a missing `done` as a severed - // network connection and triggers an auto-reconnect → resumeSession. - // Before this fix, a model empty-response (the most common case here) - // returned without `done`, was misclassified as a network drop, and - // the reconnect logic re-invoked the agent with a generic "report - // your state" note — which caused the agent to re-propose the plan - // and duplicate it in the sidebar (operator-reported 2026-07-14). - // Every error return below must go through emitError so the frontend - // shows the error inline instead of silently reconnecting. - emitError := func(data string) { - emit(agentEvent{Type: "error", Data: data, SessionID: sessionID}) - emit(agentEvent{Type: "done", Data: map[string]any{ - "session_id": sessionID, - "correlation_id": correlationID, - "iterations": 0, - "error": true, - }, SessionID: sessionID}) - } - - tools, err := a.buildTools(sessionID) - if err != nil { - emitError(fmt.Sprintf("build tools: %v", err)) - return - } - - system := a.system - if snapshot := a.fleetSnapshot(sessionID); snapshot != "" { - system += "\n\n" + snapshot - } - messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)} - history, truncatedHistory, _ := a.store.GetRecentMessages(ctx, sessionID, historyWindowSize) - if truncatedHistory { - // Tell the model explicitly rather than silently dropping older - // turns — otherwise it might assume something wasn't done just - // because it doesn't see the turn that did it. - messages = append(messages, openai.SystemMessage(fmt.Sprintf( - "[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]", - historyWindowSize))) - } - // sawSetGoal / sawCompleteTask track whether this session has EVER framed - // itself as a structured task (set_goal) or already reached a terminal - // state (complete_task) — across both replayed history and this turn's - // own tool calls (updated again below as they happen live). Used by the - // end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md, - // fix 1): most sessions are a single trivial Q&A exchange that answers in - // text and never calls either tool, leaving agent_sessions.status stuck - // at its creation-time default forever. If a session never framed itself - // as a task, its first plain-text turn-end IS the task ending. - var sawSetGoal, sawCompleteTask bool - var lastAssistantCalls []persistedCall - for _, m := range history { - text := extractText(m.Content) - switch m.Role { - case "user": - messages = append(messages, openai.UserMessage(text)) - case "assistant": - if calls := extractToolCalls(m.Content); len(calls) > 0 { - messages = append(messages, assistantToolCallMessage(calls)) - for _, c := range calls { - messages = append(messages, openai.ToolMessage(c.resultText(), c.id)) - switch c.name { - case "set_goal": - sawSetGoal = true - case "complete_task": - sawCompleteTask = true - } - } - lastAssistantCalls = calls - } - if text != "" { - messages = append(messages, openai.AssistantMessage(text)) - } - } - } - if len(history) == 0 { - messages = append(messages, openai.UserMessage(message)) - } - - // Chat-assent approval: if the immediately-preceding assistant turn - // proposed gated action(s) and the operator's new message reads as - // authorization ("go ahead", "yes", ...), grant them now — this is the - // primary approval path; the Approve button in the UI is a fallback for - // when the operator wants to click instead of type. Destructive-risk - // actions are never granted by loose assent — they need the stricter - // isTypedConfirmation ("I confirm ...", per SOUL.md's guidance for what - // to ask the operator to type). - pending := assent.ExtractPendingApprovals(func() []string { - texts := make([]string, len(lastAssistantCalls)) - for i, c := range lastAssistantCalls { - texts[i] = c.resultText() - } - return texts - }()) - operatorAssented := assent.IsAssent(message) - typedConfirm := assent.IsTypedConfirmation(message) - if len(pending) > 0 && (operatorAssented || typedConfirm) { - var granted, blocked []string - for _, p := range pending { - if p.Destructive && !typedConfirm { - blocked = append(blocked, p.ExecID) - continue - } - if !p.Destructive && !operatorAssented { - continue // typed-confirm alone doesn't grant a non-destructive item without also reading as assent - } - ok, status, aerr := a.approveExecution(ctx, p.ExecID) - if aerr != nil { - slog.Error("nomos: chat-assent approve", "execution", p.ExecID, "error", aerr) - continue - } - if ok { - granted = append(granted, p.ExecID) - slog.Info("nomos: chat-assent granted", "execution", p.ExecID, "status", status, "session", sessionID) - - // An explicit typed confirmation for a destructive action - // opens a short, target-scoped window so the rest of a - // destructive recovery sequence on the SAME target (e.g. - // stop -> destroy) doesn't need a second typed confirmation. - if p.Destructive && typedConfirm { - if execUUID, perr := uuid.Parse(p.ExecID); perr == nil { - if target := a.store.ExecutionTarget(ctx, execUUID); target != "" { - a.store.OpenDestructiveWindow(ctx, a.agentID, target, sessionID) - slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID) - } - } - } - } - } - if len(granted) > 0 { - a.openAssentWindow(ctx, sessionID) - // Mark approved executions as continued so the continuation - // worker doesn't call resumeSession while the chat handler is - // still processing "go ahead" — two concurrent LLM calls for the - // same session cause empty responses and race conditions. - for _, execID := range granted { - if execUUID, perr := uuid.Parse(execID); perr == nil { - a.store.MarkContinued(ctx, execUUID) - } - } - // No system note. The model already sees "go ahead" in the - // replayed history (the user message was saved to the DB before - // chat() was called). The old note said "they are now running" - // which made the model think work was being done for it — - // causing empty responses (finish_reason=stop, content_len=0). - // The approved executions are dispatched; the model will - // continue with the remaining plan steps naturally. - } - if len(blocked) > 0 { - note := fmt.Sprintf("[System: execution(s) %s are classified DESTRUCTIVE and were NOT approved by loose assent — you must ask the operator for an explicit typed confirmation before they can run. Once they do confirm, further destructive steps on that SAME target (e.g. finishing a stop-then-destroy sequence) will auto-run for 15 minutes without asking again — but a different target always needs its own confirmation.]", strings.Join(blocked, ", ")) - messages = append(messages, openai.SystemMessage(note)) - } - } else if operatorAssented && len(pending) == 0 { - // The operator said "proceed"/"go ahead"/"yes" but there are no - // pending approvals — the agent proposed a plan (via propose_plan) - // and asked "shall I?" Open the assent window silently. No system - // note: the model sees "go ahead" in the replayed history and - // responds naturally. - a.openAssentWindow(ctx, sessionID) - } - - // Worker continuation: append the finished-execution note so the model - // sees the result and decides the next step (proceed / recover / done). - if systemInject != "" { - messages = append(messages, openai.SystemMessage(systemInject)) - } - - // Retry cap (P0.1 from plans/2026-07-18-session-review-three-sessions.md): - // track failing `run` calls within this turn so an identical command that - // keeps failing is refused after retrycap.MaxRunRetries attempts. Without this, - // session 1e9c7691 retried the same `chown` ~20 times, each retry piling - // up a zombie process on the target (knfsd was holding a kernel lock). - // The tracker is per-turn — a fresh turn after the operator responds can - // retry once more, so this doesn't permanently block recovery. - retries := retrycap.New() - - for i := 0; i < maxIterations; i++ { - params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(a.model), - Messages: messages, - Tools: tools, - } - - var msg openai.ChatCompletionMessage - var acc openai.ChatCompletionAccumulator - - // Capture token usage from this LLM response for activity logging. - // Previously always NULL — every agent_activity row had no token - // count. Now each tool call in this iteration gets the same total. - totalTokens := 0 - - for attempt := 0; attempt <= maxLLMRetries; attempt++ { - acc = openai.ChatCompletionAccumulator{} - stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...) - for stream.Next() { - chunk := stream.Current() - acc.AddChunk(chunk) - if len(chunk.Choices) > 0 { - if delta := chunk.Choices[0].Delta.Content; delta != "" { - emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1}) - } - } - } - if err := stream.Err(); err != nil { - if attempt < maxLLMRetries { - slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID) - continue - } - emitError(fmt.Sprintf("llm: %v", err)) - return - } - if len(acc.Choices) == 0 { - if attempt < maxLLMRetries { - slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID) - continue - } - emitError("no choices in response (the model returned zero completions — likely a provider or rate-limit issue)") - return - } - - msg = acc.Choices[0].Message - finishReason := acc.Choices[0].FinishReason - - // Capture token usage from this iteration. - if acc.Usage.TotalTokens > 0 { - totalTokens = int(acc.Usage.TotalTokens) - } - - if len(msg.ToolCalls) == 0 { - if isRefusalOrEmpty(msg.Content) { - if attempt < maxLLMRetries { - slog.Warn("nomos: empty or refusal response, retrying", - "session", sessionID, "iter", i+1, "attempt", attempt+1, - "content_len", len(msg.Content), "finish_reason", finishReason) - continue - } - // B.4: surface the real error context (finish_reason + - // refusal text) instead of a generic "empty response" — - // the operator can tell "content_filter — rephrase" from - // "length — token limit hit" from "stop — model no-op'd". - detail := "empty response" - if msg.Refusal != "" { - detail = fmt.Sprintf("refusal: %s", msg.Refusal) - } else if finishReason != "" && finishReason != "stop" { - detail = fmt.Sprintf("finish_reason=%s", finishReason) - } - emitError(fmt.Sprintf("Nomos returned an empty or unusable response (%s). Retry or rephrase.", detail)) - return - } - } - break - } - - if len(msg.ToolCalls) == 0 { - emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) - if !sawSetGoal && !sawCompleteTask { - a.autoCompleteTrivialTask(ctx, sessionID, msg.Content) - } - // Safety net: if the agent called set_goal (structured task) - // but didn't call complete_task, and all plan steps are - // terminal, auto-complete. The model often does the work but - // forgets to close the loop (confirmed live: the #1 remaining - // model reliability gap after D.1). - if !sawCompleteTask { - a.autoCompleteIfPlanDone(ctx, sessionID, msg.Content) - } - emit(agentEvent{Type: "done", Data: map[string]any{ - "session_id": sessionID, - "usage": acc.Usage, - "correlation_id": correlationID, - "iterations": i + 1, - }, SessionID: sessionID}) - return - } - - // P3: persist intermediate reasoning. When the model produces text - // AND tool calls in the same iteration, the text is its reasoning - // before the tool calls — the operator saw it live via text_delta, - // but without emitting it as a `text` event here, the persist layer - // (main.go/continue.go) never captures it and a reload shows only - // the final summary + a flat tool-call list, not the thinking that - // led to each step. Emitting it lets the persist layer accumulate - // per-iteration reasoning into the row's text field. - if strings.TrimSpace(msg.Content) != "" { - emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID, IsThinking: true}) - } - - slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID) - - messages = append(messages, msg.ToParam()) - - for _, tc := range msg.ToolCalls { - var args map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{} - } - - switch tc.Function.Name { - case "set_goal": - sawSetGoal = true - case "complete_task": - sawCompleteTask = true - } - - // Retry cap: if this `run` call has already failed - // retrycap.MaxRunRetries times this turn with the same (target, - // command), refuse to dispatch it again. Return a synthetic - // tool result directing the agent to investigate *why* the - // command hangs instead of retrying. See retrycap.go and - // plans/2026-07-18-session-review-three-sessions.md P0.1. - if tc.Function.Name == "run" { - t, _ := args["target"].(string) - c, _ := args["command"].(string) - key := retrycap.RunFailureKey(t, c) - if n := retries.Failures(key); n >= retrycap.MaxRunRetries { - directive := retrycap.RunRetryDirective(t, c, n) - slog.Warn("nomos: run retry cap hit — refusing dispatch", - "target", t, "failures", n, "session", sessionID) - a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, - tc.Function.Arguments, directive, 0, false, correlationID, totalTokens) - emit(agentEvent{ - Type: "tool_result", - Data: map[string]any{"name": tc.Function.Name, "result": directive, "id": tc.ID, "retry_capped": true}, - SessionID: sessionID, - Iteration: i + 1, - }) - messages = append(messages, openai.ToolMessage(directive, tc.ID)) - continue - } - } - - emit(agentEvent{ - Type: "tool_use", - Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID}, - SessionID: sessionID, - Iteration: i + 1, - }) - - start := time.Now() - // Session-scoped task tools are handled in-process; everything else - // is forwarded to the shared MCP server. - var result any - var callErr error - if localRes, handled := a.handleTaskTool(ctx, sessionID, tc.Function.Name, args); handled { - result = localRes - } else { - // _session_id rides along on the wire call only — never in - // `args` (which is what gets emitted/logged/persisted as the - // model's own tool call) — so the MCP-side assent/destructive - // window checks can scope to THIS task instead of bleeding - // across every concurrently-running one sharing this agent - // identity. Not part of any tool's declared InputSchema, so - // the model never sees or supplies it. - wireArgs := make(map[string]any, len(args)+1) - for k, v := range args { - wireArgs[k] = v - } - wireArgs["_session_id"] = sessionID - var client *mcpClient - client, callErr = a.clients.get(sessionID) - if callErr == nil { - result, callErr = client.callTool(tc.Function.Name, wireArgs) - } - } - elapsed := int(time.Since(start).Milliseconds()) - - inputJSON, _ := json.Marshal(args) - inputStr := string(inputJSON) - - if callErr != nil { - a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID, totalTokens) - - // Retry cap: dispatch errors (e.g. MCP client timeout) - // count toward the cap too. A command that keeps timing - // out at the gateway is exactly the pattern we want to - // break — see session 1e9c7691's 20+ identical - // `chown` timeouts. - if tc.Function.Name == "run" { - t, _ := args["target"].(string) - c, _ := args["command"].(string) - key := retrycap.RunFailureKey(t, c) - n := retries.RecordFailure(key) - if n >= retrycap.MaxRunRetries { - slog.Warn("nomos: run failure cap reached — next identical call will be refused", - "target", t, "failures", n, "session", sessionID) - } - } - - emit(agentEvent{ - Type: "tool_result", - Data: map[string]any{"name": tc.Function.Name, "error": callErr.Error(), "id": tc.ID}, - SessionID: sessionID, - Iteration: i + 1, - }) - messages = append(messages, openai.ToolMessage(callErr.Error(), tc.ID)) - slog.Error("nomos: tool error", "tool", tc.Function.Name, "error", callErr, "ms", elapsed) - continue - } - - resultJSON, _ := json.Marshal(result) - a.store.LogActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID, totalTokens) - - // Link any execution this tool queued/started back to this - // session, so the auto-continuation worker can feed its result - // back here when it finishes (see cmd/nomos/continue.go). Async - // executions (pct_create, apt_upgrade) are the ones that matter — - // their result lands after this turn ends. - for _, execID := range extractExecutionIDs(string(resultJSON)) { - a.store.LinkExecution(ctx, execID, sessionID) - } - - // Record which entities this task touched (task —involves→ entity) - // and pulse them on the live context panel. Args only — never - // results — so a bulk query doesn't drag the whole fleet in. - a.store.RecordTouched(ctx, sessionID, tc.Function.Name, args) - - // When the agent records knowledge, link that note to this task so - // the task's outcome view shows what it learned (and pulse it live). - if tc.Function.Name == "upsert_knowledge" { - a.store.LinkKnowledgeToTask(ctx, sessionID, string(resultJSON)) - } - - emit(agentEvent{ - Type: "tool_result", - Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID}, - SessionID: sessionID, - Iteration: i + 1, - }) - messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID)) - slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed) - - // Retry cap: record failures of `run` calls so the cap above - // can refuse a repeated identical failure. A "failure" here - // means the dispatch errored OR the MCP result text matches - // the "run on : ERROR …" signature — both indicate - // the command actually ran and failed, not just that it - // queued for approval (pending approvals are not failures). - // Pass the RAW result text (not JSON-encoded) so the helper's - // HasPrefix check sees "run on …" not "\"run on …\"". - if retrycap.IsRunFailure(tc.Function.Name, retrycap.RunResultText(result), callErr) { - t, _ := args["target"].(string) - c, _ := args["command"].(string) - key := retrycap.RunFailureKey(t, c) - n := retries.RecordFailure(key) - if n >= retrycap.MaxRunRetries { - slog.Warn("nomos: run failure cap reached — next identical call will be refused", - "target", t, "failures", n, "session", sessionID) - } - } - - // ask_operator pauses the task: the agent has posed a decision only - // the operator can make. End the turn here so it doesn't barrel past - // its own question — the answer (panel or chat reply) resumes it. - // The prompt becomes the assistant's visible message so the question - // also shows inline in the transcript. - if tc.Function.Name == "ask_operator" { - prompt, _ := args["prompt"].(string) - emit(agentEvent{Type: "text", Data: prompt, SessionID: sessionID}) - emit(agentEvent{Type: "done", Data: map[string]any{ - "session_id": sessionID, - "correlation_id": correlationID, - "iteration": i + 1, - }, SessionID: sessionID}) - return - } - } - } - - // Hitting the step limit used to end the turn with a bare "max iterations - // reached without final answer" — a dead end that made the operator ask - // "status?" to find out what actually happened after a long working turn. - // Instead, spend one final call asking the model to summarize what it did - // and the current state, so the turn always ends with a real report. - messages = append(messages, openai.SystemMessage("[System: you've reached the step limit for this turn. STOP calling tools now and write a concise status report: what you accomplished, the current state of the goal, anything that failed, and what remains. This is what the operator sees.]")) - summary := a.finalSummary(ctx, messages) - if summary == "" { - summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state." - } - emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID}) - if !sawCompleteTask { - a.autoCompleteIfPlanDone(ctx, sessionID, summary) - } - emit(agentEvent{Type: "done", Data: map[string]any{ - "session_id": sessionID, - "correlation_id": correlationID, - "iterations": maxIterations, - }, SessionID: sessionID}) -} - -// finalSummary makes one non-tool LLM call to turn an exhausted tool-loop into -// a real status report instead of a dead-end message. Best-effort: empty on -// any error, and the caller has a fallback. -func (a *agent) finalSummary(ctx context.Context, messages []openai.ChatCompletionMessageParamUnion) string { - params := openai.ChatCompletionNewParams{ - Model: openai.ChatModel(a.model), - Messages: messages, - // No Tools: force a text answer. - } - resp, err := a.provider.Chat.Completions.New(ctx, params, a.reqOpts...) - if err != nil || len(resp.Choices) == 0 { - return "" - } - return resp.Choices[0].Message.Content -} - -// extractText pulls the "text" field from a persisted message's JSONB content. -func extractText(content json.RawMessage) string { - var m struct { - Text string `json:"text"` - } - if err := json.Unmarshal(content, &m); err != nil { - return "" - } - return m.Text -} - -// persistedCall is one merged tool_use+tool_result pair from a persisted -// assistant message's tool_calls array. The store keeps them as two entries -// sharing the same id (mirroring the SSE event pair); replay needs one -// entry per id to build a valid tool-calling assistant message. -type persistedCall struct { - id string - name string - args json.RawMessage - result json.RawMessage - errMsg string -} - -func (c persistedCall) resultText() string { - if c.errMsg != "" { - return c.errMsg - } - if len(c.result) > 0 { - return string(c.result) - } - return "null" -} - -// extractToolCalls parses and merges a persisted message's tool_calls array, -// preserving first-seen order across ids. -func extractToolCalls(content json.RawMessage) []persistedCall { - var m struct { - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Args json.RawMessage `json:"args"` - Result json.RawMessage `json:"result"` - Error string `json:"error"` - } `json:"tool_calls"` - } - if err := json.Unmarshal(content, &m); err != nil || len(m.ToolCalls) == 0 { - return nil - } - - byID := make(map[string]*persistedCall, len(m.ToolCalls)) - var order []string - for _, tc := range m.ToolCalls { - if tc.ID == "" { - continue - } - pc, ok := byID[tc.ID] - if !ok { - pc = &persistedCall{id: tc.ID} - byID[tc.ID] = pc - order = append(order, tc.ID) - } - if tc.Name != "" { - pc.name = tc.Name - } - if len(tc.Args) > 0 && string(tc.Args) != "null" { - pc.args = tc.Args - } - if tc.Type == "tool_result" { - pc.errMsg = tc.Error - pc.result = tc.Result - } - } - - calls := make([]persistedCall, 0, len(order)) - for _, id := range order { - calls = append(calls, *byID[id]) - } - return calls -} - -// assistantToolCallMessage builds the tool-calling assistant message that -// must precede the tool-role results being replayed. -func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessageParamUnion { - toolCalls := make([]openai.ChatCompletionMessageToolCallParam, 0, len(calls)) - for _, c := range calls { - args := string(c.args) - if args == "" { - args = "{}" - } - toolCalls = append(toolCalls, openai.ChatCompletionMessageToolCallParam{ - ID: c.id, - Function: openai.ChatCompletionMessageToolCallFunctionParam{ - Name: c.name, - Arguments: args, - }, - }) - } - return openai.ChatCompletionMessageParamUnion{ - OfAssistant: &openai.ChatCompletionAssistantMessageParam{ToolCalls: toolCalls}, - } -} - -// fleetSnapshot returns a compact, current-as-of-now fleet health line for -// the system prompt so the agent starts each turn already oriented instead -// of spending its first iteration rediscovering topology it already has -// tools to query. Best-effort: an empty string on any failure just means no -// snapshot, not an error for the turn. -func (a *agent) fleetSnapshot(sessionID string) string { - client, err := a.clients.get(sessionID) - if err != nil { - return "" - } - result, err := client.callTool("get_health_summary", map[string]any{}) - if err != nil { - return "" - } - rows, ok := result.([]any) - if !ok { - return "" - } - - counts := map[string]int{} - var attention []string - for _, r := range rows { - row, ok := r.(map[string]any) - if !ok { - continue - } - health, _ := row["health"].(string) - counts[health]++ - if health != "healthy" && health != "" { - if slug, ok := row["slug"].(string); ok && len(attention) < 10 { - attention = append(attention, fmt.Sprintf("%s(%s)", slug, health)) - } - } - } - if len(counts) == 0 { - return "" - } - - summary := fmt.Sprintf("Current fleet snapshot (as of now): healthy=%d degraded=%d down=%d stale=%d unknown=%d.", - counts["healthy"], counts["degraded"], counts["down"], counts["stale"], counts["unknown"]) - if len(attention) > 0 { - summary += " Needs attention: " + fmt.Sprintf("%v", attention) + "." - } - return summary -} - -// isRefusalOrEmpty returns true when the LLM response is blank or looks like a -// canned non-English refusal to an English-language conversation. Flash-tier -// models occasionally emit Chinese boilerplate deflection instead of a real -// answer; this catches it before it reaches the UI. -func isRefusalOrEmpty(text string) bool { - if strings.TrimSpace(text) == "" { - return true - } - ascii, nonASCII := 0, 0 - for _, r := range text { - if r <= 127 { - ascii++ - } else { - nonASCII++ - } - } - if nonASCII > ascii { - return true - } - for _, pattern := range refusalDenylist { - if strings.Contains(text, pattern) { - return true - } - } - return false -} - -func (a *agent) buildTools(sessionID string) ([]openai.ChatCompletionToolParam, error) { - client, err := a.clients.get(sessionID) - if err != nil { - return nil, err - } - defs, err := client.listToolsFull() - if err != nil { - return nil, err - } - // Append nomos-local, session-scoped task tools (complete_task, …) to the - // MCP tool list. They're routed to handleTaskTool, not the MCP client. - defs = append(defs, taskToolDefs()...) - - var tools []openai.ChatCompletionToolParam - for _, d := range defs { - params := shared.FunctionParameters(d.InputSchema) - if params == nil { - params = shared.FunctionParameters{"type": "object", "properties": map[string]any{}} - } - - tools = append(tools, openai.ChatCompletionToolParam{ - Type: "function", - Function: shared.FunctionDefinitionParam{ - Name: d.Name, - Description: openai.String(d.Description), - Parameters: params, - }, - }) - } - return tools, nil -} - -// listToolsFull returns the MCP server's tool list, cached on this client -// after the first call (see mcpClient.toolsCache). Fix F1 of -// plans/2026-07-11-nomos-agent-code-review.md: buildTools calls this at the -// start of every chat turn, including every auto-continuation resume — the -// tool list is static for the lifetime of one MCP connection, so re-fetching -// it every single time was avoidable network+parsing work on the hot path. -// Cache invalidates on reconnectLocked (an api restart may change what's -// registered). -func (c *mcpClient) listToolsFull() ([]toolDef, error) { - c.toolsMu.Lock() - if c.toolsCache != nil { - cached := c.toolsCache - c.toolsMu.Unlock() - return cached, nil - } - c.toolsMu.Unlock() - - resp, err := c.doRequest("tools/list", map[string]any{}) - if err != nil { - return nil, err - } - var tr struct { - Tools []struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"inputSchema"` - } `json:"tools"` - } - if err := json.Unmarshal(resp.Result, &tr); err != nil { - return nil, err - } - out := make([]toolDef, len(tr.Tools)) - for i, t := range tr.Tools { - out[i] = toolDef{ - Name: t.Name, - Description: t.Description, - InputSchema: t.InputSchema, - } - } - - c.toolsMu.Lock() - c.toolsCache = out - c.toolsMu.Unlock() - return out, nil -} diff --git a/cmd/nomos/continue.go b/cmd/nomos/continue.go deleted file mode 100644 index b92c1046..00000000 --- a/cmd/nomos/continue.go +++ /dev/null @@ -1,402 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "regexp" - "strings" - "time" - - "github.com/dtoro/oikos/internal/nomos/session" - "github.com/dtoro/oikos/internal/safego" - "github.com/google/uuid" -) - -// execIDRe matches "execution " in a tool result — the phrasing shared -// by request_execution / run when they queue or start a gated execution. -// Only these async executions need continuation; the synchronous auto-run -// path returns its output inline and is already observed in-turn. -var execIDRe = regexp.MustCompile(`(?i)execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`) - -func extractExecutionIDs(toolResult string) []uuid.UUID { - matches := execIDRe.FindAllStringSubmatch(toolResult, -1) - seen := map[uuid.UUID]bool{} - var out []uuid.UUID - for _, m := range matches { - if id, err := uuid.Parse(m[1]); err == nil && !seen[id] { - seen[id] = true - out = append(out, id) - } - } - return out -} - -// idleTaskThreshold is how long a goal-bearing session can sit non-terminal -// with no activity before the idle sweep nudges it, per -// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point, -// not measured against real task durations — long enough that it won't fire -// mid-turn, short enough the board doesn't lie for hours. -const idleTaskThreshold = 15 * time.Minute - -// runIdleSweepWorker is the safety net for case 2 of -// plans/2026-07-11-task-completion-safety-net.md: sessions that called -// set_goal (so the inline safety net in agent.go correctly left them alone, -// since they framed themselves as a real task) but then stalled without -// ever calling complete_task. Coarser than runContinuationWorker's 4s tick -// since "gone idle" is a much slower signal than "an execution just -// finished." Blocks until ctx is cancelled. -func (a *agent) runIdleSweepWorker(ctx context.Context) { - if a.store == nil { - slog.Warn("nomos: idle sweep worker disabled (no store)") - return - } - slog.Info("nomos: idle sweep worker started") - ticker := time.NewTicker(2 * time.Minute) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - a.processIdleSweep(ctx) - } - } -} - -// processIdleSweep nudges a stalled goal-bearing session once; if it's still -// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered, -// not just that the model is still working), auto-closes it with a -// visible "auto-closed" outcome instead of leaving it stuck forever — same -// reasoning resumeSession already applies below for a different failure -// mode (a resume that produces no response at all). -func (a *agent) processIdleSweep(ctx context.Context) { - stale := a.store.StaleGoalSessions(ctx, idleTaskThreshold, 5) - for _, s := range stale { - s := s - if s.CompletionNudges == 0 { - safego.Go("nomos:idle-nudge:"+s.ID, func() { - note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+ - "If the goal is done (or can't be completed), call complete_task now with the outcome and a "+ - "one-line summary. If you're still genuinely working through the plan, ignore this and continue.]", - s.Goal, idleTaskThreshold) - note = a.store.EnrichResumeNote(ctx, s.ID, note) - // P1: only count the nudge if it actually delivered. resumeSession - // skips (returns false) when a turn is already active; bumping the - // counter anyway would make the next sweep auto-close a merely-busy - // session as "unanswered." - if a.resumeSession(ctx, s.ID, note) { - if err := a.store.BumpCompletionNudge(ctx, s.ID); err != nil { - slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err) - } - } - }) - continue - } - safego.Go("nomos:idle-autoclose:"+s.ID, func() { - summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold) - if err := a.store.CompleteTask(ctx, s.ID, "partial", summary); err != nil { - slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err) - } - }) - } -} - -// runContinuationWorker is the event loop that replaces the human typing -// "continue". It polls for gated executions that (a) were initiated by a chat -// session and (b) have just finished, and — while that agent has an open assent -// window (an approved plan is in flight) — feeds each result back into the -// agent so it proceeds to the next step or recovers from the failure, all -// without an operator tick. Blocks until ctx is cancelled. -func (a *agent) runContinuationWorker(ctx context.Context) { - if a.store == nil { - slog.Warn("nomos: continuation worker disabled (no store)") - return - } - slog.Info("nomos: continuation worker started") - ticker := time.NewTicker(4 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - a.processContinuations(ctx) - } - } -} - -// processContinuations dispatches each pending item as its OWN goroutine -// (safego.Go, so a panic deep in one task's resumed turn — JSON parsing of -// model output, an unexpected nil in a tool result — is recovered and logged -// instead of taking down this whole function, which used to run every -// item sequentially in the SAME goroutine as the ticker loop. Two problems -// that fixed: (1) throughput — task B's continuation no longer waits for -// task A's full (up to 10-minute) resumed turn to finish first, the exact -// per-task blocking this session's earlier concurrency work removed from the -// live-chat path but had left in place here; (2) survivability — since Go -// panics unwind the goroutine they occur in, an unrecovered one here used to -// mean this call (and every future tick, since the whole ticker loop runs in -// one goroutine) would simply stop — auto-continuation for every task would -// silently die until nomos restarted. Now a single bad item can only ever -// take down its own goroutine. -func (a *agent) processContinuations(ctx context.Context) { - pending := a.store.PendingContinuations(ctx, 5) - for _, p := range pending { - // Scope gate: only auto-continue while an approved plan is active FOR - // THIS SESSION. Checked per-item, not once for the whole batch — with - // multiple tasks in flight, one task's open window must never cover a - // pending continuation belonging to a different task. - if !a.store.AssentWindowActive(ctx, a.agentID, p.SessionID) { - // Re-open the assent window if this session is genuinely - // executing (plan was approved, work is in progress) — the - // window may have expired while the execution ran. Don't - // penalize timing: the plan was approved, the work happened, - // the result should flow back. - sesh, seshErr := a.store.GetSession(ctx, p.SessionID) - if seshErr == nil && sesh.Goal != "" && (sesh.Status == "executing" || sesh.Status == "planning") { - a.openAssentWindow(ctx, p.SessionID) - slog.Info("nomos: re-opened assent window for continuing session", "session", p.SessionID, "execution", p.ExecID) - } else { - // Genuinely no plan — inject a visible note so the - // operator knows WHY the agent didn't auto-continue. - note := fmt.Sprintf("[System: execution %s finished with status=%s, but the assent window for this session is not active. The agent will not auto-continue. Reply 'continue' or re-approve the plan to resume.]", p.ExecID, p.Status) - body, _ := json.Marshal(map[string]any{"role": "assistant", "text": note, "auto": true}) - a.store.SaveMessage(context.Background(), p.SessionID, "assistant", body) - a.store.MarkContinued(ctx, p.ExecID) - continue - } - } - // markContinued now happens inside continueSession, AFTER resumeSession - // actually runs (P0). Pre-marking here consumed the item even when - // resumeSession skipped on a busy session, losing the result. - safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) }) - } -} - -// continueSession re-invokes the agent for one finished execution. Persists -// progress LIVE — a placeholder row immediately, updated in place as each -// tool call completes — instead of only saving once the whole continuation -// finishes. The frontend polls (see chat.ts startPolling); without -// incremental persistence here, a continuation that runs several tool calls -// before concluding would look like total silence in the UI for however long -// that takes, which is exactly the "I just wait while nothing happens" -// complaint this exists to fix — polling alone only helps if there's -// something new to poll for. -func (a *agent) continueSession(ctx context.Context, p session.PendingContinuation) { - slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) - // P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution - // continued ONLY after the turn actually ran. resumeSession skips (returns - // false) when another turn is already active for this session; marking - // before that — as the old code did — consumed the item (continued_at set, - // never re-queued by pendingContinuations) and silently lost the result. - // On a skip, leave it pending so the next worker tick retries once the - // active turn frees the permit. - if !a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) { - slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID) - return - } - a.store.MarkContinued(ctx, p.ExecID) -} - -// resumeSession re-invokes the agent for a session with a system-injected note — -// a finished execution (continueSession) or an operator's answer to a question -// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated -// in place as each tool call lands) so the frontend poller sees each step, -// instead of total silence until the whole resume concludes. -// -// F1 (plan 2026-08-03): this is the single entry point for EVERY background -// turn — the continuation worker, idle sweep, answer-question, /resume, and the -// empty-message reconnect all funnel through here. It acquires the session's -// turn permit non-blocking and SKIPS if a turn is already running. A duplicate -// resume while a turn (live or background) is active is exactly the -// interleaving that corrupted the activity panel and made tasks feel stuck. -// -// Returns whether the turn actually ran. Callers that mutate state before -// resuming (the continuation worker's markContinued, the idle sweep's nudge -// bump) MUST gate that mutation on a true return — otherwise a busy-skip leaves -// the state changed but the work undone (lost continuation / false auto-close). -// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1. -func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool { - if !a.gate.Acquire(sessionID, 0) { - slog.Info("nomos: turn already active, skipping background resume", "session", sessionID) - return false - } - // Release the gate, then drain any operator message that was queued while - // this background turn ran (plan 2026-08-03 F2). Queued messages are run as - // real user turns server-side; resumeSession itself never enqueues. - defer func() { - a.gate.Release(sessionID) - safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) }) - }() - - placeholder, _ := json.Marshal(map[string]any{ - "role": "assistant", - "text": "", - "auto": true, - }) - msgID, err := a.store.InsertMessageReturningID(ctx, sessionID, "assistant", placeholder) - if err != nil { - slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err) - } - - var toolCalls []map[string]any - var finalText, errText string - var finalThinking string - - persist := func() { - if msgID == uuid.Nil { - return - } - text := finalText - if text == "" && errText != "" { - text = fmt.Sprintf("(auto-continuation hit an internal error and did not respond: %s — the execution's own result is above; you may need to prompt the agent again)", errText) - } - body, _ := json.Marshal(map[string]any{ - "role": "assistant", - "text": text, - "thinking": finalThinking, - "tool_calls": toolCalls, - "auto": true, // marks this as an autonomous continuation, not an operator turn - }) - a.store.UpdateMessage(ctx, msgID, body) - } - - // One retry if the LLM call itself produced nothing (transient flake / - // empty-response) — the whole point of this mechanism is "don't give up - // on the first error," which should apply to the continuation call - // itself, not just the homelab commands it's continuing. Found live: a - // destructive-recovery continuation hit an empty LLM response, its - // internal retry (chatWith's own maxLLMRetries=1) also came up empty, and - // without this outer retry the operator would see nothing at all. - cctx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - // B.3: escalate the recovery note across attempts — a transient flake - // needs a different prompt than a model that's stuck no-op'ing. The - // final attempt is maximally directive ("do this specific thing now"). - // B.5: back off between retries (4s, 8s) so a transient provider issue - // has time to clear — 3 identical calls in 3 seconds just get 3 - // identical empties. - notes := []string{ - note, // attempt 0: the original (already enriched per B.2) note - fmt.Sprintf("[System: your previous turn produced no response. %s. Produce a response now — call the next tool or report progress in one sentence.]", note), - fmt.Sprintf("[System: two consecutive empty responses. Stop trying to be clever. The next action is: pick the lowest-pending plan step, mark it running with update_plan_step, and call run for its target. Do that now.]"), - } - for attempt := 0; attempt < 3; attempt++ { - if attempt > 0 { - select { - case <-cctx.Done(): - return true // a turn ran on an earlier attempt; consume, don't re-loop - case <-time.After(time.Duration(2< 0 { - break - } - if attempt < 2 { - slog.Warn("nomos: resume produced nothing, retrying", "session", sessionID, "error", errText, "attempt", attempt+1) - } - } - - if errText != "" && finalText == "" { - slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText) - // Persist a visible system note in the transcript so the - // operator sees what happened, but do NOT auto-complete the - // task — leave it in 'executing' so a follow-up chat message - // can resume it. Before this fix, the task was marked 'failed' - // here, which ended it permanently and required starting over. - resumeFailedNote := fmt.Sprintf("[System: auto-resume failed after retrying: %s. The task is paused — send another message to continue.]", errText) - body, _ := json.Marshal(map[string]any{ - "role": "assistant", - "text": resumeFailedNote, - "auto": true, - }) - if msgID != uuid.Nil { - a.store.UpdateMessage(context.Background(), msgID, body) - } else { - // No placeholder was inserted (rare), save directly. - a.store.SaveMessage(context.Background(), sessionID, "assistant", body) - } - return true // do not call persist() again — already persisted above - } - persist() // final state — same row, updated one last time with the concluding text - return true -} - -// buildContinuationNote frames the finished execution for the model: what -// happened, and what to do about it. The persist-through-errors instruction -// lives here (and in SOUL) so the agent recovers instead of stopping. -func buildContinuationNote(p session.PendingContinuation) string { - action := p.Action - if i := strings.IndexByte(action, ':'); i > 0 && len(action) > 40 { - action = action[:i] // keep just the action verb for brevity; params are in the DB - } - result := p.Result - if len(result) > 3000 { - result = result[:3000] + "…[truncated]" - } - var b strings.Builder - fmt.Fprintf(&b, "[System: execution %s (%s) finished with status=%s.\nResult: %s\n\n", - p.ExecID, action, p.Status, result) - switch p.Status { - case "completed": - b.WriteString("It SUCCEEDED. Continue the approved plan: run the next step. If this was the final step, verify the end goal actually works (e.g. curl the service) and then report success to the operator. Do NOT stop and wait for the operator to say 'continue'.") - case "failed", "cancelled": - b.WriteString("It FAILED. Do NOT give up or hand back to the operator. Diagnose the cause from the result above (and by running read-only inspection commands if needed), form a hypothesis, fix it, and retry or take an alternative approach. You have an active assent window, so config_mutation steps run without re-approval. Only stop and ask the operator if you are genuinely blocked (need information only they have) or the fix would require a destructive action they haven't approved.") - default: // denied / revoked - b.WriteString("The operator denied or revoked this step. Stop executing this plan and briefly acknowledge.") - } - b.WriteString("]") - return b.String() -} diff --git a/cmd/nomos/continue_test.go b/cmd/nomos/continue_test.go deleted file mode 100644 index 79778350..00000000 --- a/cmd/nomos/continue_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package main - -import ( - "context" - "testing" - - "github.com/dtoro/oikos/internal/nomos/session" - "github.com/dtoro/oikos/internal/nomos/turngate" - "github.com/google/uuid" -) - -func TestExtractExecutionIDs(t *testing.T) { - // Real tool-result phrasings that should yield an execution id. - pos := map[string]string{ - `"pct_create on host:strong auto-approved via assent window — execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running."`: "019f4b19-eafd-74ed-baa6-d24a27b3f52c", - `"run on lxc:caddy requires approval (risk: config_mutation) — execution 019f4af7-7eff-7723-b38c-b540b267f407 queued."`: "019f4af7-7eff-7723-b38c-b540b267f407", - `"apt_upgrade on host:hubris auto-approved via assent window — execution 019f4b58-c88c-7767-87dd-044608ced913 running."`: "019f4b58-c88c-7767-87dd-044608ced913", - } - for in, want := range pos { - ids := extractExecutionIDs(in) - if len(ids) != 1 || ids[0].String() != want { - t.Errorf("extractExecutionIDs(%q) = %v, want [%s]", in, ids, want) - } - } - - // Synchronous auto-run and read-only results carry no "execution " - // phrasing — they've already completed inline and must NOT be linked for - // continuation. - neg := []string{ - `"run on host:strong (read_only, auto): 09:30 up 8 days"`, - `"run on lxc:caddy (config_mutation, auto via assent window): done"`, - `[{"slug":"lxc:caddy","health":"healthy"}]`, - `"target not found: lxc:nope"`, - } - for _, in := range neg { - if ids := extractExecutionIDs(in); len(ids) != 0 { - t.Errorf("extractExecutionIDs(%q) = %v, want none", in, ids) - } - } - - // De-dupes repeated ids in one result. - dup := `execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c queued ... execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running` - if ids := extractExecutionIDs(dup); len(ids) != 1 { - t.Errorf("expected de-dup to 1 id, got %v", ids) - } -} - -// TestResumeSession_SkipsWhenBusy guards the P0 fix -// (plans/2026-08-03-nomos-chat-changes-review.md): resumeSession must skip — -// return false, body never executed — when a turn is already active for the -// session. continueSession relies on this so it only marks a continuation -// "continued" after a turn really ran (otherwise the result is lost: marked -// continued, never re-queued by PendingContinuations). -// -// A minimal agent with only a gate is enough: if the body ever ran, chatWith -// would dereference the nil provider and panic. Returning false cleanly proves -// the body was skipped. -func TestResumeSession_SkipsWhenBusy(t *testing.T) { - a := &agent{gate: turngate.New()} - if !a.gate.Acquire("sess", 0) { - t.Fatal("precondition: initial acquire should succeed on a free session") - } - ran := a.resumeSession(context.Background(), "sess", "note") - if ran { - t.Fatal("resumeSession must return false (skip) while a turn is active for the session") - } -} - -// TestContinueSession_DefersWhenBusy guards the other half of P0: when the -// session is busy, continueSession defers (leaves the execution pending for the -// next worker tick) instead of running or marking it. It must return cleanly -// without reaching resumeSession's body (nil provider → panic) or markContinued. -func TestContinueSession_DefersWhenBusy(t *testing.T) { - a := &agent{gate: turngate.New()} - if !a.gate.Acquire("sess", 0) { - t.Fatal("precondition: initial acquire should succeed on a free session") - } - p := session.PendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"} - a.continueSession(context.Background(), p) // must not panic; must not run/mark -} diff --git a/cmd/nomos/eval/main.go b/cmd/nomos/eval/main.go deleted file mode 100644 index b2a848d0..00000000 --- a/cmd/nomos/eval/main.go +++ /dev/null @@ -1,372 +0,0 @@ -// Command nomos-eval runs golden conversation evals against a live nomos -// gateway. It loads a YAML manifest of conversations + assertions, sends -// each prompt to the chat endpoint, waits for the turn(s) to finish, and -// scores assertions against the persisted transcript. -// -// Usage: -// -// go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml -// -// The gateway must already be running (nomos serve, or the docker container). -// Each conversation costs real OpenRouter credits (~$0.01–0.05 each). -// -// Manifest format — see evals/example.yaml. Assertions are scored against the -// final transcript: tool calls made, plan steps, final session status, and -// whether the turn completed. The runner does NOT judge text quality — only -// structural properties that can be checked deterministically from the -// persisted state. This is deliberate: text quality is model-dependent and -// noisy; structure is what the Go gates + SOUL.md should enforce. -package main - -import ( - "bytes" - "context" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" -) - -func main() { - gateway := flag.String("gateway", "http://localhost:8092", "nomos gateway URL") - manifestGlob := flag.String("manifest", "evals/*.yaml", "glob of manifest files to run") - timeout := flag.Duration("timeout", 2*time.Minute, "per-conversation timeout") - flag.Parse() - - if err := health(*gateway); err != nil { - fmt.Fprintf(os.Stderr, "gateway not reachable at %s: %v\n", *gateway, err) - os.Exit(1) - } - - files, err := filepath.Glob(*manifestGlob) - if err != nil { - fmt.Fprintf(os.Stderr, "glob %s: %v\n", *manifestGlob, err) - os.Exit(1) - } - if len(files) == 0 { - fmt.Fprintf(os.Stderr, "no manifests matched %s\n", *manifestGlob) - os.Exit(1) - } - - total, passed, failed := 0, 0, 0 - for _, f := range files { - convs, err := loadManifest(f) - if err != nil { - fmt.Fprintf(os.Stderr, "load %s: %v\n", f, err) - os.Exit(1) - } - for _, c := range convs { - total++ - name := c.Name - if name == "" { - name = fmt.Sprintf("conversation-%d", total) - } - fmt.Printf("=== %s (from %s) ===\n", name, filepath.Base(f)) - res := runConversation(context.Background(), *gateway, c, *timeout) - if res.Passed { - passed++ - fmt.Printf(" ✅ PASS (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount) - } else { - failed++ - fmt.Printf(" ❌ FAIL (%.1fs, %d tool calls)\n", res.Duration.Seconds(), res.ToolCallCount) - } - for _, a := range res.Assertions { - mark := "✅" - if !a.Passed { - mark = "❌" - } - fmt.Printf(" %s %s: %s\n", mark, a.Name, a.Detail) - } - } - } - fmt.Printf("\n=== Summary: %d/%d passed, %d failed ===\n", passed, total, failed) - if failed > 0 { - os.Exit(1) - } -} - -func health(gateway string) error { - resp, err := http.Get(gateway + "/healthz") - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != 200 { - return fmt.Errorf("healthz status %d", resp.StatusCode) - } - return nil -} - -// runConversation sends the prompt (and any followup), waits for each turn to -// finish, then scores assertions against the final transcript. -func runConversation(ctx context.Context, gateway string, c conversation, timeout time.Duration) convResult { - start := time.Now() - deadline := time.Now().Add(timeout) - res := convResult{} - - // Send the initial prompt (no session_id → creates a new session). - sid, err := sendChat(ctx, gateway, "", c.Prompt) - if err != nil { - res.Assertions = []assertionResult{{Name: "send_prompt", Passed: false, Detail: err.Error()}} - res.Duration = time.Since(start) - return res - } - res.SessionID = sid - - // Wait for the first turn to finish. - if err := waitForTurn(ctx, gateway, sid, deadline); err != nil { - res.Assertions = []assertionResult{{Name: "turn_complete", Passed: false, Detail: err.Error()}} - res.Duration = time.Since(start) - return res - } - - // Send followup if any. - for _, fu := range c.followups() { - if _, err := sendChat(ctx, gateway, sid, fu); err != nil { - res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}} - res.Duration = time.Since(start) - return res - } - if err := waitForTurn(ctx, gateway, sid, deadline); err != nil { - res.Assertions = []assertionResult{{Name: "followup_turn_complete", Passed: false, Detail: err.Error()}} - res.Duration = time.Since(start) - return res - } - } - - // Fetch the final transcript + session state. - transcript, session, err := fetchTranscript(ctx, gateway, sid) - if err != nil { - res.Assertions = []assertionResult{{Name: "fetch_transcript", Passed: false, Detail: err.Error()}} - res.Duration = time.Since(start) - return res - } - res.ToolCallCount = transcript.toolCallCount() - res.Duration = time.Since(start) - - // Score assertions. - res.Assertions = scoreAssertions(c.Assertions, transcript, session) - - res.Passed = true - for _, a := range res.Assertions { - if !a.Passed { - res.Passed = false - break - } - } - return res -} - -// sendChat POSTs to /chat and extracts the session_id from the first SSE -// event, then KEEPS READING the stream until it ends (the `done` event or -// the connection closes). This is critical: the chat handler uses -// r.Context() which cancels when the HTTP connection closes — if we stop -// reading after the session event, the agent's work gets canceled mid-turn. -// We must drain the full stream so the agent completes its turn server-side. -func sendChat(ctx context.Context, gateway, sid, message string) (string, error) { - body, _ := json.Marshal(map[string]string{"session_id": sid, "message": message}) - req, _ := http.NewRequestWithContext(ctx, "POST", gateway+"/chat", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.StatusCode != 200 && resp.StatusCode != 202 { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("chat status %d: %s", resp.StatusCode, string(b)) - } - // For a reconnect (sid != ""), the body is 202 with no stream. - if sid != "" { - io.Copy(io.Discard, resp.Body) - return sid, nil - } - // Read the SSE stream, capturing the session_id from the first session - // event, and draining the rest so the agent's turn completes. The stream - // ends when the server closes it (after the `done` event) or when the - // request context cancels. - dec := newSSEReader(resp.Body) - sessionID := "" - for { - ev, err := dec.next() - if err != nil { - if sessionID == "" { - return "", fmt.Errorf("no session event before stream end: %w", err) - } - return sessionID, nil - } - if ev["type"] == "session" && sessionID == "" { - if s, ok := ev["session_id"].(string); ok { - sessionID = s - } - } - // Keep reading until the stream ends — don't return early. - } -} - -// waitForTurn polls the session until its last_active_at stops advancing for -// 8 seconds (the turn ended) or the session reaches a terminal status. We -// can't rely on status=done alone because a trivial task may auto-complete -// while a plan-proposing task stays in 'executing' waiting for approval. -func waitForTurn(ctx context.Context, gateway, sid string, deadline time.Time) error { - var lastActive string - stableSince := time.Now() - for { - if time.Now().After(deadline) { - return fmt.Errorf("timeout waiting for turn to complete") - } - _, session, err := fetchTranscript(ctx, gateway, sid) - if err != nil { - time.Sleep(2 * time.Second) - continue - } - if session.LastActive != lastActive { - lastActive = session.LastActive - stableSince = time.Now() - } - if time.Since(stableSince) >= 8*time.Second { - return nil // turn is idle — consider it complete - } - if session.Status == "done" || session.Status == "failed" { - return nil - } - time.Sleep(2 * time.Second) - } -} - -type transcript struct { - Messages []struct { - Role string `json:"role"` - Content struct { - Text string `json:"text"` - ToolCalls []map[string]any `json:"tool_calls"` - } `json:"content"` - } `json:"messages"` - // PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations - // assertion). Each step carries a `generation` int; distinctGenerations - // counts the unique values. nil when the endpoint returned no plan - // (e.g. a pure-DB Q&A with no propose_plan call). - PlanSteps []planStep `json:"steps"` -} - -// planStep is one step from /sessions/{id}/plan, carrying only the fields the -// eval needs: the generation number (P2 iteration counter). -type planStep struct { - Generation int `json:"generation"` - Status string `json:"status"` - Title string `json:"title"` -} - -func (t transcript) toolCallCount() int { - n := 0 - for _, m := range t.Messages { - n += len(m.Content.ToolCalls) - } - return n -} - -func (t transcript) toolNames() []string { - var names []string - for _, m := range t.Messages { - for _, tc := range m.Content.ToolCalls { - if name, ok := tc["name"].(string); ok { - names = append(names, name) - } - } - } - return names -} - -// distinctGenerations counts unique plan generation values across all plan -// steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0 -// when there are no plan steps (no propose_plan was called). -func (t transcript) distinctGenerations() int { - seen := map[int]bool{} - for _, s := range t.PlanSteps { - seen[s.Generation] = true - } - return len(seen) -} - -type sessionState struct { - ID string `json:"id"` - Status string `json:"status"` - Outcome string `json:"outcome"` - LastActive string `json:"last_active_at"` -} - -// fetchTranscript fetches the messages from /sessions/{id} (which returns -// only session_id + messages) and the session metadata from /sessions -// (which returns status/outcome/last_active_at for each session). P5 also -// fetches /sessions/{id}/plan for the plan_generations assertion. -func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) { - var t transcript - resp, err := http.Get(gateway + "/sessions/" + sid) - if err != nil { - return t, sessionState{}, err - } - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil { - return t, sessionState{}, err - } - if err := json.Unmarshal(b, &t); err != nil { - return t, sessionState{}, err - } - // Fetch the plan (steps with generation numbers) for the - // plan_generations assertion. A 404 or empty response is fine — a - // pure-DB Q&A with no propose_plan has no plan. ?all=true returns every - // generation so the assertion can count them (the default view returns - // only the current generation). - if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan?all=true"); perr == nil { - if planResp.StatusCode == 200 { - pb, _ := io.ReadAll(planResp.Body) - _ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field - } - planResp.Body.Close() - } - // The detail endpoint doesn't return status/outcome — fetch from the - // sessions list and find the matching id. - s, err := fetchSessionMeta(ctx, gateway, sid) - return t, s, err -} - -// fetchSessionMeta fetches /sessions and extracts the one matching sid. -func fetchSessionMeta(ctx context.Context, gateway, sid string) (sessionState, error) { - resp, err := http.Get(gateway + "/sessions") - if err != nil { - return sessionState{}, err - } - defer resp.Body.Close() - var list struct { - Sessions []sessionState `json:"sessions"` - } - if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { - return sessionState{}, err - } - for _, s := range list.Sessions { - if s.ID == sid { - return s, nil - } - } - return sessionState{}, fmt.Errorf("session %s not found in list", sid) -} - -// convResult is the outcome of one conversation. -type convResult struct { - SessionID string - Passed bool - Duration time.Duration - ToolCallCount int - Assertions []assertionResult -} - -type assertionResult struct { - Name string - Passed bool - Detail string -} diff --git a/cmd/nomos/eval/manifest.go b/cmd/nomos/eval/manifest.go deleted file mode 100644 index 76c0b450..00000000 --- a/cmd/nomos/eval/manifest.go +++ /dev/null @@ -1,236 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "gopkg.in/yaml.v3" -) - -// conversation is one golden conversation from a manifest. -type conversation struct { - Name string `yaml:"name"` - Prompt string `yaml:"prompt"` - Followup string `yaml:"followup"` // backward compat: single followup - Followups []string `yaml:"followups"` // P5: multi-turn followups - Assertions []assertion `yaml:"assertions"` -} - -// followups returns the full list of follow-up messages, supporting both -// the single `followup` field (backward compat) and the multi-turn -// `followups` list. -func (c conversation) followups() []string { - if len(c.Followups) > 0 { - return c.Followups - } - if c.Followup != "" { - return []string{c.Followup} - } - return nil -} - -// assertion is one check against the final transcript. The `kind` field -// selects the scorer; the rest are scorer-specific parameters. -// -// Supported kinds: -// -// completes — session status reached done/failed (not stuck executing) -// outcome_is — session outcome == value (success/failure/partial) -// no_propose_plan — propose_plan was never called -// proposes_plan — propose_plan called >= 1 time (plan-always model; P1) -// proposes_plan_once — propose_plan was called exactly once -// no_duplicate_proposal — propose_plan called at most once -// plan_before_run — the first `run` call comes after the first `propose_plan` (P1 ordering gate) -// plan_generations — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup) -// writes_back — update_entity_attributes or create_relationship was called -// max_tool_calls — total tool calls <= value -// max_run_calls — total `run` calls <= value -// no_run — `run` was never called -// calls_tool — the named tool appears in the transcript -// plan_step_count — the plan has exactly `value` steps -// no_duplicate_complete — complete_task called at most once -type assertion struct { - Kind string `yaml:"kind"` - Value any `yaml:"value"` -} - -// loadManifest reads a YAML file containing a list of conversations. -func loadManifest(path string) ([]conversation, error) { - b, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var convs []conversation - if err := yaml.Unmarshal(b, &convs); err != nil { - return nil, fmt.Errorf("parse %s: %w", path, err) - } - return convs, nil -} - -// scoreAssertions evaluates each assertion against the transcript + session. -func scoreAssertions(asserts []assertion, t transcript, s sessionState) []assertionResult { - out := make([]assertionResult, 0, len(asserts)) - for _, a := range asserts { - r := assertionResult{Name: a.Kind} - r.Passed, r.Detail = scoreOne(a, t, s) - if !r.Passed && r.Detail == "" { - r.Detail = "assertion failed" - } - out = append(out, r) - } - return out -} - -func scoreOne(a assertion, t transcript, s sessionState) (bool, string) { - tools := t.toolNames() - switch a.Kind { - case "completes": - if s.Status == "done" || s.Status == "failed" { - return true, fmt.Sprintf("status=%s", s.Status) - } - return false, fmt.Sprintf("status=%s (not terminal)", s.Status) - - case "outcome_is": - want, _ := a.Value.(string) - if s.Outcome == want { - return true, fmt.Sprintf("outcome=%s", s.Outcome) - } - return false, fmt.Sprintf("outcome=%s, want %s", s.Outcome, want) - - case "no_propose_plan": - n := countTool(tools, "propose_plan") - if n == 0 { - return true, "propose_plan not called" - } - return false, fmt.Sprintf("propose_plan called %d time(s)", n) - - case "proposes_plan": - // P1 plan-always: propose_plan called >= 1 time. - n := countTool(tools, "propose_plan") - if n >= 1 { - return true, fmt.Sprintf("propose_plan called %d time(s)", n) - } - return false, "propose_plan never called (plan-always requires >= 1)" - - case "proposes_plan_once": - n := countTool(tools, "propose_plan") - if n == 1 { - return true, "propose_plan called once" - } - return false, fmt.Sprintf("propose_plan called %d time(s), want 1", n) - - case "no_duplicate_proposal": - n := countTool(tools, "propose_plan") - if n <= 1 { - return true, fmt.Sprintf("propose_plan called %d time(s)", n) - } - return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n) - - case "plan_before_run": - // P1 ordering gate: the first `run` call's global index in the - // transcript is strictly greater than the first `propose_plan` - // index. Both indices are over the flat tool-call list (across all - // messages, in order). - planIdx, runIdx := -1, -1 - for i, name := range tools { - if name == "propose_plan" && planIdx == -1 { - planIdx = i - } - if name == "run" && runIdx == -1 { - runIdx = i - } - } - if runIdx == -1 { - return true, "run never called (ordering trivially satisfied)" - } - if planIdx == -1 { - return false, "run called but propose_plan never called" - } - if planIdx < runIdx { - return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx) - } - return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx) - - case "plan_generations": - // P2 iteration: counts distinct `generation` values in - // session_plan_steps. 1 = single sub-task, 2 = one follow-up - // sub-task, etc. Requires the plan endpoint to return generation - // values; the eval fetches /sessions/{id}/plan and passes it via - // the transcript's PlanSteps field. - want := toInt(a.Value) - gens := t.distinctGenerations() - if gens == want { - return true, fmt.Sprintf("%d plan generation(s)", gens) - } - return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want) - - case "writes_back": - n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship") - if n > 0 { - return true, fmt.Sprintf("%d writeback call(s)", n) - } - return false, "no update_entity_attributes or create_relationship calls" - - case "max_tool_calls": - max := toInt(a.Value) - if t.toolCallCount() <= max { - return true, fmt.Sprintf("%d tool calls (<= %d)", t.toolCallCount(), max) - } - return false, fmt.Sprintf("%d tool calls, want <= %d", t.toolCallCount(), max) - - case "max_run_calls": - max := toInt(a.Value) - n := countTool(tools, "run") - if n <= max { - return true, fmt.Sprintf("%d run calls (<= %d)", n, max) - } - return false, fmt.Sprintf("%d run calls, want <= %d", n, max) - - case "no_run": - n := countTool(tools, "run") - if n == 0 { - return true, "run not called" - } - return false, fmt.Sprintf("run called %d time(s)", n) - - case "calls_tool": - want, _ := a.Value.(string) - n := countTool(tools, want) - if n > 0 { - return true, fmt.Sprintf("%s called %d time(s)", want, n) - } - return false, fmt.Sprintf("%s not called", want) - - case "no_duplicate_complete": - n := countTool(tools, "complete_task") - if n <= 1 { - return true, fmt.Sprintf("complete_task called %d time(s)", n) - } - return false, fmt.Sprintf("complete_task called %d time(s), want <= 1", n) - - default: - return false, fmt.Sprintf("unknown assertion kind: %s", a.Kind) - } -} - -func countTool(names []string, name string) int { - n := 0 - for _, x := range names { - if x == name { - n++ - } - } - return n -} - -func toInt(v any) int { - switch x := v.(type) { - case int: - return x - case int64: - return int(x) - case float64: - return int(x) - } - return 0 -} diff --git a/cmd/nomos/eval/sse.go b/cmd/nomos/eval/sse.go deleted file mode 100644 index 87e8b590..00000000 --- a/cmd/nomos/eval/sse.go +++ /dev/null @@ -1,52 +0,0 @@ -package main - -import ( - "bufio" - "encoding/json" - "io" - "strings" -) - -// sseReader parses a text/event-stream into a sequence of JSON events. -// Each event is one or more "data: " lines; the lines are concatenated -// and parsed as a single JSON object. Blank lines separate events. -type sseReader struct { - r *bufio.Reader -} - -func newSSEReader(r io.Reader) *sseReader { - return &sseReader{r: bufio.NewReader(r)} -} - -func (s *sseReader) next() (map[string]any, error) { - var data strings.Builder - for { - line, err := s.r.ReadString('\n') - if err != nil { - if err == io.EOF && data.Len() > 0 { - return parseEvent(data.String()) - } - return nil, err - } - line = strings.TrimRight(line, "\r\n") - if line == "" { - if data.Len() > 0 { - return parseEvent(data.String()) - } - continue // blank line, no event buffered yet - } - if strings.HasPrefix(line, "data: ") { - data.WriteString(strings.TrimPrefix(line, "data: ")) - } else if strings.HasPrefix(line, "data:") { - data.WriteString(strings.TrimPrefix(line, "data:")) - } - } -} - -func parseEvent(s string) (map[string]any, error) { - var ev map[string]any - if err := json.Unmarshal([]byte(s), &ev); err != nil { - return nil, err - } - return ev, nil -} diff --git a/cmd/nomos/mcp.go b/cmd/nomos/mcp.go deleted file mode 100644 index f1304fdc..00000000 --- a/cmd/nomos/mcp.go +++ /dev/null @@ -1,348 +0,0 @@ -package main - -import ( - "bufio" - "bytes" - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "strings" - "sync" - "time" -) - -// ─── MCP Streamable HTTP client ──────────────────────────────────────── - -type mcpClient struct { - baseURL string - token string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it on every request (no dev-open bypass) - sessionID string - http *http.Client - nextID int - mu sync.Mutex // one client serializes its own MCP calls (the pool gives each session its own client, so this never blocks another session) - // toolsCache holds the last tools/list result. The tool list is static - // for the lifetime of one MCP connection — it only changes when the api - // process (re)registers tools, i.e. on a restart, which this client - // already detects and reacts to via reconnectLocked. Without this, - // buildTools (called at the start of EVERY chat turn, including every - // auto-continuation resume) paid a full tools/list round-trip every - // single time for a list that's almost always identical to the last one. - // Guarded separately from mu (not reused) so a cache check never - // contends with an in-flight doRequest call for a different method. - toolsMu sync.Mutex - toolsCache []toolDef -} - -func newMCPClient(baseURL, token string) (*mcpClient, error) { - c := &mcpClient{ - baseURL: baseURL, - token: token, - http: &http.Client{Timeout: 120 * time.Second}, - } - - resp, err := c.doRequest("initialize", map[string]any{ - "protocolVersion": "2024-11-05", - "capabilities": map[string]any{}, - "clientInfo": map[string]any{"name": "nomos", "version": "2.0"}, - }) - if err != nil { - return nil, fmt.Errorf("initialize: %w", err) - } - if resp.sessionID == "" { - return nil, fmt.Errorf("no session ID in initialize response") - } - c.sessionID = resp.sessionID - - c.doRequest("notifications/initialized", map[string]any{}) - - slog.Info("nomos: mcp connected", "session", c.sessionID[:16]+"...") - return c, nil -} - -type mcpJSONRPCResponse struct { - sessionID string - Result json.RawMessage `json:"result"` - Error json.RawMessage `json:"error"` -} - -// errStaleSession signals that the MCP server rejected our session id (e.g. -// after an api/MCP restart), so the client should re-initialize and retry. -var errStaleSession = fmt.Errorf("mcp session stale") - -// doRequest serializes MCP calls and transparently re-initializes the session -// if the server has forgotten it (common after an api redeploy), retrying the -// original call once. Without this, an api restart permanently breaks nomos -// until it is itself restarted. -func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPCResponse, error) { - c.mu.Lock() - defer c.mu.Unlock() - - resp, err := c.send(method, params) - if err != nil && method != "initialize" && errors.Is(err, errStaleSession) { - slog.Warn("nomos: mcp session stale, reconnecting") - if rerr := c.reconnectLocked(); rerr != nil { - return nil, fmt.Errorf("mcp reconnect: %w (original: %v)", rerr, err) - } - return c.send(method, params) - } - return resp, err -} - -// reconnectLocked re-initializes the MCP session. The caller must hold c.mu. -func (c *mcpClient) reconnectLocked() error { - c.sessionID = "" - // A reconnect means the api process was restarted (or forgot us) — its - // tool registration may have changed, so the cached list is no longer - // trustworthy. - c.toolsMu.Lock() - c.toolsCache = nil - c.toolsMu.Unlock() - resp, err := c.send("initialize", map[string]any{ - "protocolVersion": "2024-11-05", - "capabilities": map[string]any{}, - "clientInfo": map[string]any{"name": "nomos", "version": "2.0"}, - }) - if err != nil { - return err - } - if resp.sessionID == "" { - return fmt.Errorf("no session ID on re-initialize") - } - c.sessionID = resp.sessionID - _, _ = c.send("notifications/initialized", map[string]any{}) - slog.Info("nomos: mcp reconnected", "session", c.sessionID[:16]+"...") - return nil -} - -// send performs one MCP round-trip. It does not lock; callers hold c.mu. -func (c *mcpClient) send(method string, params map[string]any) (*mcpJSONRPCResponse, error) { - c.nextID++ - body, _ := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "method": method, - "params": params, - "id": c.nextID, - }) - - req, err := http.NewRequest(http.MethodPost, c.baseURL, bytes.NewReader(body)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - if c.sessionID != "" { - req.Header.Set("Mcp-Session-Id", c.sessionID) - } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) - } - - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // A rejected/unknown session comes back as 4xx (commonly 400/404). - if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest { - return nil, errStaleSession - } - - result := &mcpJSONRPCResponse{} - result.sessionID = resp.Header.Get("Mcp-Session-Id") - - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) - gotData := false - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, "data: ") { - gotData = true - data := line[6:] - if err := json.Unmarshal([]byte(data), result); err != nil { - return nil, fmt.Errorf("parse response: %w", err) - } - } - } - - if result.Error != nil { - return nil, fmt.Errorf("rpc error: %s", string(result.Error)) - } - - // Empty body with no result and a session set: the server likely dropped - // our session. Notifications legitimately return no data, so exempt them. - if !gotData && result.Result == nil && method != "notifications/initialized" { - return nil, errStaleSession - } - - if result.sessionID != "" { - c.sessionID = result.sessionID - } - - return result, nil -} - -func (c *mcpClient) callTool(name string, args map[string]any) (any, error) { - resp, err := c.doRequest("tools/call", map[string]any{ - "name": name, - "arguments": args, - }) - if err != nil { - return nil, err - } - - var toolResult struct { - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` - } - if err := json.Unmarshal(resp.Result, &toolResult); err != nil { - return string(resp.Result), nil - } - - var texts []string - for _, c := range toolResult.Content { - if c.Type == "text" { - var parsed any - if json.Unmarshal([]byte(c.Text), &parsed) == nil { - return parsed, nil - } - texts = append(texts, c.Text) - } - } - if len(texts) == 1 { - return texts[0], nil - } - return texts, nil -} - -func (c *mcpClient) listTools() ([]string, error) { - resp, err := c.doRequest("tools/list", map[string]any{}) - if err != nil { - return nil, err - } - var tr struct { - Tools []struct { - Name string `json:"name"` - Description string `json:"description"` - } `json:"tools"` - } - if err := json.Unmarshal(resp.Result, &tr); err != nil { - return nil, err - } - var names []string - for _, t := range tr.Tools { - names = append(names, t.Name) - } - return names, nil -} - -func (c *mcpClient) close() { -} - -// ─── Per-session MCP client pool ──────────────────────────────────────── -// -// A single shared mcpClient serializes EVERY tool call across EVERY -// concurrently-running task through one mutex (see mcpClient.mu) — `run` -// executes its SSH command synchronously inside that lock and is capped at -// up to 10 minutes, so one task mid-`run` stalled every other task's tool -// calls, even trivial reads, behind it. The MCP *server* has no per- -// connection state to protect (newServer in internal/mcp/server.go returns -// one shared *mcp.Server instance whose tool handlers close only over the DB -// pool, which is already safe for concurrent use) — the mutex existed purely -// because the *client* reused one stateful transport session, not because -// the server needed it. Giving each task's own session its own client -// removes the cross-task serialization entirely: a task's own tool calls -// stay sequential (which they already are — the agent loop calls tools one -// at a time within a turn), but no longer block anyone else's. -type mcpClientPool struct { - baseURL string - token string - mu sync.Mutex - clients map[string]*pooledMCPClient -} - -type pooledMCPClient struct { - client *mcpClient - lastUsed time.Time -} - -func newMCPClientPool(baseURL, token string) *mcpClientPool { - return &mcpClientPool{baseURL: baseURL, token: token, clients: make(map[string]*pooledMCPClient)} -} - -// get returns the client for sessionID, creating and initializing one (a -// real MCP handshake) on first use. Session ids that don't identify a real -// persisted conversation ("" / "ephemeral", the no-DB-store path; "query", -// the structured /query endpoint) still get exactly one dedicated, -// reused client each via the same map — just keyed on a fixed string instead -// of a real session id — so that traffic doesn't pay a fresh handshake per -// request while still never sharing a connection with an actual task. -func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) { - key := sessionID - if key == "" { - key = "ephemeral" - } - - p.mu.Lock() - if pc, ok := p.clients[key]; ok { - pc.lastUsed = time.Now() - p.mu.Unlock() - return pc.client, nil - } - p.mu.Unlock() - - // Initialize outside the lock — it's a network round-trip, and holding - // the pool mutex for it would serialize unrelated sessions' first calls - // behind each other, undermining the whole point of this pool. - c, err := newMCPClient(p.baseURL, p.token) - if err != nil { - return nil, err - } - - p.mu.Lock() - // Another goroutine may have created one for the same key while we were - // initializing (two of this session's tool calls racing on a cold - // start); keep whichever won, close out the loser's connection (a no-op - // today, but future-proof if mcpClient.close ever does real teardown). - if existing, ok := p.clients[key]; ok { - p.mu.Unlock() - c.close() - return existing.client, nil - } - p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()} - p.mu.Unlock() - return c, nil -} - -// mcpClientIdleTimeout is how long an idle session's MCP client is kept -// before eviction — long enough to outlive a single slow `run` (capped at 10 -// minutes server-side) plus normal think-time between a task's tool calls, -// short enough not to accumulate one abandoned connection per finished task -// forever. -const mcpClientIdleTimeout = 20 * time.Minute - -// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker. -func (p *mcpClientPool) sweep() { - cutoff := time.Now().Add(-mcpClientIdleTimeout) - p.mu.Lock() - defer p.mu.Unlock() - for key, pc := range p.clients { - if pc.lastUsed.Before(cutoff) { - pc.client.close() - delete(p.clients, key) - } - } -} - -func (p *mcpClientPool) closeAll() { - p.mu.Lock() - defer p.mu.Unlock() - for key, pc := range p.clients { - pc.client.close() - delete(p.clients, key) - } -} \ No newline at end of file diff --git a/cmd/nomos/server.go b/cmd/nomos/server.go deleted file mode 100644 index 01ac7b1b..00000000 --- a/cmd/nomos/server.go +++ /dev/null @@ -1,696 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "os" - "os/signal" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/dtoro/oikos/internal/nomos/session" - "github.com/dtoro/oikos/internal/safego" - "github.com/dtoro/oikos/internal/secrets" - "github.com/jackc/pgx/v5" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintln(os.Stderr, "usage: nomos serve") - os.Exit(1) - } - if os.Args[1] == "healthcheck" { - runHealthcheck() - return - } - mcpURL := os.Getenv("NOMOS_MCP_URL") - if mcpURL == "" { - mcpURL = "http://localhost:8090/mcp" - } - mcpToken := os.Getenv("OIKOS_MCP_BEARER_TOKEN") - - agentSlug := os.Getenv("NOMOS_AGENT_SLUG") - if agentSlug == "" { - agentSlug = "agent:nomos" - } - - databaseURL := os.Getenv("DATABASE_URL") - if databaseURL == "" { - databaseURL = os.Getenv("OIKOS_DATABASE_URL") - } - - sec := secrets.NewManagerFromConfig( - os.Getenv("OIKOS_INFISICAL_SITE_URL"), - os.Getenv("OIKOS_INFISICAL_CLIENT_ID"), - os.Getenv("OIKOS_INFISICAL_CLIENT_SECRET"), - os.Getenv("OIKOS_INFISICAL_PROJECT_ID"), - os.Getenv("OIKOS_INFISICAL_ENV"), - os.Getenv("OIKOS_SECRETS_DIR"), - ) - var openrouterAPIKey string - var secretsResolved int - if sec != nil { - resCtx, resCancel := context.WithTimeout(context.Background(), 10*time.Second) - if v := secrets.ResolveSecret(resCtx, sec, "mcp_bearer-token", ""); v != "" { - mcpToken = v - secretsResolved++ - } - openrouterAPIKey = secrets.ResolveSecret(resCtx, sec, "openrouter_api-key", os.Getenv("OPENROUTER_API_KEY")) - if openrouterAPIKey != "" && openrouterAPIKey != os.Getenv("OPENROUTER_API_KEY") { - secretsResolved++ - } - resCancel() - if secretsResolved > 0 { - slog.Info("nomos: secrets resolved from Infisical", "count", secretsResolved) - } - } - - switch os.Args[1] { - case "serve": - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT) - defer cancel() - - // One MCP client PER SESSION, not one shared client for the whole - // process — see mcpClientPool's doc comment. A dedicated client is - // created lazily on each session's first tool call. - clientPool := newMCPClientPool(mcpURL, mcpToken) - // Prove connectivity at startup the same way the old single-client - // constructor did, so a misconfigured/unreachable MCP endpoint still - // fails fast on boot instead of only on the first real chat. Doesn't - // reuse the pool (nothing to key it by yet) — just a throwaway probe. - if probe, err := newMCPClient(mcpURL, mcpToken); err != nil { - slog.Error("nomos: mcp connect", "url", mcpURL, "error", err) - os.Exit(1) - } else { - probe.close() - } - - st, err := session.New(ctx, databaseURL) - if err != nil { - slog.Error("nomos: db connect", "error", err) - os.Exit(1) - } - if st != nil { - defer st.Close() - } - - nAgent, err := newAgent(ctx, clientPool, st, agentSlug, openrouterAPIKey) - if err != nil { - slog.Error("nomos: agent init", "error", err) - os.Exit(1) - } - - // Event-driven auto-continuation: feed finished async executions back - // into the agent so an approved plan runs to completion (and recovers - // from failures) without the operator ticking it forward each step. - safego.Go("nomos:continuation-worker", func() { nAgent.runContinuationWorker(ctx) }) - - // Idle sweep for stalled goal-bearing tasks (fix 2+3 of - // plans/2026-07-11-task-completion-safety-net.md) — a coarser, - // slower-ticking counterpart to the continuation worker above. - safego.Go("nomos:idle-sweep-worker", func() { nAgent.runIdleSweepWorker(ctx) }) - - safego.Go("nomos:mcp-pool-sweeper", func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - clientPool.sweep() - } - } - }) - - // Stale execution sweep: cancels non-terminal executions older than - // 10 minutes (orphaned by MCP timeouts — see cleanupStaleExecutions). - safego.Go("nomos:stale-execution-sweeper", func() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - st.CleanupStaleExecutions(ctx, 10*time.Minute) - } - } - }) - - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) - w.Write([]byte("ok")) - }) - mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) { - handleQuery(w, r, clientPool, agentSlug, mcpURL) - }) - mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) { - handleChat(w, r, nAgent, st) - }) - mux.HandleFunc("/sessions", func(w http.ResponseWriter, r *http.Request) { - handleSessionsList(w, r, st) - }) - mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) { - handleSessionDetail(w, r, st, nAgent) - }) - - addr := os.Getenv("NOMOS_LISTEN") - if addr == "" { - addr = ":8092" - } - - srv := &http.Server{Addr: addr, Handler: mux} - safego.Go("nomos:http-server", func() { - slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "") - if err := srv.ListenAndServe(); err != http.ErrServerClosed { - slog.Error("nomos: serve", "error", err) - } - }) - - <-ctx.Done() - slog.Info("nomos: shutting down") - srv.Shutdown(context.Background()) - clientPool.closeAll() - - default: - fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1]) - os.Exit(1) - } -} - -func runHealthcheck() { - addr := os.Getenv("NOMOS_LISTEN") - if addr == "" { - addr = ":8092" - } - host := addr - if strings.HasPrefix(host, ":") { - host = "127.0.0.1" + host - } - client := &http.Client{Timeout: 3 * time.Second} - resp, err := client.Get("http://" + host + "/healthz") - if err != nil { - os.Exit(1) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - os.Exit(1) - } -} - -func sseEvent(w http.ResponseWriter, flusher http.Flusher, event agentEvent) { - data, _ := json.Marshal(event) - fmt.Fprintf(w, "data: %s\n\n", data) - flusher.Flush() -} - -func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *session.Store) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", 405) - return - } - - var req struct { - SessionID string `json:"session_id"` - Message string `json:"message"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request: "+err.Error(), 400) - return - } - if req.Message == "" && req.SessionID == "" { - http.Error(w, "message is required", 400) - return - } - - // Empty message with an existing session = reconnect/resume. This path is - // defensive now — the frontend (post F2) recovers a dropped SSE via the - // poller + terminal task.status clearing, and no longer POSTs empty - // messages. If a client ever does, route into resumeSession so the agent - // reports current state — but SKIP a terminal session (done/failed/ - // abandoned): there's nothing to resume, and running a "report state" - // turn there is just a spare turn the operator never asked for (P2.1). - if req.Message == "" && req.SessionID != "" { - if sess, err := st.GetSession(context.Background(), req.SessionID); err == nil { - switch sess.Status { - case "done", "failed", "abandoned": - slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status) - w.WriteHeader(202) - return - } - } - slog.Info("nomos: reconnect", "session", req.SessionID) - safego.Go("nomos:reconnect:"+req.SessionID, func() { - base := "[System: the operator's connection was re-established. The task may have progressed in the background.]" - note := st.EnrichResumeNote(context.Background(), req.SessionID, base) - a.resumeSession(context.Background(), req.SessionID, note) - }) - // Return 202 so the frontend doesn't try to consume an SSE stream - // from this POST — resumeSession writes to the DB directly and - // the poller picks it up. - w.WriteHeader(202) - return - } - - flusher, ok := w.(http.Flusher) - if !ok { - http.Error(w, "streaming not supported", 500) - return - } - - w.Header().Set("Content-Type", "text/event-stream") - w.Header().Set("Cache-Control", "no-cache") - w.Header().Set("Connection", "keep-alive") - w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering - w.WriteHeader(200) - - // All writes to w (events + the keepalive comment below) go through one - // mutex: http.ResponseWriter is NOT safe for concurrent use, and the - // keepalive ticker runs alongside the turn's event sink (plan 2026-08-03 - // F3). Without this, interleaved writes corrupt the SSE stream. - var writeMu sync.Mutex - writeEvent := func(ev agentEvent) { - writeMu.Lock() - defer writeMu.Unlock() - sseEvent(w, flusher, ev) - } - - ctx := r.Context() - sessionID := req.SessionID - - // pctx (persistence context) is deliberately context.Background(), not - // ctx/r.Context(), for every DB write in this handler — ctx cancels the - // instant the client disconnects (Stop button, tab close, network blip), - // and a write made with an already-cancelled context fails. Before this - // fix, the assistant message was only ever saved ONCE, at the very end, - // using ctx — so a disconnect mid-turn silently lost the ENTIRE turn's - // tool-call history from the persisted transcript, even though real work - // (executions launched, knowledge written) had already happened - // server-side. The agent's own work (a.chat below) still correctly stops - // when ctx cancels — this only changes what happens to persistence. - pctx := context.Background() - - if sessionID == "" { - title := truncate(req.Message, 80) - sess, err := st.CreateSession(pctx, title) - if err != nil { - slog.Error("nomos: create session", "error", err) - sessionID = "ephemeral" - } else { - sessionID = sess.ID - } - } else { - // P2 iteration: if the operator sends a follow-up on a session - // that already reached a terminal state (done/failed), reopen it - // so a new sub-task can be framed (set_goal → propose_plan → - // execute). reopenSession marks the prior plan's steps as - // `replaced` (proposePlan ignores those) and clears outcome/ - // summary. Without this, propose_plan refuses the follow-up with - // ErrPlanInFlight because the prior steps are all `done`. If the - // session is still active, reopen is a no-op — the follow-up is - // just a continuation of in-flight work. - st.ReopenSession(pctx, sessionID) - st.TouchSession(pctx, sessionID) - } - - slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100)) - - userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message}) - st.SaveMessage(pctx, sessionID, "user", userMsg) - - // If this task has a pending operator question, the incoming message IS the - // answer — close it so the panel clears. No separate resume needed: this - // chat turn is the resume, and the agent sees the question + answer in its - // replayed history. - if qid := st.OpenQuestionID(pctx, sessionID); qid != "" { - st.AnswerQuestion(pctx, sessionID, qid, req.Message) - } - - writeEvent(agentEvent{Type: "session", Data: sessionID, SessionID: sessionID}) - - // F1/F2 (plan 2026-08-03): serialize turns per session. The user message is - // already persisted above, so it is never lost. Wait briefly for a finishing - // background turn; if one is still running after that, QUEUE this message - // (don't reject it) and tell the client so it shows a "queued" state. The - // in-flight turn's release drains the queue (drainQueued) and runs it as a - // real turn server-side. This never stacks concurrent turns — the gate still - // guarantees one in-flight turn per session. - const turnWait = 5 * time.Second - if !a.gate.Acquire(sessionID, turnWait) { - a.queue.Enqueue(sessionID, req.Message) - slog.Info("nomos: turn already active, queued operator message", "session", sessionID) - writeEvent(agentEvent{Type: "queued", Data: sessionID, SessionID: sessionID}) - writeEvent(agentEvent{Type: "done", Data: map[string]any{ - "session_id": sessionID, - "queued": true, - }, SessionID: sessionID}) - return - } - defer func() { - a.gate.Release(sessionID) - // Run any message that was queued while this turn held the gate. In a - // goroutine so the HTTP response finishes without waiting on the next - // turn; the queued turn has no SSE client of its own. - safego.Go("nomos:drain:"+sessionID, func() { a.drainQueued(context.Background(), sessionID) }) - }() - - // F3 (plan 2026-08-03): keep the SSE alive during long turns. A turn can - // run for many minutes (provisioning chains, deep research); the model - // often takes 20-40s between tool iterations, and with nothing flushed in - // that gap a proxy/browser idle timeout silently closes the stream. The - // client then sees streaming=false while the server keeps working — the - // "I can't tell it's working" desync. An SSE comment line (":keepalive") is - // ignored by EventSource but resets idle timers. - keepDone := make(chan struct{}) - go func() { - t := time.NewTicker(12 * time.Second) - defer t.Stop() - for { - select { - case <-keepDone: - return - case <-t.C: - writeMu.Lock() - fmt.Fprintf(w, ":keepalive\n\n") - flusher.Flush() - writeMu.Unlock() - } - } - }() - // Defer the close (not a statement after runChatTurn) so the goroutine - // exits even if runChatTurn panics — net/http recovers handler panics, so - // a non-deferred close would be skipped and the ticker would keep writing - // to a dead ResponseWriter forever. - defer close(keepDone) - a.runChatTurn(pctx, ctx, sessionID, req.Message, func(ev agentEvent) { - writeEvent(ev) - }) -} - -func handleSessionsList(w http.ResponseWriter, r *http.Request, st *session.Store) { - if st == nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"sessions": []any{}}) - return - } - - if r.Method == http.MethodOptions { - return - } - - // P2.8 (2026-07-20): filtering + pagination. The audit script in - // .agents/skills/session-review/SKILL.md slices `.sessions[:10]` - // client-side; "show me partial sessions touching lxc:rclone" - // required fetching the full list and filtering in JS. Push the - // filters into SQL so the audit becomes a single `curl | jq`. - // Supported query params (all optional, composable): - // ?outcome=partial|success|failure — exact match on outcome - // ?status=active|done|failed|executing — exact match on status - // ?entity_id= — exact match on entity_id - // ?since= — last_active_at >= ... - // ?blocker= — exact match on blocker - // ?limit= — default 50, max 200 - // ?cursor= — last_active_at < cursor (page back) - q := r.URL.Query() - limit := 50 - if v := q.Get("limit"); v != "" { - if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 { - limit = n - } - } - sessions, err := st.ListSessionsFiltered(r.Context(), session.ListFilter{ - Outcome: q.Get("outcome"), - Status: q.Get("status"), - EntityID: q.Get("entity_id"), - Blocker: q.Get("blocker"), - Since: q.Get("since"), - Cursor: q.Get("cursor"), - Limit: limit, - }) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - // Next-page cursor: the oldest last_active_at in this page. The next - // request passes it as ?cursor=... to get the page before it. Empty - // when the list is exhausted. - var nextCursor string - if len(sessions) > 0 { - oldest := sessions[len(sessions)-1].LastActiveAt - nextCursor = oldest.UTC().Format(time.RFC3339Nano) - if len(sessions) < limit { - nextCursor = "" // last page - } - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "sessions": sessions, - "next_cursor": nextCursor, - "limit": limit, - }) -} - -func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent) { - if st == nil { - http.Error(w, "not found", 404) - return - } - - rest := strings.TrimPrefix(r.URL.Path, "/sessions/") - parts := strings.Split(rest, "/") - id := parts[0] - if id == "" { - http.Error(w, "session id required", 400) - return - } - - // POST /sessions/{id}/questions/{qid}/answer — the operator answers a - // pinned question from the context panel; resume the agent with the answer. - if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", 405) - return - } - handleAnswerQuestion(w, r, st, a, id, parts[2]) - return - } - - // POST /sessions/{id}/resume — the operator asks the agent to continue. - if len(parts) == 2 && parts[1] == "resume" && r.Method == http.MethodPost { - base := "[System: the operator wants you to continue. Pick up where you left off — execute the next step of the plan, diagnose and fix any failures, or report progress if everything is done.]" - note := st.EnrichResumeNote(context.Background(), id, base) - safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), id, note) }) - w.WriteHeader(202) - return - } - - // GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for - // the context panel when it first opens a task; live events carry deltas - // from there. - // GET /sessions/{id}/tool_calls — flat view of every tool call in the - // session, without the two-level message-shell nesting. The audit at - // plans/2026-07-20-session-review-ten-sessions.md P2.10 had to write - // Python to walk messages[].content.tool_calls[]; this endpoint makes - // it a single `curl | jq`. - if len(parts) == 2 && r.Method == http.MethodGet { - switch parts[1] { - case "plan": - all := r.URL.Query().Has("all") && r.URL.Query().Get("all") != "0" && r.URL.Query().Get("all") != "false" - steps, err := st.GetPlanSteps(r.Context(), id, all) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"steps": steps}) - return - case "questions": - questions, err := st.GetQuestions(r.Context(), id) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"questions": questions}) - return - case "tool_calls": - calls, err := st.GetSessionToolCalls(r.Context(), id) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"session_id": id, "tool_calls": calls}) - return - } - } - - switch r.Method { - case http.MethodDelete: - if err := st.DeleteSession(r.Context(), id); err != nil { - http.Error(w, err.Error(), 500) - return - } - w.WriteHeader(204) - - case http.MethodGet: - // P2.7 (2026-07-20): return BOTH session metadata and messages - // from GET /sessions/{id}. Previously this endpoint returned only - // {session_id, messages} — the operator had to merge with the - // /sessions list view to get title/goal/outcome. The eval harness - // at cmd/nomos/eval/main.go:302-303 already carries a comment - // about this leaky abstraction. The session field carries the - // full metadata: title, goal, outcome, summary, blocker, - // pending_approvals, message_count, tool_call_count, etc. The - // messages field is unchanged. Clients that only read - // `messages` keep working. - sess, err := st.GetSession(r.Context(), id) - if err != nil { - if err == pgx.ErrNoRows { - http.Error(w, "session not found", 404) - return - } - http.Error(w, err.Error(), 500) - return - } - messages, err := st.GetMessages(r.Context(), id) - if err != nil { - http.Error(w, err.Error(), 500) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "session_id": id, - "session": sess, - "messages": messages, - }) - - default: - http.Error(w, "method not allowed", 405) - } -} - -// handleAnswerQuestion records the operator's answer to a pinned question and -// resumes the agent in the background with that answer injected. Returns 202 — -// the agent's response lands via the normal message-polling path, not this POST. -func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *session.Store, a *agent, sessionID, questionID string) { - var req struct { - Answer string `json:"answer"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" { - http.Error(w, "answer is required", 400) - return - } - prompt, _, _ := st.GetQuestion(r.Context(), questionID) - if err := st.AnswerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil { - http.Error(w, err.Error(), 500) - return - } - if a != nil { - base := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+ - "Continue the task from here — do not re-ask.]", prompt, req.Answer) - note := st.EnrichResumeNote(context.Background(), sessionID, base) - safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) }) - } - w.WriteHeader(202) -} - -func handleQuery(w http.ResponseWriter, r *http.Request, pool *mcpClientPool, agentSlug, mcpURL string) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", 405) - return - } - - var req struct { - Query string `json:"query"` - Tool string `json:"tool"` - Args map[string]any `json:"args"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "bad request: "+err.Error(), 400) - return - } - - // The structured /query endpoint is stateless/session-less — "query" is a - // fixed pool key (not a real session id) so repeated calls reuse one - // dedicated connection instead of paying a fresh MCP handshake every time, - // while still never sharing a connection with an actual chat task. - client, err := pool.get("query") - if err != nil { - http.Error(w, "mcp unavailable: "+err.Error(), 502) - return - } - - start := time.Now() - - if req.Tool != "" { - result, err := client.callTool(req.Tool, req.Args) - duration := time.Since(start).Milliseconds() - if err != nil { - slog.Error("nomos: query failed", "tool", req.Tool, "error", err) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "error": err.Error(), - "elapsed_ms": duration, - "agent_slug": agentSlug, - }) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "result": result, - "elapsed_ms": duration, - "agent_slug": agentSlug, - "mcp_url": mcpURL, - }) - return - } - - if req.Query != "" { - if strings.Contains(strings.ToLower(req.Query), "what can you do") || - strings.Contains(strings.ToLower(req.Query), "help") { - - tools, err := client.listTools() - duration := time.Since(start).Milliseconds() - if err != nil { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "error": err.Error(), - "elapsed_ms": duration, - }) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", - "tools": tools, - "elapsed_ms": duration, - }) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "message": "natural language queries belong to /chat. Use structured /query with 'tool' for direct MCP calls.", - "elapsed_ms": time.Since(start).Milliseconds(), - }) - return - } - - http.Error(w, "either 'tool' or 'query' required", 400) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/cmd/nomos/tasks.go b/cmd/nomos/tasks.go deleted file mode 100644 index eab09247..00000000 --- a/cmd/nomos/tasks.go +++ /dev/null @@ -1,510 +0,0 @@ -package main - -import ( - "context" - "errors" - "fmt" - "log/slog" - "regexp" - "strings" - "time" - - "github.com/dtoro/oikos/internal/nomos/session" -) - -// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the -// shared MCP server (api:8090/mcp) has no session id — so these are handled -// in-process by nomos, which knows the session/task and holds the store. -// buildTools appends these to the model's tool list; the agent loop routes a -// call whose name isTaskTool to handleTaskTool instead of the MCP client. -// -// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step / -// ask_operator land in later phases through the same mechanism. - -func taskToolDefs() []toolDef { - return []toolDef{ - { - Name: "set_goal", - Description: "State the goal of this task in one sentence, as early as you " + - "can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " + - "as an LXC on strong'); it heads the task on the board and the context " + - "panel. Call it once you understand what the operator wants.", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."}, - }, - "required": []string{"goal"}, - }, - }, - { - Name: "propose_plan", - Description: "Propose the full ordered plan for this task. Call ONCE, before any " + - "execution, with EVERY step end-to-end (not one step at a time). FIRST step: " + - "research (prior knowledge, relations, blast radius). If your plan runs `run` " + - "against any target, include a LAST step: write back " + - "(update_entity_attributes + create_relationship + upsert_knowledge) — if you " + - "omit it, one is auto-appended. After this call: STOP and wait for operator " + - "approval (approval vocabulary: approved, yes, go, proceed, continue, ok, " + - "go ahead). Once a step has started (running/done/...), this tool REFUSES " + - "further calls — advance with update_plan_step + run instead. Re-propose only " + - "if the operator explicitly asks you to revise the whole plan. complete_task " + - "with outcome=success is REFUSED if you ran `run` but didn't call " + - "update_entity_attributes/create_relationship — write back before completing.", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "steps": map[string]any{ - "type": "array", - "description": "Ordered steps, first to last.", - "items": map[string]any{ - "type": "object", - "properties": map[string]any{ - "title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."}, - "detail": map[string]any{"type": "string", "description": "Optional one-line detail."}, - "target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."}, - }, - "required": []string{"title"}, - }, - }, - }, - "required": []string{"steps"}, - }, - }, - { - Name: "update_plan_step", - Description: "Advance a plan step as you work it. Set status to 'running' when " + - "you start it (pass execution_id if the step queued a gated action, so " + - "the board can auto-close it when that finishes), then 'done' / 'failed' " + - "/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " + - "view honest.", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."}, - "status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."}, - "execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."}, - }, - "required": []string{"seq", "status"}, - }, - }, - { - Name: "ask_operator", - Description: "Ask the operator a question when you hit a real decision only " + - "they can make — an ambiguous target, a trade-off, missing information, " + - "or a destructive choice not already approved. This pins a structured " + - "question card in the context panel (with your options and the entities " + - "involved) and PAUSES the task until they answer; their answer resumes " + - "you automatically. Do NOT use it for things you can determine yourself " + - "with tools — only for genuine decisions.", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "prompt": map[string]any{"type": "string", "description": "The question, stated plainly."}, - "why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."}, - "options": map[string]any{ - "type": "array", "items": map[string]any{"type": "string"}, - "description": "The choices, if it's a pick-one decision.", - }, - "context_entities": map[string]any{ - "type": "array", "items": map[string]any{"type": "string"}, - "description": "Entity slugs relevant to the decision (shown as chips).", - }, - }, - "required": []string{"prompt"}, - }, - }, - { - Name: "complete_task", - Description: "Mark the current task finished. Call this once the goal is " + - "verified done — or when you've genuinely failed or only partially " + - "succeeded. Sets the task's outcome and a one-line summary shown on the " + - "task board. Record what you learned with upsert_knowledge BEFORE " + - "completing, so future tasks on the same entities benefit.", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "outcome": map[string]any{ - "type": "string", - "enum": []string{"success", "failure", "partial"}, - "description": "Did the task achieve its goal?", - }, - "summary": map[string]any{ - "type": "string", - "description": "One line describing the result (shown on the task card).", - }, - }, - "required": []string{"outcome", "summary"}, - }, - }, - } -} - -// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int. -func toInt(v any) int { - switch n := v.(type) { - case float64: - return int(n) - case int: - return n - default: - return 0 - } -} - -// toStringSlice coerces a JSON tool-arg array to a non-empty []string. -func toStringSlice(v any) []string { - arr, ok := v.([]any) - if !ok { - return nil - } - out := make([]string, 0, len(arr)) - for _, e := range arr { - if s, ok := e.(string); ok && strings.TrimSpace(s) != "" { - out = append(out, s) - } - } - return out -} - -// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it -// handled the call, or (nil, false) if name is not a local task tool (so the -// caller forwards it to the MCP client). -func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) { - switch name { - case "set_goal": - goal, _ := args["goal"].(string) - if strings.TrimSpace(goal) == "" { - return "error: set_goal needs a goal", true - } - if err := a.store.SetGoal(ctx, sessionID, goal); err != nil { - return fmt.Sprintf("error setting goal: %v", err), true - } - // P1: the plan window is NOT opened here. Opening it on set_goal - // meant any config_mutation `run` auto-executed with zero operator - // approval, before a plan was even proposed (let alone approved) — - // a safety regression confirmed live in session d0d562e0. The - // window is now opened only when the operator approves a plan - // (chat-assent grant or explicit approval in agent.go), which is - // what the SOUL.md "approve the plan, not each step" model actually - // describes. set_goal records the goal + flips status to executing - // and nothing more. - response := "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). After propose_plan: if all steps are read-only, execute immediately (no approval needed). If any step is config_mutation/destructive, stop and wait for operator approval." - // P1.3 (2026-07-20): surface prior partial/failed sessions for the - // same problem so the agent can pick up the thread instead of - // rediscovering it. Three rclone sessions (a51e2086, 8acea2e3, - // cb8c8a4a) all bounced off the classifier because each new session - // started from scratch. The agent gets a hint with the prior - // goal + summary; if it looks related, search_knowledge or open - // the prior session's transcript (GET /sessions/{id}) before - // re-planning. See plans/2026-07-20-session-review-ten-sessions.md. - prior, _ := a.store.RecentPartialSessions(ctx, sessionID, 24*time.Hour) - if len(prior) > 0 { - var b strings.Builder - b.WriteString("\n\nNOTE — recent unfinished sessions (last 24h, outcome=partial/failed):") - for i, p := range prior { - if i >= 5 { - b.WriteString(fmt.Sprintf("\n ...and %d more", len(prior)-5)) - break - } - sum := p.Summary - if sum == "" { - sum = "(no summary)" - } - if len(sum) > 200 { - sum = sum[:200] + "..." - } - b.WriteString(fmt.Sprintf("\n - %s (sid %s, outcome=%s): %s", - p.Goal, p.ID[:8], p.Outcome, sum)) - } - b.WriteString("\nIf any of these looks like the same problem, search_knowledge for the prior investigation or read it via GET /sessions/{id} before re-planning — don't rediscover what was already learned.") - response += b.String() - } - return response, true - - case "propose_plan": - raw, _ := args["steps"].([]any) - var steps []session.PlanStepInput - for _, r := range raw { - m, ok := r.(map[string]any) - if !ok { - continue - } - title, _ := m["title"].(string) - if strings.TrimSpace(title) == "" { - continue - } - detail, _ := m["detail"].(string) - target, _ := m["target_slug"].(string) - steps = append(steps, session.PlanStepInput{Title: title, Detail: detail, TargetSlug: target}) - } - if len(steps) == 0 { - return "error: propose_plan needs at least one step with a title", true - } - // D.2: auto-append a writeback step if the agent didn't include one. - // The agent consistently writes vague last steps ("record findings") - // and then skips update_entity_attributes entirely (the #1 cause of - // knowledge-graph drift). Appending an explicit writeback step makes - // the seq-order enforcement (5.6) require it to be completed last, - // and D.1's complete_task gate enforces the actual calls. Together - // they close the loop structurally — neither relies on the agent - // reading SOUL.md. The match is broadened past the literal tool - // names so a natural-language step ("Write back: update entity - // attributes…") isn't doubled by an auto-appended duplicate (P1.2). - hasWritebackStep := false - for _, st := range steps { - t := strings.ToLower(st.Title + " " + st.Detail) - if strings.Contains(t, "update_entity_attributes") || - strings.Contains(t, "create_relationship") || - strings.Contains(t, "upsert_knowledge") || - strings.Contains(t, "write back") || - strings.Contains(t, "writeback") { - hasWritebackStep = true - break - } - } - appendedNote := "" - if !hasWritebackStep { - steps = append(steps, session.PlanStepInput{ - Title: "Write back: update_entity_attributes + create_relationship + upsert_knowledge", - Detail: "Call update_entity_attributes for every entity you ran against (versions, states, counts, timestamps). Call create_relationship for any edge you discovered. Then upsert_knowledge about the affected entities (pass `about` as an array).", - }) - appendedNote = fmt.Sprintf(" (appended a writeback step — your plan didn't include one; step %d)", len(steps)) - } - persisted, err := a.store.ProposePlan(ctx, sessionID, steps) - if err != nil { - if errors.Is(err, session.ErrPlanInFlight) { - // The plan is already in flight — refuse the re-proposal. - // The agent must advance the existing plan with - // update_plan_step + run. This is the structural fix for - // the "plan added twice" sidebar drift the operator - // reported: instead of appending (which duplicated) or - // wiping (which lost progress), we refuse and direct. - return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true - } - return fmt.Sprintf("error proposing plan: %v", err), true - } - // The writeback step is now always present (D.2 auto-appends it if - // the agent forgot), so the old advisory nudge is replaced by the - // structural gate: D.1 refuses complete_task without the actual - // update_entity_attributes/create_relationship calls. Enumerate the - // step seqs so the model knows exactly which numbers to address with - // update_plan_step (seq is 1-based within this plan — the addressing - // key, not a global counter). - var seqs strings.Builder - for i, p := range persisted { - if i > 0 { - seqs.WriteString("; ") - } - title := fmt.Sprint(p["title"]) - fmt.Fprintf(&seqs, "%v=%s", p["seq"], title) - } - result := fmt.Sprintf("Plan set (%d steps): %s.%s Address them with update_plan_step(seq=N). If all steps are read-only, execute now — call update_plan_step(running) + run for each step, no approval needed. If any step is config_mutation/destructive, STOP and wait for operator approval (\"approved\", \"yes\", \"go\", \"proceed\", \"continue\", \"ok\", \"go ahead\"). Do not call propose_plan again.", len(persisted), seqs.String(), appendedNote) - return result, true - - case "update_plan_step": - seq := toInt(args["seq"]) - status, _ := args["status"].(string) - execID, _ := args["execution_id"].(string) - if seq <= 0 || status == "" { - return "error: update_plan_step needs seq (>=1) and status", true - } - reason, _ := args["replaced_reason"].(string) - if err := a.store.UpdatePlanStep(ctx, sessionID, seq, status, execID, reason); err != nil { - if errors.Is(err, session.ErrPlanStepNotFound) { - // The seq doesn't address a step in the CURRENT plan — most - // often a stale 1-based number the model carried across a - // re-plan, or an out-of-range seq. seq is generation-relative - // (1..N within the latest propose_plan), so a superseded - // generation's row is never touched (P0.1 fix 3). Direct the - // model instead of silently no-op'ing. - return fmt.Sprintf("Step %d is not in the current plan. seq is 1-based within your latest propose_plan (a re-plan resets it to 1..N, so an old step number no longer applies). The plan was not changed. Re-address with the correct 1-based seq, or if you've lost track, re-read the plan.", seq), true - } - return fmt.Sprintf("error updating step %d: %v", seq, err), true - } - return fmt.Sprintf("Step %d → %s. (Advance with update_plan_step + run; do not re-propose.)", seq, status), true - - case "ask_operator": - prompt, _ := args["prompt"].(string) - if strings.TrimSpace(prompt) == "" { - return "error: ask_operator needs a prompt", true - } - qctx := map[string]any{} - if why, _ := args["why"].(string); strings.TrimSpace(why) != "" { - qctx["why"] = why - } - if opts := toStringSlice(args["options"]); len(opts) > 0 { - qctx["options"] = opts - } - if ents := toStringSlice(args["context_entities"]); len(ents) > 0 { - qctx["entities"] = ents - } - if _, err := a.store.AskOperator(ctx, sessionID, prompt, qctx); err != nil { - return fmt.Sprintf("error posting question: %v", err), true - } - return "Question posted to the operator; the task is paused until they answer. " + - "Do not continue or call more tools — end your turn now and wait for their answer.", true - - case "complete_task": - outcome, _ := args["outcome"].(string) - summary, _ := args["summary"].(string) - switch outcome { - case "": - outcome = "success" // no outcome given at all — assume success, the common case - case "success", "failure", "partial": - // valid, use as-is - default: - // The tool schema declares an enum, but a weaker model (or a - // typo) can still send anything — an unrecognized value used to - // persist as-is, silently, with only "failure" special-cased - // (store.completeTask derives status='failed' from it; anything - // else became status='done' regardless of what the value - // actually said). Default to "partial" rather than silently - // treating an unrecognized value as "success" — safer to - // under-claim than over-claim a task's outcome. - slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial", - "session", sessionID, "outcome", outcome) - outcome = "partial" - } - // D.1: refuse success when discovery ran but no writeback followed. - // The prior advisory warning (below) was ignorable — the agent - // saw it and ended the task anyway. This gate fires BEFORE - // completeTask runs, so the session stays in 'executing' state - // and the agent must call update_entity_attributes/create_relationship - // then retry complete_task. Only blocks `success`; an explicit - // `failure` or `partial` is allowed through (the agent is - // acknowledging it didn't finish — no reason to force writeback). - if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) && !a.store.HadEntityWriteback(ctx, sessionID) { - return "Refused: this session ran `run` against live targets (discovery) but did not call update_entity_attributes or create_relationship to persist what you learned. The knowledge graph will drift if you complete without writeback. Call update_entity_attributes for each entity you ran against (versions, states, counts, timestamps), and create_relationship for any edge you discovered, then call complete_task again. Outcome is held at 'executing' until you do.", true - } - // D.2: refuse success when the goal mentions a reachability/uptime - // check but no verification was done. The agent can't claim "X is - // reachable" based on a shell command alone — the proxy (Caddy) can - // return 200 for a terminal page (ttyd) or fallback while the actual - // dashboard is still down. Must call ping_service or run a successful - // curl before claiming success. - if outcome == "success" && a.store.HadDiscovery(ctx, sessionID) { - goal := a.store.SessionGoal(ctx, sessionID) - if mentionsReachability(goal) && !a.store.HadRecentVerification(ctx, sessionID) { - return "Refused: the goal involves a reachability or uptime check (\"make X reachable\", \"get X up\", etc.), but no ping_service call or successful curl/HTTP request against the target was detected. Caddy can return 200 for a terminal or fallback page while the actual service is still down — you must verify the service itself, not just the proxy. Call ping_service(target) or run a curl against the actual service URL, then call complete_task again. Outcome held until verified.", true - } - } - if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil { - if errors.Is(err, session.ErrTaskAlreadyComplete) { - return "Task is already complete. Do not call complete_task again. If the operator pointed out a UI/sidebar inconsistency, fix it with update_plan_step (reconcile step states) or summarize the panel in your reply — do not re-execute the work.", true - } - return fmt.Sprintf("error completing task: %v", err), true - } - result := fmt.Sprintf("Task marked %s: %s", outcome, summary) - if !a.store.HadEntityWriteback(ctx, sessionID) { - result += "\n\n⚠️ No entity attributes or relationships were updated in this session. Call update_entity_attributes and create_relationship to persist what you learned about entities before the next session starts from scratch." - } - return result, true - default: - return nil, false - } -} - -// reachabilityPatterns matches goal text that involves making something -// reachable/accessible/working. Used by complete_task to surface a soft -// warning when the session goal was about reachability but no verification -// occurred before marking success. -var reachabilityPatterns = []*regexp.Regexp{ - regexp.MustCompile(`(?i)https?://[^\s]+`), - regexp.MustCompile(`(?i)\.hubris\.net\w+`), - regexp.MustCompile(`(?i)(un)?reachable`), - regexp.MustCompile(`(?i)(not?\s+)?(accessible|reachable|responding|resolving)`), - regexp.MustCompile(`(?i)diagnose\s+why`), - regexp.MustCompile(`(?i)(fix|restore|bring\s+back).*(accessible|reachable|online)`), -} - -func mentionsReachability(goal string) bool { - for _, p := range reachabilityPatterns { - if p.MatchString(goal) { - return true - } - } - return false -} - -// autoCompleteTrivialTask is the case-1 fix from -// plans/2026-07-11-task-completion-safety-net.md: a session that never -// called set_goal never framed itself as a structured task, so a turn that -// ends with a plain-text answer and no further tool calls IS the task -// ending — but the model consistently skips complete_task for exactly this -// case (confirmed live: 43/50 production sessions were a single trivial -// Q&A exchange, none of which ever reached a terminal status). Rather than -// leave agent_sessions.status stuck at its creation-time default forever, -// close it out mechanically here: no judgment call needed, since SOUL.md -// already treats a one-shot answered question as done by definition. -func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) { - summary := strings.TrimSpace(responseText) - summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line - const maxLen = 120 - if len(summary) > maxLen { - summary = summary[:maxLen] + "…" - } - if summary == "" { - summary = "Answered without further action needed." - } - if err := a.store.CompleteTask(ctx, sessionID, "success", summary); err != nil { - slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err) - } -} - -// autoCompleteIfPlanDone is the structural safety net for "the agent did the -// work but forgot to call complete_task" — the #1 remaining model reliability -// gap after D.1's writeback gate. After a turn ends, if the session has a goal, -// the agent never called complete_task this turn, and either (a) all plan -// steps are terminal OR (b) the agent did discovery (ran `run`), auto-complete. -// Path (b) catches the common case where the agent skips update_plan_step -// bookkeeping but still does the actual work — the D.1 gate already enforces -// writeback before `complete_task`, so if the agent forgot to complete at all, -// we close it out mechanically. If writeback happened → success; if not → -// partial (honest: work was done but knowledge graph wasn't updated). -func (a *agent) autoCompleteIfPlanDone(ctx context.Context, sessionID, responseText string) { - if a.store == nil || sessionID == "" || sessionID == "ephemeral" { - return - } - sess, err := a.store.GetSession(ctx, sessionID) - if err != nil || sess.Status != "executing" { - return - } - // Don't auto-complete if there are pending approvals — the agent is - // blocked waiting for the operator, not done. Auto-completing here - // would close the session and the operator's approval would land on a - // dead task. Confirmed in eval: agent hits P5 approval gate, turn - // ends, auto-complete fires incorrectly because the approval-queue - // `run` responses were logged as success=true in agent_activity. - if a.store.HasPendingApprovals(ctx, sessionID) { - return - } - discovery := a.store.HadDiscovery(ctx, sessionID) - writeback := a.store.HadEntityWriteback(ctx, sessionID) - // (a) all plan steps terminal, OR (b) agent did discovery (ran `run`). - shouldComplete := a.store.AllPlanStepsTerminal(ctx, sessionID) - if !shouldComplete && discovery { - shouldComplete = true - } - if !shouldComplete { - return - } - outcome := "success" - if discovery && !writeback { - outcome = "partial" // honest: work done, knowledge graph not updated - } - summary := strings.TrimSpace(responseText) - summary = strings.SplitN(summary, "\n", 2)[0] - const maxLen = 120 - if len(summary) > maxLen { - summary = summary[:maxLen] + "…" - } - if summary == "" { - summary = "All plan steps completed." - } - if err := a.store.CompleteTask(ctx, sessionID, outcome, summary); err != nil { - slog.Error("nomos: auto-complete plan-done task failed", "session", sessionID, "error", err) - } else { - slog.Info("nomos: auto-completed task — agent didn't call complete_task", "session", sessionID, "outcome", outcome) - } -} diff --git a/cmd/nomos/workers.go b/cmd/nomos/workers.go deleted file mode 100644 index 8a91d316..00000000 --- a/cmd/nomos/workers.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "log/slog" - "strings" - "time" - - "github.com/google/uuid" -) - -// runChatTurn is the shared core of an operator-initiated turn: insert an -// assistant placeholder, run a.chat with incremental persistence (so whatever -// happened before an abort is never lost), finalize the row, and derive a -// title. It is agnostic to the transport: `sink` receives every agent event -// for delivery (SSE for a live handleChat, a no-op for a queued turn that has -// no client attached — the frontend learns about those via the poller + the -// status-driven "working" signal). The caller MUST already hold the session's -// turn-gate permit. -func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) { - toolCalls := []map[string]any{} - // P3: accumulate per-iteration reasoning instead of overwriting with the - // final `text` event (see the original inline comment in handleChat). - var textParts []string - var thinkingParts []string - var finalText string - var finalThinking string - - placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""}) - msgID, err := a.store.InsertMessageReturningID(pctx, sessionID, "assistant", placeholder) - if err != nil { - slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err) - } - persist := func() { - if msgID == uuid.Nil { - return - } - body, _ := json.Marshal(map[string]any{ - "role": "assistant", - "text": finalText, - "thinking": finalThinking, - "tool_calls": toolCalls, - }) - a.store.UpdateMessage(pctx, msgID, body) - } - - a.chat(ctx, sessionID, message, func(ev agentEvent) { - if ev.Type == "tool_use" || ev.Type == "tool_result" { - if m, ok := ev.Data.(map[string]any); ok { - m["type"] = ev.Type - // One entry per tool call: tool_use creates it, tool_result - // merges the result into the same entry (matched by id). - id, _ := m["id"].(string) - if id != "" && ev.Type == "tool_result" { - for _, existing := range toolCalls { - if eID, _ := existing["id"].(string); eID == id { - for k, v := range m { - existing[k] = v - } - break - } - } - } else { - toolCalls = append(toolCalls, m) - } - } - persist() // live: survives even if the client disconnects right after - } - if ev.Type == "text" { - if t, ok := ev.Data.(string); ok && t != "" { - if ev.IsThinking { - thinkingParts = append(thinkingParts, t) - finalThinking = strings.Join(thinkingParts, "\n\n") - } else { - textParts = append(textParts, t) - finalText = strings.Join(textParts, "\n\n") - } - persist() - } - } - sink(ev) - }) - - // B.6: if the turn ended with no text and no tool calls (the model - // empty-response'd and all retries failed), delete the placeholder row - // instead of persisting an empty bubble. - if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil { - a.store.DeleteMessage(pctx, msgID) - } else { - persist() // final state — same row, updated one last time - } - - // Title: prefer the goal once set; else the first assistant answer. - if finalText != "" && sessionID != "ephemeral" { - var goalTitle string - if sess, gerr := a.store.GetSession(pctx, sessionID); gerr == nil && sess.Goal != "" { - goalTitle = truncate(sess.Goal, 120) - } - title := goalTitle - if title == "" { - title = truncate(finalText, 80) - } - if title != "" { - a.store.UpdateSessionTitle(pctx, sessionID, title) - } - } -} - -// drainAcquireWait is how long drainQueued blocks for a busy gate before -// re-queuing and deferring to the holder's own release-drain. A package var so -// tests can shorten it; in production it just needs to outlast the brief -// release→drain handoff window. -var drainAcquireWait = 5 * time.Second - -// drainQueued runs every queued operator message for a session as its own turn, -// one at a time, under the turn gate. Called (in a goroutine) whenever a turn -// releases the gate — from handleChat (live) and resumeSession (background) — -// so a message queued while the agent was busy is acted on as soon as it's -// free, without the operator re-sending. See messagequeue.go (plan 2026-08-03 -// F2). -// -// Each queued turn is persisted incrementally and has no SSE client (the -// browser detached after receiving the `queued` event); the frontend sees the -// result via the 3s poller and the status-driven "working" indicator. -func (a *agent) drainQueued(ctx context.Context, sessionID string) { - for { - msg, ok := a.queue.Dequeue(sessionID) - if !ok { - return - } - // Block briefly for the gate. If a live turn grabbed it first, put the - // message back — that turn's release will drain it again. Never stack. - if !a.gate.Acquire(sessionID, drainAcquireWait) { - a.queue.RequeueFront(sessionID, msg) - return - } - slog.Info("nomos: running queued operator message", "session", sessionID) - pctx := context.Background() - // Run the turn inside a per-iteration closure so the gate release is - // deferred to the end of THIS turn (and runs even if runChatTurn - // panics — safego recovers the panic at the goroutine boundary, so a - // non-deferred release would be skipped and the session's permit held - // forever, deadlocking all future turns). A bare `defer release` in - // the loop would be wrong too: Go defers run at function exit, not - // iteration exit, so the gate would stay held across iterations. - func() { - defer a.gate.Release(sessionID) - a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {}) - }() - } -} \ No newline at end of file diff --git a/cmd/oikos/main.go b/cmd/oikos/main.go index cca8f03e..3eff5922 100644 --- a/cmd/oikos/main.go +++ b/cmd/oikos/main.go @@ -150,7 +150,7 @@ Roles: knowledge Convert wiki to knowledge seed (one-shot) version Print version info -The operator interface is Nomos (MCP agent) — no CLI needed. +The operator interface is dsh (MCP agent) — no CLI needed. Environment: OIKOS_DATABASE_URL Postgres connection string OIKOS_API_LISTEN API listen address (default :8090) diff --git a/cmd/webhook/main.go b/cmd/webhook/main.go index 7c064228..c857fdf6 100644 --- a/cmd/webhook/main.go +++ b/cmd/webhook/main.go @@ -17,21 +17,84 @@ import ( "github.com/dtoro/oikos/internal/safego" ) +type webhookHandler struct { + secret string + script string +} + +func (h *webhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "read body failed", 400) + return + } + + sigHex := r.Header.Get("X-Hub-Signature-256") + if sigHex == "" { + http.Error(w, "missing signature", 401) + return + } + + mac := hmac.New(sha256.New, []byte(h.secret)) + mac.Write(body) + expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + + if !hmac.Equal([]byte(sigHex), []byte(expected)) { + slog.Warn("webhook: invalid signature") + http.Error(w, "invalid signature", 401) + return + } + + slog.Info("webhook: deploy triggered", "script", h.script) + w.WriteHeader(http.StatusAccepted) + w.Write([]byte(`{"status":"deploy started"}`)) + + safego.Go("webhook:"+h.script, func() { + apiToken := "" + if sec != nil { + apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "") + } + cmd := exec.Command(h.script) + cmd.Dir = repoDir + cmd.Env = append(os.Environ(), + "REPO_DIR="+repoDir, + "PROFILE=full", + "OIKOS_API_TOKEN="+apiToken, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + start := time.Now() + if err := cmd.Run(); err != nil { + slog.Error("webhook: deploy failed", "script", h.script, "error", err, "duration", time.Since(start)) + return + } + slog.Info("webhook: deploy succeeded", "script", h.script, "duration", time.Since(start)) + }) +} + +var ctx context.Context +var sec *secrets.Manager +var repoDir string + func main() { - ctx := context.Background() + ctx = context.Background() port := os.Getenv("WEBHOOK_LISTEN") if port == "" { port = ":9797" } - repoDir := os.Getenv("WEBHOOK_REPO_DIR") + repoDir = os.Getenv("WEBHOOK_REPO_DIR") if repoDir == "" { repoDir = os.Getenv("HOME") + "/Projects/oikos" } - // Create secrets manager once, share between HMAC resolution and deploy - sec := newSecrets() + sec = newSecrets() secret := resolveWebhookHMAC(ctx, sec) if secret == "" { fmt.Fprintln(os.Stderr, "WEBHOOK_HMAC_SECRET must be set (env var or Infisical webhook_hmac-secret)") @@ -39,60 +102,8 @@ func main() { } mux := http.NewServeMux() - mux.HandleFunc("/deploy", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", 405) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "read body failed", 400) - return - } - - sigHex := r.Header.Get("X-Hub-Signature-256") - if sigHex == "" { - http.Error(w, "missing signature", 401) - return - } - - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - expected := "sha256=" + hex.EncodeToString(mac.Sum(nil)) - - if !hmac.Equal([]byte(sigHex), []byte(expected)) { - slog.Warn("webhook: invalid signature") - http.Error(w, "invalid signature", 401) - return - } - - slog.Info("webhook: deploy triggered") - w.WriteHeader(http.StatusAccepted) - w.Write([]byte(`{"status":"deploy started"}`)) - - safego.Go("webhook:deploy", func() { - apiToken := "" - if sec != nil { - apiToken = secrets.ResolveSecret(ctx, sec, "api_token", "") - } - cmd := exec.Command(repoDir + "/scripts/deploy.sh") - cmd.Dir = repoDir - cmd.Env = append(os.Environ(), - "REPO_DIR="+repoDir, - "PROFILE=full", - "OIKOS_API_TOKEN="+apiToken, - ) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - start := time.Now() - if err := cmd.Run(); err != nil { - slog.Error("webhook: deploy failed", "error", err, "duration", time.Since(start)) - return - } - slog.Info("webhook: deploy succeeded", "duration", time.Since(start)) - }) - }) + mux.Handle("/deploy", &webhookHandler{secret: secret, script: repoDir + "/scripts/deploy.sh"}) + mux.Handle("/deploy-plugins", &webhookHandler{secret: secret, script: repoDir + "/scripts/deploy-plugins.sh"}) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -106,7 +117,6 @@ func main() { } } -// newSecrets creates the Infisical secrets manager from env vars. func newSecrets() *secrets.Manager { return secrets.NewManagerFromConfig( os.Getenv("OIKOS_INFISICAL_SITE_URL"), @@ -118,8 +128,6 @@ func newSecrets() *secrets.Manager { ) } -// resolveWebhookHMAC fetches the webhook HMAC secret from Infisical, -// falling back to the WEBHOOK_HMAC_SECRET env var. func resolveWebhookHMAC(ctx context.Context, sec *secrets.Manager) string { envFallback := os.Getenv("WEBHOOK_HMAC_SECRET") if sec == nil { diff --git a/compose/nomos/Dockerfile b/compose/nomos/Dockerfile deleted file mode 100644 index ae780d92..00000000 --- a/compose/nomos/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# Nomos agent container — standalone MCP client gateway (Phase 4) -FROM golang:1.26-alpine AS builder - -RUN apk add --no-cache git ca-certificates - -WORKDIR /build -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . - -RUN CGO_ENABLED=0 go build -o /nomos -tags timetzdata -ldflags="-s -w" ./cmd/nomos - -FROM gcr.io/distroless/static:nonroot - -COPY --from=builder /nomos /nomos -COPY nomos/ /app/nomos/ - -ENV NOMOS_MCP_URL=http://api:8090/mcp -ENV NOMOS_AGENT_SLUG=agent:nomos -ENV NOMOS_LISTEN=:8092 -ENV NOMOS_MODEL=deepseek/deepseek-v4-pro - -EXPOSE 8092 - -ENTRYPOINT ["/nomos", "serve"] diff --git a/docker-compose.yml b/docker-compose.yml index 684ab47e..d98b8f88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,9 @@ # Usage: docker compose up -d postgres (just the DB) # make dev (full dev stack) # -# The SPA isn't embedded in the oikos binary (see -# plans/2026-07-12-wails-desktop-app.md 0.1/0.6) but it IS part of this -# stack as its own `web` service (compose/web/Dockerfile), so it deploys -# through the same push-to-main pipeline as everything else. `npm run dev` -# in web/ is still the fast local-iteration path. +# The control-room SPA lives in its own repo (dtoro/oikos-web) with its own +# compose project; the agent runtime (dsh) runs outside this stack and talks +# to api's /mcp like any MCP client. services: postgres: @@ -78,13 +76,12 @@ services: OIKOS_ENV: dev OIKOS_DEBUG: "true" # No dev-open auth bypass (plans/2026-07-12-wails-desktop-app.md 0.4) — - # every request needs this token. nomos uses the same value to call - # back into api's /mcp and /api/v1/approvals/*/decision. + # every request needs this token. dsh uses the same value to call + # into api's /mcp and /api/v1/approvals/*/decision. OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} OIKOS_OIDC_ISSUER: ${OIKOS_OIDC_ISSUER:-https://auth.hubris.network/application/o/oikos/} OIKOS_OIDC_CLIENT_ID: ${OIKOS_OIDC_CLIENT_ID:-otkHBSueHJsYtOHstL6rn5izeGgyOsavp1qA1hod} OIKOS_NOMOS_AGENT_SLUG: ${OIKOS_NOMOS_AGENT_SLUG:-agent:nomos} - NOMOS_PROXY_URL: http://nomos:8092 # Rate limiting (plan D3). Default off; set OIKOS_API_RATE_LIMIT to a # requests/sec value to throttle runaway agent loops per source IP. OIKOS_API_RATE_LIMIT: ${OIKOS_API_RATE_LIMIT:-} @@ -104,10 +101,9 @@ services: stop_grace_period: 30s mem_limit: 512m cpus: 1.0 - # Exists so nomos can wait for the API to actually answer rather than just - # for its container to exist — see nomos's depends_on below. wget is - # BusyBox's, already in the alpine runtime image, so this adds no - # dependency. /healthz pings the DB, so "healthy" means genuinely ready. + # Self-probe so `docker compose ps` reports genuine readiness: /healthz + # pings the DB, so "healthy" means actually answering. wget is BusyBox's, + # already in the alpine runtime image, so this adds no dependency. healthcheck: test: ["CMD", "wget", "-q", "-O", "-", "http://127.0.0.1:8090/healthz"] interval: 5s @@ -118,7 +114,7 @@ services: # The api's NewHandler stalls on TWO unreachable external deps at startup # before binding :8090: Infisical (4x auth retries, ~40s) and OIDC # discovery (auth.hubris.network, ~35s of timeouts). Total ~90-95s, so - # the start period must clear it or nomos (depends_on: api-healthy) fails. + # the start period must clear it. start_period: 180s # Scheduler (Phase 3) — observe loop @@ -188,52 +184,11 @@ services: retries: 3 start_period: 90s - # Nomos agent gateway (Phase 4) — mesh-published :8092 - nomos: - image: oikos-nomos:${OIKOS_VERSION:-latest} - build: - context: . - dockerfile: compose/nomos/Dockerfile - restart: unless-stopped - profiles: ["full"] - depends_on: - api: - # service_started only waits for the container to exist, so nomos came - # up while the API was still binding :8090, failed its MCP initialize, - # exited 1, and crash-looped for ~25s on every single deploy. It always - # recovered, which is exactly why it went unnoticed. service_healthy - # waits for the API to actually answer. - condition: service_healthy - environment: - NOMOS_MCP_URL: http://api:8090/mcp - NOMOS_AGENT_SLUG: agent:nomos - OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} - NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro} - DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable - # Must match api's OIKOS_MCP_BEARER_TOKEN above — api's combinedAuth - # rejects every request without it now (no dev-open bypass). - OIKOS_MCP_BEARER_TOKEN: ${OIKOS_MCP_BEARER_TOKEN:-dev-token} - # Infisical secret store (Phase 5) — nomos resolves mcp_bearer-token - # and openrouter_api-key from here, overriding the env values above. - OIKOS_INFISICAL_SITE_URL: ${OIKOS_INFISICAL_SITE_URL:-} - OIKOS_INFISICAL_CLIENT_ID: ${OIKOS_INFISICAL_CLIENT_ID:-} - OIKOS_INFISICAL_CLIENT_SECRET: ${OIKOS_INFISICAL_CLIENT_SECRET:-} - OIKOS_INFISICAL_PROJECT_ID: ${OIKOS_INFISICAL_PROJECT_ID:-} - OIKOS_INFISICAL_ENV: ${OIKOS_INFISICAL_ENV:-dev} - ports: - - "8092:8092" - stop_signal: SIGTERM - stop_grace_period: 10s - mem_limit: 512m - cpus: 1.0 - # nomos runs on a distroless image (no shell/wget), so the healthcheck - # uses the binary's own `healthcheck` subcommand to self-probe /healthz. - healthcheck: - test: ["CMD", "/nomos", "healthcheck"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 15s + # Nomos agent gateway (Phase 4) — decommissioned. Replaced by dsh + # (DeepSeek Harness) as the agent runtime; see + # plans/2026-08-16-dsh-as-agent-replace-nomos.md section 5. dsh runs + # outside this stack (Node.js sidecar, dev at http://127.0.0.1:3080) + # and talks to api's /mcp like any MCP client. # The control-room SPA moved to its own repo (dtoro/oikos-web, Phase 1 of # plans/2026-08-15-hexagonal-architecture.md) with its own compose project diff --git a/evals/golden.yaml b/evals/golden.yaml deleted file mode 100644 index a1172146..00000000 --- a/evals/golden.yaml +++ /dev/null @@ -1,55 +0,0 @@ -# Golden conversation evals for the nomos agent. -# Run: go run ./cmd/nomos/eval -gateway http://localhost:8092 -manifest evals/*.yaml -# -# Each conversation costs real OpenRouter credits (~$0.01–0.05). The runner -# sends the prompt, waits for the turn to finish, optionally sends a followup, -# and scores assertions against the final persisted transcript. -# -# These are STRUCTURAL assertions only — tool-call sequences, plan steps, -# writeback, completion. Text quality is model-dependent and not scored. - -# --- eval 1: trivial read-only task (degenerate case) --- -- name: trivial_readonly - prompt: "What is the state of lxc:dns? One line, no plan needed." - assertions: - - kind: completes - - kind: no_propose_plan # trivial — no ceremony - - kind: max_tool_calls - value: 5 # get_entity + complete_task + maybe one more - - kind: no_run # read-only, no `run` needed - -# --- eval 2: the operator's original bug — plan + proceed --- -- name: plan_advances_on_proceed - prompt: "Check the uptime of lxc:gitea. Plan it out, propose the plan, then wait for my approval before running anything." - followup: "proceed with the rest" - assertions: - - kind: completes - - kind: proposes_plan_once # propose_plan called exactly once - - kind: no_duplicate_proposal # the original bug: re-propose on "proceed" - - kind: writes_back # ran `run` → must update_entity_attributes (D.1) - - kind: no_duplicate_complete # C.1 — complete_task called at most once - -# --- eval 3: UI complaint should not re-execute (C.2) --- -- name: ui_complaint_no_rerun - prompt: "Check the uptime of lxc:dns. Plan it out and wait for my approval." - followup: "go ahead" - assertions: - - kind: completes - - kind: proposes_plan_once - - kind: writes_back -# (The followup "go ahead" is approval, not a UI complaint — we'd test the -# complaint path separately with a second followup, but that needs the -# session to stay open after completion, which the runner doesn't support yet. -# For now this validates the approval-vocabulary path.) - -# --- eval 4: knowledge preferred over fleet re-execution (E.1) --- -# A same-day fleet audit knowledge entry exists in the DB. The agent should -# search_knowledge first and NOT run `run` against 20 LXCs. -- name: knowledge_preferred_over_rerun - prompt: "Give me an overview of what needs updating across the homelab, categorize by criticality. There may be a recent audit already." - assertions: - - kind: completes - - kind: calls_tool - value: search_knowledge # E.1 — must check the knowledge base first - - kind: max_run_calls - value: 4 # NOT 20+ — a targeted refresh only diff --git a/evals/iteration-followup.yaml b/evals/iteration-followup.yaml deleted file mode 100644 index e5f77d1c..00000000 --- a/evals/iteration-followup.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# P5 eval: iteration. A read-only task completes; the follow-up asks the -# agent to act on what it found (a config_mutation). The second followup -# is the approval ("go ahead") so the plan can execute. Asserts the session -# reopens, a second plan generation is created, and the agent completes -# both sub-tasks. -- name: iteration-followup - prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB." - followups: - - "The repos folder failed last time. Reset the failed service and re-run the backup." - - "go ahead" - assertions: - - kind: completes - - kind: plan_generations - value: 2 - - kind: proposes_plan - - kind: writes_back - - kind: max_run_calls - value: 40 diff --git a/evals/iteration-readonly.yaml b/evals/iteration-readonly.yaml deleted file mode 100644 index ae619ae5..00000000 --- a/evals/iteration-readonly.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# P5 eval: two read-only sub-tasks back-to-back. The follow-up is also -# read-only (no approval needed) so the agent can execute immediately after -# proposing the second plan. Asserts the session reopens and a second plan -# generation is created. -- name: iteration-readonly - prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled?" - followups: - - "Now check the uptime of lxc:dns." - assertions: - - kind: completes - - kind: plan_generations - value: 2 - - kind: proposes_plan - - kind: calls_tool - value: run - - kind: max_run_calls - value: 6 diff --git a/evals/no-plan-no-run.yaml b/evals/no-plan-no-run.yaml deleted file mode 100644 index 1eacb542..00000000 --- a/evals/no-plan-no-run.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# P5 eval: a pure-DB Q&A that calls NO run. This is the ONLY remaining -# carve-out from plan-first: a task that never touches a live target via -# `run` doesn't need propose_plan (the gate only fires on run). Asserts -# the agent answers directly and completes without ceremony. -- name: no-plan-no-run - prompt: "List all LXC containers and their current health." - assertions: - - kind: completes - - kind: no_run - - kind: no_propose_plan diff --git a/evals/plan-always-readonly.yaml b/evals/plan-always-readonly.yaml deleted file mode 100644 index 07947153..00000000 --- a/evals/plan-always-readonly.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# P5 eval: a read-only question that requires live inspection (not just DB -# lookup). Asserts the plan-first gate works: the agent must propose_plan -# before run, even for a trivial read-only task. -- name: plan-always-readonly - prompt: "Check the live systemd timer on lxc:rclone — when did the last backup to Proton Drive actually run, and when is the next one scheduled? Inspect the actual service state, don't just read the DB." - assertions: - - kind: completes - - kind: proposes_plan - - kind: plan_before_run - - kind: calls_tool - value: run - - kind: writes_back - - kind: max_run_calls - value: 6 diff --git a/internal/adapters/postgres/governance.go b/internal/adapters/postgres/governance.go index 3e60e7de..b50f2208 100644 --- a/internal/adapters/postgres/governance.go +++ b/internal/adapters/postgres/governance.go @@ -64,8 +64,9 @@ func (g *GovernanceRepo) AssentWindowActive(ctx context.Context, agentID domain. } // DestructiveWindowActive checks the target+session-scoped destructive -// window key. Key format must match cmd/nomos/store.go's -// openDestructiveWindow — both processes read/write the same rows. +// window key. Key format must match the writer in approvals.go +// (openDestructiveWindow) — same rows, same format: +// "destructive_window.agent:.target:". func (g *GovernanceRepo) DestructiveWindowActive(ctx context.Context, agentID domain.UUID, targetSlug, sessionID string) bool { if agentID == "" || targetSlug == "" || sessionID == "" { return false // fail closed diff --git a/internal/config/config.go b/internal/config/config.go index da4934d9..e2bed0c5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,7 +20,7 @@ type Config struct { // Auth (Phase 2: static bearer tokens + OIDC JWT) APIToken string // operator/CI bearer token for the REST API - MCPBearerToken string // shared secret for Nomos→API MCP calls + MCPBearerToken string // shared secret for dsh→API MCP calls OIDCIssuer string // OIDC issuer URL for JWT validation (e.g. https://authentik.example.com/application/o/oikos/) OIDCClientID string // OIDC client ID (aud claim expected in JWT) OIDCClientSecret string // optional client secret for token endpoint proxy (confidential clients) @@ -62,7 +62,9 @@ type Config struct { // Learning (Phase 3) LearningInterval time.Duration // pattern extraction interval (default 3600s) - // Nomos agent entity ID (Phase 4) + // Agent entity MCP activity is attributed to (slug agent:nomos, seeded). + // dsh calls MCP without per-agent identity, so this static entity is the + // attribution anchor. NomosAgentID string NomosAgentSlug string diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 77050cc5..4275d9a2 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -16,9 +16,7 @@ import ( "log/slog" "math/big" "net/http" - "net/http/httputil" "net/url" - "os" "strings" "sync" "time" @@ -350,16 +348,6 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, entities } r.With(combinedAuth(cfg, false)).Handle("/mcp", mcphandler.NewHandler(pool, cfg.MCPBearerToken, nomosAgentID, s.secretsManager, s.entities, s.relService, s.execSvc)) - if nomosURL := os.Getenv("NOMOS_PROXY_URL"); nomosURL != "" { - target, _ := url.Parse(nomosURL) - proxy := httputil.NewSingleHostReverseProxy(target) - // Was unauthenticated (pre-existing gap, predates the client/server - // split — this mount was never wrapped in combinedAuth, unlike every - // other custom route below). Harmless while dev-open was in effect; - // a real hole now that every route needs a real credential. - r.Mount("/agent", combinedAuth(cfg, false)(http.StripPrefix("/agent", proxy))) - } - return r } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index ec62226a..fa792b66 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -555,7 +555,7 @@ func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult { if err != nil { return textResult(fmt.Sprintf("error: %v", err)) } - hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)") + hreq.Header.Set("User-Agent", "oikos-mcp/1.0 (+homelab)") hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5") client := &http.Client{Timeout: 20 * time.Second} diff --git a/internal/nomos/messagequeue/messagequeue.go b/internal/nomos/messagequeue/messagequeue.go deleted file mode 100644 index c2db1d3f..00000000 --- a/internal/nomos/messagequeue/messagequeue.go +++ /dev/null @@ -1,82 +0,0 @@ -package messagequeue - -import ( - "log/slog" - "sync" -) - -// MaxQueuedPerSession caps a session's queue. A held turn plus unbounded -// enqueues would grow memory without limit; an operator nudging a long -// autonomous turn realistically queues only a handful, so a generous cap is -// pure insurance. Overflow drops the newest Enqueue and logs (the message is -// already persisted in the DB by handleChat before Enqueue, so it isn't lost -// from the transcript — it just won't auto-run). -const MaxQueuedPerSession = 20 - -// MessageQueue holds operator messages that arrived while a turn was already -// running for a session. Plan 2026-08-03 (F2): instead of rejecting the -// operator's message with "Nomos is still finishing a previous step… send it -// again", the message is queued and auto-run when the in-flight turn releases -// the session's turn-gate permit. -// -// The queue only schedules WHEN a turn runs, not WHETHER the message is stored -// — handleChat persists the user message before acquiring the gate, so a queued -// message is already in the transcript; this just makes sure a turn eventually -// acts on it. -// -// Draining is strictly one-at-a-time under the turn gate (see drainQueued in -// main.go), so this cannot stack concurrent turns — the exact hazard the gate -// itself exists to prevent. Background resumeSession callers never touch this -// queue; they keep their non-blocking skip. -type MessageQueue struct { - mu sync.Mutex - queue map[string][]string -} - -func New() *MessageQueue { - return &MessageQueue{queue: map[string][]string{}} -} - -// Enqueue appends a message to the back of the session's FIFO. Returns false -// (and logs) if the session is already at MaxQueuedPerSession — the caller's -// message is already persisted in the DB, so this only skips auto-running it. -func (q *MessageQueue) Enqueue(sessionID, msg string) bool { - q.mu.Lock() - defer q.mu.Unlock() - if len(q.queue[sessionID]) >= MaxQueuedPerSession { - slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", MaxQueuedPerSession) - return false - } - q.queue[sessionID] = append(q.queue[sessionID], msg) - return true -} - -// Dequeue pops the next message from the front of the session's FIFO. Returns -// ok=false when empty. -func (q *MessageQueue) Dequeue(sessionID string) (string, bool) { - q.mu.Lock() - defer q.mu.Unlock() - xs := q.queue[sessionID] - if len(xs) == 0 { - return "", false - } - m := xs[0] - q.queue[sessionID] = xs[1:] - return m, true -} - -// RequeueFront pushes a message back to the front — used when a drainer popped -// a message but lost the race for the gate to a live turn; that turn's own -// release will drain it again. -func (q *MessageQueue) RequeueFront(sessionID, msg string) { - q.mu.Lock() - defer q.mu.Unlock() - q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...) -} - -// Peek reports the queued depth for a session (test/diagnostic helper). -func (q *MessageQueue) Peek(sessionID string) int { - q.mu.Lock() - defer q.mu.Unlock() - return len(q.queue[sessionID]) -} diff --git a/internal/nomos/messagequeue/messagequeue_test.go b/internal/nomos/messagequeue/messagequeue_test.go deleted file mode 100644 index d570629e..00000000 --- a/internal/nomos/messagequeue/messagequeue_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package messagequeue - -import ( - "sync" - "testing" -) - -func TestMessageQueue_FIFO(t *testing.T) { - q := New() - q.Enqueue("s", "first") - q.Enqueue("s", "second") - q.Enqueue("s", "third") - - want := []string{"first", "second", "third"} - for _, w := range want { - got, ok := q.Dequeue("s") - if !ok || got != w { - t.Fatalf("Dequeue = %q,%v want %q,true", got, ok, w) - } - } - if _, ok := q.Dequeue("s"); ok { - t.Fatal("Dequeue on drained queue should return ok=false") - } -} - -func TestMessageQueue_RequeueFront(t *testing.T) { - q := New() - q.Enqueue("s", "a") - q.Enqueue("s", "b") - // Pop "a", then push it back to the front; "a" must come out before "b". - a, _ := q.Dequeue("s") - q.RequeueFront("s", a) - got, _ := q.Dequeue("s") - if got != "a" { - t.Fatalf("after RequeueFront, Dequeue = %q want %q", got, "a") - } - got2, _ := q.Dequeue("s") - if got2 != "b" { - t.Fatalf("next Dequeue = %q want %q", got2, "b") - } -} - -func TestMessageQueue_IsolatedPerSession(t *testing.T) { - q := New() - q.Enqueue("s1", "one") - q.Enqueue("s2", "two") - if got, _ := q.Dequeue("s1"); got != "one" { - t.Fatalf("s1 = %q want one", got) - } - if got, _ := q.Dequeue("s2"); got != "two" { - t.Fatalf("s2 = %q want two", got) - } - if q.Peek("s1") != 0 || q.Peek("s2") != 0 { - t.Fatal("both sessions should be drained") - } -} - -func TestMessageQueue_Concurrent(t *testing.T) { - q := New() - const n = MaxQueuedPerSession // stay under the cap so every Enqueue lands - var wg sync.WaitGroup - for i := 0; i < n; i++ { - wg.Add(1) - go func(i int) { - defer wg.Done() - q.Enqueue("s", "m") - }(i) - } - wg.Wait() - if q.Peek("s") != n { - t.Fatalf("Peek = %d want %d (all enqueues must be counted)", q.Peek("s"), n) - } - seen := 0 - for { - if _, ok := q.Dequeue("s"); !ok { - break - } - seen++ - } - if seen != n { - t.Fatalf("drained %d want %d", seen, n) - } -} - -func TestMessageQueue_CapsOverflow(t *testing.T) { - q := New() - for i := 0; i < MaxQueuedPerSession; i++ { - if !q.Enqueue("s", "m") { - t.Fatalf("Enqueue #%d within cap should succeed", i) - } - } - if q.Enqueue("s", "overflow") { - t.Fatal("Enqueue past the cap should return false (dropped)") - } - if got := q.Peek("s"); got != MaxQueuedPerSession { - t.Fatalf("Peek = %d want %d (overflow must not append)", got, MaxQueuedPerSession) - } -} diff --git a/internal/nomos/retrycap/retrycap.go b/internal/nomos/retrycap/retrycap.go deleted file mode 100644 index 97ce95dc..00000000 --- a/internal/nomos/retrycap/retrycap.go +++ /dev/null @@ -1,170 +0,0 @@ -package retrycap - -import ( - "crypto/sha256" - "encoding/hex" - "strings" - "sync" -) - -// MaxRunRetries is the per-turn cap on identical failing `run` tool calls. -// After this many Failures with the same (target, command) key, the agent -// loop refuses to dispatch the call again and instead surfaces a directive -// to investigate *why* (ps/strace/lsof) or escalate to the operator. -// -// Background: session 1e9c7691 (2026-07-18) retried the same -// `chown :10000 /mnt/media_local && chmod 2775 …` ~20 times across direct -// runs, SSH-hop-via-hubris, wrapping in a shell script, and bare `echo test` -// sanity checks. Each retry piled up another zombie process on the target -// (knfsd was holding a kernel lock on the exported directory). The agent -// only investigated *why* after the operator explicitly asked -// "the command just keeps running?" — see -// plans/2026-07-18-session-review-three-sessions.md P0.1. -const MaxRunRetries = 3 - -// RunRetryTracker deduplicates failing `run` calls within a single chat -// turn (chatWith invocation). It is NOT persisted across turns — the cap -// is per-turn, so a fresh turn after the operator responds can retry once -// more. The intent is to break a tight retry loop within one turn, not to -// permanently block the agent from ever attempting the operation again. -// -// Threading: the agent loop is single-goroutine per turn, but the tracker -// is guarded by a mutex so future callers (e.g. concurrent tool dispatch) -// stay safe. The mutex is uncontended on the current hot path. -type RunRetryTracker struct { - mu sync.Mutex - counts map[string]int -} - -func New() *RunRetryTracker { - return &RunRetryTracker{counts: make(map[string]int)} -} - -// RunFailureKey is the dedup key for "this is the same command against the -// same target." Whitespace is collapsed so trivial reformatting -// (newlines vs spaces, trailing whitespace) doesn't escape the cap. The -// purpose field is intentionally NOT part of the key: the agent often -// rephrases purpose between retries while issuing the same command. -func RunFailureKey(target, command string) string { - collapsed := strings.Join(strings.Fields(command), " ") - target = strings.TrimSpace(target) - h := sha256.Sum256([]byte(target + "\x00" + collapsed)) - return hex.EncodeToString(h[:]) -} - -// RecordFailure increments the failure count for the given key and returns -// the new count. The caller should check `count > MaxRunRetries` BEFORE -// dispatching to decide whether to skip the call. -func (r *RunRetryTracker) RecordFailure(key string) int { - r.mu.Lock() - defer r.mu.Unlock() - r.counts[key]++ - return r.counts[key] -} - -// Failures returns the current failure count for a key (0 if unseen). -func (r *RunRetryTracker) Failures(key string) int { - r.mu.Lock() - defer r.mu.Unlock() - return r.counts[key] -} - -// IsRunFailure reports whether a `run` tool call's outcome should count -// as a failure for retry-cap purposes. A call counts as failed when: -// - the dispatch itself errored (callErr != nil), OR -// - the result text starts with "run on : ERROR" — the -// shape classifyAndGate/sshExec produce when SSH or the command fails. -// -// Approvals queued ("requires approval") do NOT count as Failures: they -// are pending operator action, not a command execution failure. A read -// of the existing code paths (classifyAndGate in internal/mcp/server.go) -// confirms the "ERROR" prefix is the stable failure signature for `run`. -// -// The resultText parameter is the MCP tool's RAW text result (not JSON- -// re-encoded): when classifyAndGate returns a textResult like -// "run on host:strong: ERROR ...", the MCP client unwraps it back to a -// plain Go string (see mcpClient.callTool). The caller should pass that -// raw string, not json.Marshal's output (which would quote-wrap it). -func IsRunFailure(toolName string, resultText string, callErr error) bool { - if callErr != nil { - return true - } - if toolName != "run" { - return false - } - // "run on host:strong: ERROR ..." or "run on lxc:caddy: ERROR ..." - // Both shapes start with "run on ". - if !strings.HasPrefix(resultText, "run on ") { - return false - } - return strings.Contains(resultText, ": ERROR") -} - -// RunResultText extracts the raw text from a `run` tool's result value as -// returned by mcpClient.callTool — typically a Go string, but may also be -// a []string (multi-content result) or other JSON-decoded shape. Returns -// "" for shapes we don't recognize. Used by the retry-cap path so -// IsRunFailure receives the un-quoted text form (see its doc comment). -func RunResultText(result any) string { - switch v := result.(type) { - case string: - return v - case []string: - if len(v) > 0 { - return v[0] - } - case []any: - var b strings.Builder - for _, e := range v { - if s, ok := e.(string); ok { - b.WriteString(s) - } - } - return b.String() - } - return "" -} - -// RunRetryDirective is the synthetic tool result returned to the model -// when the retry cap is hit, in place of dispatching the call again. It -// directs the agent to investigate *why* the command keeps failing before -// retrying, or to surface the blocker to the operator. -func RunRetryDirective(target, command string, Failures int) string { - return "Refused: this `run` against " + target + " has failed " + - itoa(Failures) + " times this turn — retry cap hit. The command:\n " + - command + "\nis almost certainly blocked by something on the target " + - "(a hung process, a kernel lock, an unexported FS, a stuck SSH " + - "session, …) — NOT a transient gateway issue. Do NOT retry with " + - "different routing or quoting. Instead, BEFORE calling `run` again, " + - "investigate *why* the command hangs: e.g. `ps aux | grep `, " + - "`lsof `, `strace -f -p ` or `strace -f `, " + - "`mount | grep `, `dmesg | tail`. If you find a structural " + - "blocker (e.g. a kernel lock on an exported NFS directory → " + - "unexport → mutate → re-export), say so to the operator and fix it " + - "with a different command. If you genuinely cannot diagnose, " + - "surface the blocker to the operator with what you've tried — do " + - "not just retry the same command." -} - -// itoa is a tiny strconv.Itoa to keep this file dependency-free. -func itoa(n int) string { - if n == 0 { - return "0" - } - neg := n < 0 - if neg { - n = -n - } - var buf [20]byte - i := len(buf) - for n > 0 { - i-- - buf[i] = byte('0' + n%10) - n /= 10 - } - if neg { - i-- - buf[i] = '-' - } - return string(buf[i:]) -} diff --git a/internal/nomos/retrycap/retrycap_test.go b/internal/nomos/retrycap/retrycap_test.go deleted file mode 100644 index d4ea4622..00000000 --- a/internal/nomos/retrycap/retrycap_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package retrycap - -import ( - "strings" - "testing" -) - -func TestRunFailureKey_StableAcrossWhitespace(t *testing.T) { - cases := []struct{ a, b string }{ - {"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local", - "chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"}, - {"chown :10000 /mnt/media_local\n&& chmod 2775 /mnt/media_local", - "chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"}, - {"chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local ", - " chown :10000 /mnt/media_local && chmod 2775 /mnt/media_local"}, - } - for i, c := range cases { - ka := RunFailureKey("host:strong", c.a) - kb := RunFailureKey("host:strong", c.b) - if ka != kb { - t.Errorf("case %d: keys differ for whitespace-equivalent commands:\n a=%q\n b=%q", i, c.a, c.b) - } - } -} - -func TestRunFailureKey_DiffersByTarget(t *testing.T) { - a := RunFailureKey("host:strong", "echo hi") - b := RunFailureKey("host:hubris", "echo hi") - if a == b { - t.Error("keys should differ when target differs") - } -} - -func TestRunFailureKey_DiffersByCommand(t *testing.T) { - a := RunFailureKey("host:strong", "echo hi") - b := RunFailureKey("host:strong", "echo bye") - if a == b { - t.Error("keys should differ when command differs") - } -} - -func TestRunRetryTracker_CountsAndCaps(t *testing.T) { - r := New() - key := RunFailureKey("host:strong", "chown :10000 /mnt/media_local") - for i := 1; i <= MaxRunRetries; i++ { - if got := r.RecordFailure(key); got != i { - t.Errorf("RecordFailure #%d = %d, want %d", i, got, i) - } - } - // At the cap, Failures() should report MaxRunRetries, and the next - // identical call should be refused by the agent loop (Failures() >= - // MaxRunRetries). - if got := r.Failures(key); got != MaxRunRetries { - t.Errorf("Failures = %d, want %d", got, MaxRunRetries) - } - if r.Failures(key) < MaxRunRetries { - t.Errorf("cap should be enforced at MaxRunRetries=%d", MaxRunRetries) - } -} - -func TestRunRetryTracker_PerTurnIsolation(t *testing.T) { - // Different keys don't interfere. - r := New() - k1 := RunFailureKey("host:strong", "echo a") - k2 := RunFailureKey("host:strong", "echo b") - r.RecordFailure(k1) - r.RecordFailure(k1) - if got := r.Failures(k2); got != 0 { - t.Errorf("k2 Failures = %d, want 0 (keys are isolated)", got) - } -} - -func TestIsRunFailure(t *testing.T) { - cases := []struct { - desc string - tool string - result string - callErr error - want bool - }{ - {"run with ERROR prefix", "run", "run on host:strong: ERROR ssh: signal: killed", nil, true}, - {"run with exit error", "run", "run on lxc:caddy: ERROR exit status 1", nil, true}, - {"run success (read-only auto)", "run", "run on host:strong (read_only, auto): hello", nil, false}, - {"run success (assent window)", "run", "run on host:strong (config_mutation, auto via assent window): done", nil, false}, - {"run queued for approval", "run", "run on host:strong requires approval (risk: config_mutation) — execution 019f4930 queued. Present the command and purpose to the operator and wait; do not re-request.", nil, false}, - {"non-run tool", "get_entity", "lxc list result", nil, false}, - {"callErr set (dispatch failure)", "run", "", errFake{}, true}, - {"callErr set on non-run tool", "get_entity", "some result", errFake{}, true}, // callErr trumps name - } - for i, c := range cases { - got := IsRunFailure(c.tool, c.result, c.callErr) - if got != c.want { - t.Errorf("case %d (%s): IsRunFailure = %v, want %v", i, c.desc, got, c.want) - } - } -} - -type errFake struct{} - -func (errFake) Error() string { return "fake dispatch error" } - -func TestRunRetryDirective_Content(t *testing.T) { - d := RunRetryDirective("host:strong", "chown :10000 /mnt/media_local", 3) - for _, want := range []string{ - "Refused:", - "host:strong", - "3 times", - "retry cap hit", - "Do NOT retry", - "strace", - "ps aux", - "lsof", - "surface the blocker", - } { - if !strings.Contains(d, want) { - t.Errorf("directive missing %q; got:\n%s", want, d) - } - } -} - -func TestItoa(t *testing.T) { - cases := map[int]string{0: "0", 1: "1", 9: "9", 10: "10", 42: "42", - 100: "100", -1: "-1", -42: "-42"} - for in, want := range cases { - if got := itoa(in); got != want { - t.Errorf("itoa(%d) = %q, want %q", in, got, want) - } - } -} diff --git a/internal/nomos/session/store.go b/internal/nomos/session/store.go deleted file mode 100644 index 928e36e7..00000000 --- a/internal/nomos/session/store.go +++ /dev/null @@ -1,2155 +0,0 @@ -package session - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "regexp" - "strings" - "time" - - "github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen" - "github.com/dtoro/oikos/internal/observability" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" -) - -const maxToolResultSize = 4096 - -// ErrPlanInFlight is returned by proposePlan when called again after a step -// has already started. The agent must advance the existing plan with -// update_plan_step + run instead of re-proposing — re-proposing was the -// source of duplicate plans in the sidebar (operator-reported 2026-07-14). -// The caller translates this into a directive tool result. -var ErrPlanInFlight = errors.New("plan already in flight") - -// ErrPlanStepNotFound is returned by updatePlanStep when no step matches the -// given seq in the CURRENT (MAX) generation — either the seq is out of range, -// or (after a re-plan) the model addressed a stale 1-based number. seq is -// generation-relative, so this never resurrects a superseded generation's row. -// The caller translates it into a directive tool result (P0.1). -var ErrPlanStepNotFound = errors.New("plan step not found in current generation") - -type Store struct { - pool *pgxpool.Pool -} - -func New(ctx context.Context, databaseURL string) (*Store, error) { - if databaseURL == "" { - return nil, nil - } - pool, err := pgxpool.New(ctx, databaseURL) - if err != nil { - return nil, fmt.Errorf("connect db: %w", err) - } - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping db: %w", err) - } - s := &Store{pool: pool} - s.CleanupStaleExecutions(ctx, time.Hour) - return s, nil -} - -// cleanupStaleExecutions marks non-terminal executions older than maxAge as -// cancelled. Orphaned executions accumulate when the MCP client times out -// (30s) before the run handler's error path can mark them failed — the -// execution entity is created before the SSH call, and a timeout kills the -// connection before the handler runs its UPDATE. Without this, stale -// `running` and `pending_approval` executions pile up in the DB and pollute -// the Operations page + session rail badges. Called at startup (maxAge=1h) -// and periodically (maxAge=10m) by the sweep worker. -func (s *Store) CleanupStaleExecutions(ctx context.Context, maxAge time.Duration) int { - if s == nil { - return 0 - } - tag, err := s.pool.Exec(ctx, ` - UPDATE executions SET status = 'cancelled', - result = jsonb_build_object('message', 'cleaned up — stale non-terminal execution (older than ' || $1 || ')') - WHERE status IN ('running', 'pending_approval', 'approved', 'queued') - AND entity_id IN ( - SELECT entity_id FROM entities WHERE created_at < now() - ($2 * interval '1 second') - )`, - maxAge.String(), maxAge.Seconds()) - if err != nil { - slog.Warn("nomos: stale execution cleanup failed", "error", err) - return 0 - } - n := int(tag.RowsAffected()) - if n > 0 { - slog.Info("nomos: cleaned up stale executions", "count", n, "max_age", maxAge.String()) - } - return n -} - -func (s *Store) Close() { - if s.pool != nil { - s.pool.Close() - } -} - -// Exec runs a raw SQL query against the store's pool. Used by the agent to -// write autonomy_settings rows directly. -func (s *Store) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { - if s == nil { - return pgconn.CommandTag{}, nil - } - return s.pool.Exec(ctx, sql, args...) -} - -// session is a chat session elevated to a task: goal-structured work with a -// lifecycle status and an outcome (see migration 018 / the task-board plan). -// Outcome/Summary/EntityID are empty until set, hence omitempty. -// -// P1.5 (2026-07-20): Blocker and ClosedAt track WHY a session ended -// partial/failed and WHEN it actually closed. ClosedAt is distinct from -// LastActiveAt — the latter is touched on any access (including a UI -// transcript view), the former is set ONCE at completion. Without it, -// "duration" computed as last_active - created lies for reopened sessions -// (a51e2086 reported 4-day duration because the operator reopened it). -// Blocker is a short structured reason: approval_timeout, -// classifier_overreach, user_abandoned, tool_error, etc. See -// plans/2026-07-20-session-review-ten-sessions.md P1.5. -type Session struct { - ID string `json:"id"` - Title string `json:"title"` - Actor string `json:"actor"` - Goal string `json:"goal"` - Status string `json:"status"` - Outcome string `json:"outcome,omitempty"` - Summary string `json:"summary,omitempty"` - EntityID string `json:"entity_id,omitempty"` - PendingApprovals int `json:"pending_approvals"` - Blocker string `json:"blocker,omitempty"` - CreatedAt time.Time `json:"created_at"` - LastActiveAt time.Time `json:"last_active_at"` - ClosedAt *time.Time `json:"closed_at,omitempty"` - // P2.6 (2026-07-20): server-side aggregates so /sessions can answer - // "how big was this task?" without N+1 transcript fetches. The audit - // had to pull every session's full message tree to count tool calls — - // ~600 KB of JSON for 10 sessions. With these, the list view is a - // single round trip. omitempty so getSession for a brand-new session - // with zero activity doesn't emit zeros. - MessageCount int `json:"message_count,omitempty"` - ToolCallCount int `json:"tool_call_count,omitempty"` - DurationSeconds int `json:"duration_seconds,omitempty"` -} - -type Message struct { - ID string `json:"id"` - SessionID string `json:"session_id"` - Role string `json:"role"` - Content json.RawMessage `json:"content"` - CreatedAt time.Time `json:"created_at"` -} - -func (s *Store) CreateSession(ctx context.Context, title string) (*Session, error) { - if s == nil { - return &Session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil - } - var id string - err := s.pool.QueryRow(ctx, - `INSERT INTO agent_sessions (title, actor) VALUES ($1, 'agent:nomos') RETURNING id`, - title).Scan(&id) - if err != nil { - return nil, err - } - // Give the task its own entity so knowledge and involved-entity edges hang - // off the existing relationships graph. Best-effort: a failure here must not - // block the chat — the session is usable without a graph anchor. - entityID := s.createTaskEntity(ctx, id, title) - return &Session{ID: id, Title: title, Actor: "agent:nomos", Status: "active", - EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil -} - -// createTaskEntity creates (or reuses) the task: entity that -// anchors this task's knowledge and involved-entity relationships, and records -// it on the session. Returns the entity id, or "" on failure — non-fatal, see -// caller. Requires the 'task' entity type (seeds/ontology.yaml). -func (s *Store) createTaskEntity(ctx context.Context, sessionID, title string) string { - entityID, _ := uuid.NewV7() - slug := "task:" + sessionID - // name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the - // name on the session id and keep the human title in attributes for display. - name := "task " + sessionID - attrs, _ := json.Marshal(map[string]any{"title": title}) - if err := s.pool.QueryRow(ctx, ` - INSERT INTO entities (id, slug, type, name, attributes) - VALUES ($1, $2, 'task', $3, $4) - ON CONFLICT (slug) DO UPDATE SET updated_at = now() - RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil { - slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err) - return "" - } - if _, err := s.pool.Exec(ctx, - `UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil { - slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err) - } - // Graph edge: task —involves→ agent:nomos (gives every task at least one - // edge from creation, even if no run calls are ever made). - s.pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - SELECT $1, id, 'involves', '{"by":"nomos"}'::jsonb, now() - FROM entities WHERE slug = 'agent:nomos' - AND NOT EXISTS ( - SELECT 1 FROM relationships r - WHERE r.source_id = $1 AND r.target_id = entities.id AND r.type = 'involves' AND r.valid_to IS NULL)`, - entityID) - return entityID.String() -} - -func (s *Store) SaveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error { - if s == nil { - return nil - } - _, err := s.pool.Exec(ctx, - `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, - sessionID, role, truncateToolResults(content)) - return err -} - -// insertMessageReturningID and updateMessage exist for the auto-continuation -// worker's live-progress persistence (see continue.go): rather than saving -// one message only once the whole continuation finishes — which could be -// several minutes of silence in the UI even though frontend polling exists — -// the worker inserts a placeholder immediately and updates the SAME row as -// each tool call completes, so a poller sees individual steps land, not just -// a final rolled-up summary. -func (s *Store) InsertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) { - if s == nil { - return uuid.Nil, nil - } - var id uuid.UUID - err := s.pool.QueryRow(ctx, - `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3) RETURNING id`, - sessionID, role, truncateToolResults(content)).Scan(&id) - return id, err -} - -func (s *Store) UpdateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error { - if s == nil || id == uuid.Nil { - return nil - } - _, err := s.pool.Exec(ctx, - `UPDATE agent_messages SET content = $2 WHERE id = $1`, - id, truncateToolResults(content)) - return err -} - -// deleteMessage removes a message row. Used by B.6: when a chat turn ends -// with no text and no tool calls (the model empty-response'd and all -// retries failed), the placeholder row is deleted instead of persisting an -// empty assistant bubble — the error was already streamed to the frontend -// via the 'done with error=true' event, so the operator sees it inline. -func (s *Store) DeleteMessage(ctx context.Context, id uuid.UUID) { - if s == nil || id == uuid.Nil { - return - } - s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE id = $1`, id) -} - -// lastUserMessage returns the most recent user message text for a Session, -// or "" if none. Used to build a context-rich reconnect/resume note: instead -// of a generic "report your state," the note can say "the operator's last -// message was X — advance the plan" so the agent doesn't re-propose or -// re-execute on a reconnect (the operator-reported 2026-07-14 divergence). -func (s *Store) lastUserMessage(ctx context.Context, sessionID string) string { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return "" - } - var content json.RawMessage - if err := s.pool.QueryRow(ctx, - `SELECT content FROM agent_messages - WHERE session_id = $1 AND role = 'user' - ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&content); err != nil { - return "" - } - var m struct { - Text string `json:"text"` - } - if err := json.Unmarshal(content, &m); err != nil { - return "" - } - return m.Text -} - -// hasPlanInFlight reports whether a session has a plan with at least one -// step in a non-terminal state (pending/running). Used to direct the -// reconnect/resume note: if a plan is in flight, the note says "advance -// the plan with update_plan_step + run" instead of the generic "report -// your state" (which caused the agent to re-propose and duplicate the plan -// in the sidebar — operator-reported 2026-07-14). -func (s *Store) HasPlanInFlight(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return false - } - var exists bool - if err := s.pool.QueryRow(ctx, - `SELECT EXISTS(SELECT 1 FROM session_plan_steps - WHERE session_id = $1 AND status IN ('pending', 'running'))`, sessionID).Scan(&exists); err != nil { - return false - } - return exists -} - -// enrichResumeNote appends session context to a base resume/reconnect note: -// the operator's last user message and, if a plan is in flight, an explicit -// directive to advance it with update_plan_step + run (not re-propose). The -// generic "report your state" note caused the agent to re-propose and -// duplicate the plan on a reconnect (operator-reported 2026-07-14); this -// enrichment gives the agent enough context to do the right thing even -// through the reconnect path. -func (s *Store) EnrichResumeNote(ctx context.Context, sessionID, base string) string { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return base - } - last := s.lastUserMessage(ctx, sessionID) - inFlight := s.HasPlanInFlight(ctx, sessionID) - if last == "" && !inFlight { - return base - } - note := base - if last != "" { - note += fmt.Sprintf(" The operator's last message was: %q.", last) - } - if inFlight { - note += " A plan is in flight — advance it with update_plan_step (status=running) + run for the next step's target. Do NOT call propose_plan again." - } - return note -} - -func truncateToolResults(content json.RawMessage) json.RawMessage { - var m map[string]any - if err := json.Unmarshal(content, &m); err != nil { - return content - } - toolCalls, ok := m["tool_calls"].([]any) - if !ok || len(toolCalls) == 0 { - return content - } - changed := false - for i, raw := range toolCalls { - tc, ok := raw.(map[string]any) - if !ok { - continue - } - if result, ok := tc["result"]; ok { - resultJSON, _ := json.Marshal(result) - if len(resultJSON) > maxToolResultSize { - tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON)) - toolCalls[i] = tc - changed = true - } - } - } - if !changed { - return content - } - m["tool_calls"] = toolCalls - out, err := json.Marshal(m) - if err != nil { - return content - } - return out -} - -func (s *Store) TouchSession(ctx context.Context, id string) { - if s != nil { - s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) - } -} - -func (s *Store) ListSessions(ctx context.Context) ([]Session, error) { - return s.ListSessionsFiltered(ctx, ListFilter{Limit: 50}) -} - -// listFilter carries the optional WHERE/ORDER clauses added by P2.8 -// (filtering & pagination). All fields optional; empty values are no-ops. -// The handler in main.go parses query params into this struct so the SQL -// builder here is the single source of truth for what filters exist. -type ListFilter struct { - Outcome string // exact match on outcome (success/partial/failure) - Status string // exact match on status (active/done/failed/executing) - EntityID string // exact match on entity_id (UUID) - Blocker string // exact match on blocker reason - Since string // last_active_at >= this; RFC3339 timestamp OR Go duration (e.g. "24h") - Cursor string // last_active_at < cursor (RFC3339) — page back in time - Limit int // default 50, clamped by the handler -} - -func (s *Store) ListSessionsFiltered(ctx context.Context, f ListFilter) ([]Session, error) { - if s == nil { - return nil, nil - } - if f.Limit <= 0 { - f.Limit = 50 - } - // Build the WHERE clause dynamically. We use a single args slice with - // $N placeholders to keep pgx happy; the index increments per clause. - var ( - where []string - args []any - n = 1 - ) - if f.Outcome != "" { - where = append(where, fmt.Sprintf("COALESCE(s.outcome, '') = $%d", n)) - args = append(args, f.Outcome) - n++ - } - if f.Status != "" { - where = append(where, fmt.Sprintf("s.status = $%d", n)) - args = append(args, f.Status) - n++ - } - if f.EntityID != "" { - // Accept UUID or string; cast gracefully if invalid. - if _, err := uuid.Parse(f.EntityID); err == nil { - where = append(where, fmt.Sprintf("s.entity_id = $%d::uuid", n)) - args = append(args, f.EntityID) - n++ - } - } - if f.Blocker != "" { - where = append(where, fmt.Sprintf("COALESCE(s.blocker, '') = $%d", n)) - args = append(args, f.Blocker) - n++ - } - if f.Since != "" { - // Accept RFC3339 timestamp OR a Go-style duration like "24h", "7d". - // Try timestamp first, fall back to duration relative to now. - if t, err := time.Parse(time.RFC3339, f.Since); err == nil { - where = append(where, fmt.Sprintf("s.last_active_at >= $%d", n)) - args = append(args, t) - n++ - } else if d, err := time.ParseDuration(f.Since); err == nil { - where = append(where, fmt.Sprintf("s.last_active_at >= now() - ($%d * interval '1 second')", n)) - args = append(args, d.Seconds()) - n++ - } - // Unknown format: silently drop the filter — better than erroring - // out and breaking the whole list. Caller can validate if needed. - } - if f.Cursor != "" { - if t, err := time.Parse(time.RFC3339, f.Cursor); err == nil { - where = append(where, fmt.Sprintf("s.last_active_at < $%d", n)) - args = append(args, t) - n++ - } - } - whereClause := "" - if len(where) > 0 { - whereClause = "WHERE " + strings.Join(where, " AND ") - } - args = append(args, f.Limit) - limitArg := fmt.Sprintf("$%d", n) - - query := fmt.Sprintf(` - SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, - COALESCE(s.entity_id::text, ''), - COALESCE(pa.cnt, 0), - COALESCE(s.blocker, ''), - s.created_at, s.last_active_at, s.closed_at, - COALESCE(msg.cnt, 0), - COALESCE(act.cnt, 0), - COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) - FROM agent_sessions s - LEFT JOIN ( - SELECT l.session_id, COUNT(*) AS cnt - FROM nomos_plan_executions l - JOIN executions e ON e.entity_id = l.execution_id - WHERE e.status = 'pending_approval' - GROUP BY l.session_id - ) pa ON pa.session_id = s.id - LEFT JOIN ( - SELECT session_id, COUNT(*) AS cnt - FROM agent_messages - GROUP BY session_id - ) msg ON msg.session_id = s.id - LEFT JOIN ( - SELECT session_id::uuid AS sid, COUNT(*) AS cnt - FROM agent_activity - WHERE session_id IS NOT NULL AND session_id <> '' - GROUP BY session_id - ) act ON act.sid = s.id - %s - ORDER BY s.last_active_at DESC - LIMIT %s`, whereClause, limitArg) - - rows, err := s.pool.Query(ctx, query, args...) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []Session - for rows.Next() { - var sess Session - if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, - &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, - &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, - &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil { - return nil, err - } - out = append(out, sess) - } - return out, rows.Err() -} - -func (s *Store) GetSession(ctx context.Context, id string) (*Session, error) { - if s == nil { - return nil, nil - } - var sess Session - err := s.pool.QueryRow(ctx, - `SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, - COALESCE(s.entity_id::text, ''), 0, COALESCE(s.blocker, ''), - s.created_at, s.last_active_at, s.closed_at, - COALESCE(msg.cnt, 0), - COALESCE(act.cnt, 0), - COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) - FROM agent_sessions s - LEFT JOIN ( - SELECT session_id, COUNT(*) AS cnt - FROM agent_messages - WHERE session_id = $1::uuid - GROUP BY session_id - ) msg ON msg.session_id = s.id - LEFT JOIN ( - SELECT session_id::uuid AS sid, COUNT(*) AS cnt - FROM agent_activity - WHERE session_id IS NOT NULL AND session_id <> '' - AND session_id::uuid = $1::uuid - GROUP BY session_id - ) act ON act.sid = s.id - WHERE s.id = $1`, id). - Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, - &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, - &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, - &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds) - if err != nil { - return nil, err - } - return &sess, nil -} - -// recentPartialSessions returns recent sessions (within `since`) whose outcome -// is partial or failed, excluding the current session. Used by the set_goal -// handler to surface prior unfinished work on the same problem — three -// duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all bounced off -// the classifier because each new session started from scratch. Surfacing the -// prior session's goal + summary at set_goal time lets the agent pick up the -// thread instead of rediscovering it. See -// plans/2026-07-20-session-review-ten-sessions.md P1.3. -func (s *Store) RecentPartialSessions(ctx context.Context, excludeSessionID string, since time.Duration) ([]Session, error) { - if s == nil { - return nil, nil - } - rows, err := s.pool.Query(ctx, - `SELECT s.id, s.title, s.actor, s.goal, s.status, COALESCE(s.outcome, ''), s.summary, - COALESCE(s.entity_id::text, ''), - COALESCE(pa.cnt, 0), - COALESCE(s.blocker, ''), - s.created_at, s.last_active_at, s.closed_at, - COALESCE(msg.cnt, 0), - COALESCE(act.cnt, 0), - COALESCE(EXTRACT(EPOCH FROM (COALESCE(s.closed_at, s.last_active_at) - s.created_at))::bigint, 0) - FROM agent_sessions s - LEFT JOIN ( - SELECT l.session_id, COUNT(*) AS cnt - FROM nomos_plan_executions l - JOIN executions e ON e.entity_id = l.execution_id - WHERE e.status = 'pending_approval' - GROUP BY l.session_id - ) pa ON pa.session_id = s.id - LEFT JOIN ( - SELECT session_id, COUNT(*) AS cnt - FROM agent_messages - GROUP BY session_id - ) msg ON msg.session_id = s.id - LEFT JOIN ( - SELECT session_id::uuid AS sid, COUNT(*) AS cnt - FROM agent_activity - WHERE session_id IS NOT NULL AND session_id <> '' - GROUP BY session_id - ) act ON act.sid = s.id - WHERE s.id <> $1 - AND s.last_active_at >= now() - ($2 * interval '1 second') - AND COALESCE(s.outcome, '') IN ('partial', 'failed') - ORDER BY s.last_active_at DESC - LIMIT 10`, - excludeSessionID, since.Seconds()) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []Session - for rows.Next() { - var sess Session - if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status, - &sess.Outcome, &sess.Summary, &sess.EntityID, &sess.PendingApprovals, - &sess.Blocker, &sess.CreatedAt, &sess.LastActiveAt, &sess.ClosedAt, - &sess.MessageCount, &sess.ToolCallCount, &sess.DurationSeconds); err != nil { - return nil, err - } - out = append(out, sess) - } - return out, rows.Err() -} - -// getMessages returns a session's ENTIRE message history, unbounded — used -// for the UI's own transcript view (GET /sessions/{id}), where the operator -// should be able to see everything a task has done regardless of how long -// it's run. For LLM replay, see getRecentMessages: sending the operator's -// full transcript is fine; sending the model's full transcript on every -// single turn is not (see getRecentMessages's doc comment). -func (s *Store) GetMessages(ctx context.Context, sessionID string) ([]Message, error) { - if s == nil { - return nil, nil - } - rows, err := s.pool.Query(ctx, - `SELECT id, session_id, role, content, created_at FROM agent_messages WHERE session_id=$1 ORDER BY created_at ASC`, - sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - - var out []Message - for rows.Next() { - var m Message - if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { - return nil, err - } - out = append(out, m) - } - return out, rows.Err() -} - -// SessionToolCall is the flat view of one tool call as exposed by -// GET /sessions/{id}/tool_calls. Mirrors the persisted tool_call shape but -// drops the message-shell wrapping. Args/Result are kept as RawMessage so -// the caller can decide how to render them (the audit case wanted raw -// text sizes, but other callers may want full JSON). -type SessionToolCall struct { - ID string `json:"id"` - Name string `json:"name"` - Args json.RawMessage `json:"args,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error string `json:"error,omitempty"` - Type string `json:"type,omitempty"` // "tool_use" or "tool_result" - MessageID string `json:"message_id"` - Role string `json:"role"` - Seq int `json:"seq"` // 1-indexed position within the session (across all messages) - CreatedAt time.Time `json:"created_at"` -} - -// getSessionToolCalls walks a session's messages and returns a flat list of -// tool calls in chronological order, without the two-level message nesting. -// The audit at plans/2026-07-20-session-review-ten-sessions.md P2.10 had to -// write Python to walk messages[].content.tool_calls[]; this method makes -// it a single SQL + Go walk on the server. Each tool_use/tool_result pair -// is emitted as two rows (same id, different Type), preserving the -// persisted shape — clients that want the merged shape can group by ID. -func (s *Store) GetSessionToolCalls(ctx context.Context, sessionID string) ([]SessionToolCall, error) { - if s == nil { - return nil, nil - } - msgs, err := s.GetMessages(ctx, sessionID) - if err != nil { - return nil, err - } - var out []SessionToolCall - seq := 0 - for _, m := range msgs { - var payload struct { - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Args json.RawMessage `json:"args"` - Result json.RawMessage `json:"result"` - Error string `json:"error"` - } `json:"tool_calls"` - } - if err := json.Unmarshal(m.Content, &payload); err != nil { - continue - } - for _, tc := range payload.ToolCalls { - if tc.ID == "" { - continue - } - seq++ - out = append(out, SessionToolCall{ - ID: tc.ID, - Name: tc.Name, - Args: tc.Args, - Result: tc.Result, - Error: tc.Error, - Type: tc.Type, - MessageID: m.ID, - Role: m.Role, - Seq: seq, - CreatedAt: m.CreatedAt, - }) - } - } - return out, nil -} - -// getRecentMessages returns the most recent `limit` messages for sessionID, -// in chronological order, plus whether older messages exist beyond that -// window. Used specifically for LLM replay (chatWith): without a bound, -// every turn re-sent the ENTIRE session history into the model's context, -// unconditionally growing with every turn — a real, observed-in-production -// cost/latency/eventual-context-limit risk for exactly the long-running, -// heavily-autonomous tasks (many auto-continuation cycles) this system is -// built to run longest. Fetches limit+1 rows to detect "there's more" -// without a separate COUNT query. -func (s *Store) GetRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []Message, truncated bool, err error) { - if s == nil { - return nil, false, nil - } - rows, qerr := s.pool.Query(ctx, - `SELECT id, session_id, role, content, created_at FROM agent_messages - WHERE session_id=$1 ORDER BY created_at DESC LIMIT $2`, - sessionID, limit+1) - if qerr != nil { - return nil, false, qerr - } - defer rows.Close() - - var out []Message - for rows.Next() { - var m Message - if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil { - return nil, false, err - } - out = append(out, m) - } - if err := rows.Err(); err != nil { - return nil, false, err - } - - truncated = len(out) > limit - if truncated { - out = out[:limit] - } - // Rows came back newest-first (for the LIMIT to bound the right end); - // reverse to chronological order for replay. - for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { - out[i], out[j] = out[j], out[i] - } - return out, truncated, nil -} - -func (s *Store) DeleteSession(ctx context.Context, id string) error { - if s == nil { - return nil - } - // Resolve the task entity so we can clean up its graph edges and events too - // — otherwise deleting a session orphans its task: entity, its involves/ - // documents relationships, and its task-scoped events. - var entID uuid.UUID - s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, id).Scan(&entID) - - if _, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id); err != nil { - return err - } - // task.status / entity.touched / knowledge.recorded are all correlated by - // session id. - s.pool.Exec(ctx, `DELETE FROM events WHERE correlation_id = $1`, id) - if _, err := s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id); err != nil { - return err - } - if entID != uuid.Nil { - // relationships FK is ON DELETE RESTRICT, so drop the task's edges first. - s.pool.Exec(ctx, `DELETE FROM relationships WHERE source_id = $1 OR target_id = $1`, entID) - s.pool.Exec(ctx, `DELETE FROM entities WHERE id = $1`, entID) - } - return nil -} - -// taskEntityPtr returns the task entity id for a Session, or nil — used as the -// entity_id on task-scoped events so they anchor to the task in the graph. -func (s *Store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID { - var id uuid.UUID - if err := s.pool.QueryRow(ctx, - `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil { - return nil - } - return &id -} - -// setGoal records the task's goal and moves it into executing. The `planning` -// intermediate state was removed (2026-07-14) — it was indistinguishable from -// `active` to the operator and caused sessions to appear stuck when the agent -// called set_goal but never propose_plan (observed in production). -// -// P2 (2026-07-15): setGoal also replaces any prior plan steps (from a -// previous sub-task or an incomplete first turn) as `replaced`, clearing the -// way for a fresh propose_plan. This is the ONLY place step replacement -// happens — not in reopenSession — because set_goal is the explicit signal -// for "new sub-task." An approval ("go ahead") does NOT call set_goal, so it -// won't destroy the plan the operator just approved. -// -// P1.4 (2026-07-18): when a non-empty prior goal is being overwritten by a -// different goal, emit a `task.superseded` event carrying the prior goal. -// This gives the UI/audit trail a clear signal that the operator pivoted — -// without it, the prior goal just silently disappears from -// agent_sessions.goal and there's no record the session ever had a -// different starting intent. See plans/2026-07-18-session-review-three- -// sessions.md P1.4 (session 55927f0a had two set_goal calls with the first -// implicitly abandoned when the operator said "lets just keep ludo-library -// then"). -func (s *Store) SetGoal(ctx context.Context, sessionID, goal string) error { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return nil - } - // Capture the prior goal BEFORE the UPDATE overwrites it. If non-empty - // and different from the new goal, emit task.superseded so the audit - // trail records the pivot — the row's goal column won't. - var priorGoal string - s.pool.QueryRow(ctx, - `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, - sessionID).Scan(&priorGoal) - if priorGoal != "" && priorGoal != goal { - _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.superseded", - s.taskEntityPtr(ctx, sessionID), "info", "nomos", sessionID, - map[string]any{"prior_goal": priorGoal, "new_goal": goal}) - slog.Info("nomos: task goal superseded by a new set_goal", - "session", sessionID, "prior_goal", priorGoal, "new_goal", goal) - } - // Replace any prior plan steps (done/running/pending/...) as `replaced`. - // The rows are kept for the generation counter + audit trail; proposePlan - // excludes `replaced` from its in-flight check, so the next propose_plan - // takes the fresh-generation path. replaced_reason records the cause - // (2026-08-04 plan-step integrity audit). - s.pool.Exec(ctx, - `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`, - sessionID, "goal superseded") - if _, err := s.pool.Exec(ctx, - `UPDATE agent_sessions SET goal = $2, status = 'executing', title = $2, last_active_at = now() WHERE id = $1`, - sessionID, goal); err != nil { - return err - } - _ = observability.Event(ctx, sqlcgen.New(s.pool), "goal.set", s.taskEntityPtr(ctx, sessionID), - "info", "nomos", sessionID, map[string]any{"goal": goal}) - return nil -} - -// reopenSession flips a terminal (done/failed) session back to `executing` -// so a follow-up message can start a new sub-task — the iteration path -// (P2, 2026-07-15). Without this, a completed session stays `done` forever -// and the panel shows a stale result. -// -// reopenSession ONLY flips the status + clears outcome/summary. It does NOT -// touch plan steps — that's `setGoal`'s job (see below). The reason: not -// every follow-up is a new sub-task. An approval ("go ahead") is a -// continuation of the current plan, and replacing its steps would destroy -// the plan the operator just approved. `set_goal` is the explicit signal for -// "new sub-task," so step replacement happens there, not here. -// -// Returns true if the session was actually reopened (was terminal), false if -// it was already active (no-op). -func (s *Store) ReopenSession(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return false - } - var currentStatus string - if err := s.pool.QueryRow(ctx, - `SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil { - return false - } - if currentStatus != "done" && currentStatus != "failed" { - return false - } - s.pool.Exec(ctx, - `UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`, - sessionID) - // Mark the prior plan's steps as replaced so the P1 plan-first gate in - // classifyAndGate forces a fresh propose_plan before any run. Without - // this, the agent could resume a session and call run against the old - // (completed) plan — exactly what caused the ZimaOS continuation to - // have 81 ad-hoc tool calls with zero plan structure (2026-08-04). - s.pool.Exec(ctx, - `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`, - sessionID, "session reopened — awaiting new plan") - _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID), - "info", "nomos", sessionID, map[string]any{"prior_status": currentStatus}) - return true -} - -// planStepInput is one step as the agent proposes it. -type PlanStepInput struct { - Title string - Detail string - TargetSlug string -} - -// proposePlan sets the task's plan and moves it into executing. Emits -// plan.proposed with the persisted steps (seq + id) so the panel can render -// and later address them by id. -// -// Two modes, chosen by whether any existing step has left 'pending': -// - Fresh/revise (no step started yet): full replace (delete + insert). This -// covers the first call, and a genuine re-plan before any work began. -// - Mid-flight (some step is running/done/failed/…): REFUSE the call. -// The agent must advance the existing plan with update_plan_step + run -// instead of re-proposing. The previous append-mode safety net (commit -// 5384499) preserved history but produced a confusing duplicate sidebar -// when the agent re-proposed on "proceed" (operator-reported 2026-07-14). -// Refusing is the correct default — the tool result tells the agent how -// to advance, and the generation column tracks revisions if a genuine -// re-plan is ever allowed. -func (s *Store) ProposePlan(ctx context.Context, sessionID string, steps []PlanStepInput) ([]map[string]any, error) { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return nil, nil - } - tx, err := s.pool.Begin(ctx) - if err != nil { - return nil, err - } - defer tx.Rollback(ctx) - - var anyStarted bool - // `replaced` steps (from a prior plan generation superseded by a - // follow-up sub-task — see setGoal/reopenSession) are excluded: they - // prove a prior plan was completed and superseded, not that a plan is in - // flight. Without this exclusion, setGoal's `replaced` marking would be - // useless — propose_plan would still refuse on the follow-up. - if err := tx.QueryRow(ctx, ` - SELECT COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false) - FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&anyStarted); err != nil { - return nil, err - } - if anyStarted { - // A plan is already in flight (a step is running/done/failed/...). - // Refuse the re-proposal — the agent must advance with - // update_plan_step + run. The caller surfaces a directive. - return nil, ErrPlanInFlight - } - // Fresh/revise: mark any prior PENDING steps as `replaced` (not DELETE). - // The rows are kept for the generation counter (MAX(generation)+1 below) - // and the plan_generations eval assertion. `replaced` steps are excluded - // from the anyStarted check above, so they don't block this proposal. - // replaced_reason records the cause — required by the plan-step integrity - // gate (2026-08-04 session audit). - if _, err := tx.Exec(ctx, - `UPDATE session_plan_steps SET status = 'replaced', replaced_reason = $2, finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status = 'pending'`, - sessionID, "superseded by new plan generation"); err != nil { - return nil, err - } - - // nextGen: generation 1 for the first plan, MAX(generation)+1 for every - // revise/follow-up (prior rows were marked `replaced` above, not deleted, - // so the counter survives). seq is generation-relative — it resets to - // 1..N for this generation, so (session_id, generation, seq) is the - // addressing key and the model's 1-based update_plan_step always maps to - // the CURRENT plan after a re-plan (P0.1). - var nextGen int - if err := tx.QueryRow(ctx, ` - SELECT COALESCE(MAX(generation), 0) + 1 - FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&nextGen); err != nil { - return nil, err - } - - out := make([]map[string]any, 0, len(steps)) - for i, st := range steps { - var targetSlug *string - if st.TargetSlug != "" { - targetSlug = &st.TargetSlug - } - seq := i + 1 - var id uuid.UUID - if err := tx.QueryRow(ctx, ` - INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug, generation) - VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - sessionID, seq, st.Title, st.Detail, targetSlug, nextGen).Scan(&id); err != nil { - return nil, err - } - out = append(out, map[string]any{ - "id": id.String(), "seq": seq, "title": st.Title, - "detail": st.Detail, "target_slug": st.TargetSlug, - "generation": nextGen, - }) - } - if _, err := tx.Exec(ctx, - `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID); err != nil { - return nil, err - } - if err := tx.Commit(ctx); err != nil { - return nil, err - } - // Event after commit so subscribers only ever see a persisted plan. - // appended=false (always now — we refuse mid-flight re-proposals) tells - // the panel to replace its list with these steps. - _ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID), - "info", "nomos", sessionID, map[string]any{"steps": out, "appended": false, "generation": nextGen}) - return out, nil -} - -// updatePlanStep sets a step's status by seq, stamping started_at/finished_at -// and linking an execution if given. Emits plan.step.started (running) or -// plan.step.finished (terminal) so the panel advances live. The execution link -// is also what lets the api auto-close the step when the execution finishes -// (see closePlanStepForExecution). -// -// Completion ordering (done/failed/skipped/blocked) is enforced: a step cannot -// be marked complete while an earlier step is still pending, preventing the -// agent from marking step 5 done before step 4 (observed in production: the -// agent rushed to close all steps in a final turn, in reverse order). -func (s *Store) UpdatePlanStep(ctx context.Context, sessionID string, seq int, status, execID, replacedReason string) error { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return nil - } - // Resolve the CURRENT generation: seq is generation-relative (1-based - // within the plan the model is working), so (session_id, MAX(generation), - // seq) is the addressing key. A re-plan's superseded generations have - // their own seq space and must never be touched by a follow-up's - // update_plan_step — that was the root cause of "the plan was off" - // (gen-1 `replaced` rows resurrected as `done` while gen-2 work went - // unrecorded). The MAX(generation) step is by construction the active - // plan, never `replaced`, so this can't resurrect a superseded row (P0.1). - var curGen int - if err := s.pool.QueryRow(ctx, - `SELECT COALESCE(MAX(generation), 0) FROM session_plan_steps WHERE session_id = $1`, - sessionID).Scan(&curGen); err != nil { - return err - } - if curGen == 0 { - return ErrPlanStepNotFound - } - stamp := "" - switch status { - case "running": - stamp = ", started_at = COALESCE(started_at, now())" - case "done", "failed", "skipped", "blocked", "replaced": - stamp = ", finished_at = now()" - } - // Completion ordering, scoped to the CURRENT generation: for terminal - // states, no earlier step in THIS plan may still be pending. Running - // steps can start out of order (the agent may dispatch parallel work), - // but completion must be sequential. Earlier generations are superseded - // and irrelevant. - if status == "done" || status == "failed" || status == "skipped" || status == "blocked" { - var blockedBy int - if err := s.pool.QueryRow(ctx, ` - SELECT COALESCE(MIN(seq), 0) - FROM session_plan_steps - WHERE session_id = $1 AND generation = $2 AND seq < $3 AND status = 'pending'`, - sessionID, curGen, seq).Scan(&blockedBy); err == nil && blockedBy > 0 { - return fmt.Errorf("cannot complete step %d — step %d is still pending", seq, blockedBy) - } - } - var execPtr *uuid.UUID - if id, err := uuid.Parse(execID); err == nil { - execPtr = &id - } - var stepID uuid.UUID - var targetSlug *string - // stamp is a fixed literal from the switch above — never user input. - // status <> 'replaced' is defense-in-depth: MAX(generation) can't hold a - // replaced row, but if it ever could, this refuses the write instead of - // resurrecting it. No matching row → ErrPlanStepNotFound (stale/out-of-range seq). - if status == "replaced" && replacedReason != "" { - err := s.pool.QueryRow(ctx, ` - UPDATE session_plan_steps - SET status = $4, execution_id = COALESCE($5, execution_id), replaced_reason = $6`+stamp+` - WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced' - RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr, replacedReason).Scan(&stepID, &targetSlug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrPlanStepNotFound - } - return err - } - } else { - err := s.pool.QueryRow(ctx, ` - UPDATE session_plan_steps - SET status = $4, execution_id = COALESCE($5, execution_id)`+stamp+` - WHERE session_id = $1 AND generation = $2 AND seq = $3 AND status <> 'replaced' - RETURNING id, target_slug`, sessionID, curGen, seq, status, execPtr).Scan(&stepID, &targetSlug) - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return ErrPlanStepNotFound - } - return err - } - } - // Anchor the event to the step's target entity when it has one, else the task. - entPtr := s.taskEntityPtr(ctx, sessionID) - if targetSlug != nil && *targetSlug != "" { - var tid uuid.UUID - if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *targetSlug).Scan(&tid) == nil { - entPtr = &tid - } - } - evType := "plan.step.finished" - if status == "running" { - evType = "plan.step.started" - } - data := map[string]any{"step_id": stepID.String(), "seq": seq, "status": status} - if execID != "" { - data["execution_id"] = execID - } - _ = observability.Event(ctx, sqlcgen.New(s.pool), evType, entPtr, "info", "nomos", sessionID, data) - return nil -} - -// ErrTaskAlreadyComplete is returned by completeTask when the session is -// already in a terminal state (done/failed/partial). The agent sometimes -// re-calls complete_task after a UI clarification (operator-reported -// 2026-07-14) — without this guard, the re-completion produces duplicate -// knowledge entries and erodes audit-log clarity. The caller translates this -// into a directive tool result. -var ErrTaskAlreadyComplete = errors.New("task already complete") - -// completeTask sets a task's terminal state, outcome, and one-line summary, -// mirrors the outcome onto the task entity's attributes (so the board/graph -// show it), and publishes task.status for the live context panel. outcome is -// success|failure|partial; status is derived (failure → failed, else done). -// Returns ErrTaskAlreadyComplete if the session is already terminal — the -// agent must not re-complete a finished task. -func (s *Store) CompleteTask(ctx context.Context, sessionID, outcome, summary string) error { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return nil - } - - // C.1: reject re-completion of an already-terminal session. The agent - // sometimes re-calls complete_task after a UI clarification ("the sidebar - // differs") — without this guard, the re-completion duplicates knowledge - // entries and produces a confusing audit trail. - var currentStatus string - if err := s.pool.QueryRow(ctx, - `SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil { - // Session doesn't exist or query failed — let the rest of the - // function proceed; it'll fail safely on the UPDATE below. - } else if currentStatus == "done" || currentStatus == "failed" { - return ErrTaskAlreadyComplete - } - - // Auto-cancel any executions still in pending_approval/approved/queued - // state for this session — preventing orphaned approvals (observed in - // production: 4 approvals left open after session completed). - var cancelledCount int - if err := s.pool.QueryRow(ctx, ` - WITH cancelled AS ( - UPDATE executions SET status = 'cancelled', - result = '{"message": "task completed — auto-cancelled"}'::jsonb - WHERE entity_id IN ( - SELECT execution_id FROM nomos_plan_executions WHERE session_id = $1 - ) AND status IN ('pending_approval', 'approved', 'queued') - RETURNING entity_id - ) - SELECT COUNT(*) FROM cancelled - `, sessionID).Scan(&cancelledCount); err != nil { - slog.Warn("nomos: completeTask failed to cancel orphaned executions", "session", sessionID, "error", err) - } - - // Mark all continuations done so the worker won't try to feed them back. - s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() - WHERE session_id = $1 AND continued_at IS NULL`, sessionID) - - // P1.4 (2026-07-20): auto-close any in-flight plan steps so the agent - // doesn't need an update_plan_step(running)→update_plan_step(done) - // dance for each step right before completion. Session 8c76bb3a - // (greeting + title-sync test) burned 4 update_plan_step calls for a - // one-step plan. completeTask is the authoritative terminal — any - // step still in pending/running when the task ends is closed (as - // "done" for success, "skipped" for partial/failure) so the UI's plan - // view doesn't show orphaned running steps on a completed task. - // Replaced/cancelled/blocked steps are left alone. - closeStatus := "done" - if outcome != "success" { - closeStatus = "skipped" - } - // Auto-close only the CURRENT generation's in-flight steps — superseded - // generations were already resolved when their plan was replaced. Stamp - // started_at so no `done` step is left with a NULL start time (P0.1 fix - // 5), and emit a plan.step.finished event per closed step so the panel - // converges instead of freezing on "running" after the task completes - // (P1.1: no bulk plan-step status write without a corresponding event). - type closingStep struct { - id uuid.UUID - seq int - targetSlug *string - } - var toClose []closingStep - if rows, qerr := s.pool.Query(ctx, ` - SELECT id, seq, target_slug FROM session_plan_steps - WHERE session_id = $1 - AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) - AND status IN ('pending', 'running')`, sessionID); qerr == nil { - for rows.Next() { - var cs closingStep - if err := rows.Scan(&cs.id, &cs.seq, &cs.targetSlug); err == nil { - toClose = append(toClose, cs) - } - } - rows.Close() - } - if _, err := s.pool.Exec(ctx, ` - UPDATE session_plan_steps - SET status = $2, - started_at = COALESCE(started_at, now()), - finished_at = COALESCE(finished_at, now()) - WHERE session_id = $1 - AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) - AND status IN ('pending', 'running')`, - sessionID, closeStatus); err != nil { - slog.Warn("nomos: completeTask failed to auto-close in-flight steps", "session", sessionID, "error", err) - } - // Emit one plan.step.finished per closed step so the live panel advances - // (mirrors updatePlanStep's event). A bulk UPDATE that skips the event - // bus guarantees a stale panel — the rule is: no plan-step status change - // without a corresponding event. - taskEnt := s.taskEntityPtr(ctx, sessionID) - for _, cs := range toClose { - evEnt := taskEnt - if cs.targetSlug != nil && *cs.targetSlug != "" { - var tid uuid.UUID - if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *cs.targetSlug).Scan(&tid) == nil { - evEnt = &tid - } - } - _ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.step.finished", evEnt, "info", "nomos", sessionID, - map[string]any{"step_id": cs.id.String(), "seq": cs.seq, "status": closeStatus}) - } - - // Clean up assent and destructive window keys from autonomy_settings. - s.pool.Exec(ctx, `DELETE FROM autonomy_settings - WHERE key LIKE '%:' || $1`, sessionID) - - status := "done" - if outcome == "failure" { - status = "failed" - } - // P1.5 (2026-07-20): derive a structured blocker reason when the - // outcome is partial/failed, so trend analysis can answer "why are - // sessions failing?" without parsing free-text summaries. Three - // duplicate rclone sessions (a51e2086, 8acea2e3, cb8c8a4a) all - // bounced off the classifier; without a blocker field, the *why* was - // buried in the last assistant message. The signatures matched here - // are the recurring ones from the 2026-07-20 session audit. Empty for - // success — that's not a blocker. - blocker := "" - if outcome != "success" { - blocker = deriveBlocker(ctx, s, sessionID, summary) - } - if _, err := s.pool.Exec(ctx, ` - UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, - blocker = $5, closed_at = now(), last_active_at = now() - WHERE id = $1`, sessionID, status, outcome, summary, blocker); err != nil { - return err - } - var entID uuid.UUID - s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&entID) - var entPtr *uuid.UUID - if entID != uuid.Nil { - attrs, _ := json.Marshal(map[string]any{"outcome": outcome, "status": status, "summary": summary}) - s.pool.Exec(ctx, `UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE id = $1`, - entID, string(attrs)) - entPtr = &entID - } - severity := "info" - if outcome == "failure" { - severity = "warning" - } - _ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID, - map[string]any{"status": status, "outcome": outcome, "summary": summary, - "cancelled_executions": cancelledCount, "blocker": blocker}) - // Auto-persist knowledge so the graph learns from this session regardless - // of whether the agent remembered to call upsert_knowledge (2026-08-04 - // session audit: only 2.4% of sessions called upsert_knowledge manually). - if outcome == "success" || outcome == "partial" { - autoUpsertKnowledge(ctx, s, sessionID, outcome, summary) - } - // Plan quality metric: compute step completion rate for the session's - // current plan generation. Tracked as a task attribute so the trend - // can be monitored over time (2026-08-04 session audit: 38% baseline). - writePlanCompletionRate(ctx, s, sessionID) - // Auto-feedback: create a feedback entry linking the session's outcome - // to its last execution, feeding the pattern-extraction pipeline that - // has been empty since launch (2026-08-04 session audit: 0 feedback rows). - if outcome == "success" || outcome == "partial" { - autoFeedback(ctx, s, sessionID, outcome, summary) - } - return nil -} - -// autoUpsertKnowledge creates a knowledge entry for a completed Session, -// capturing what was done and linking it to the entities involved. Called -// automatically from completeTask so every session leaves a trace, even if -// the agent forgot to call upsert_knowledge. Only fired for success/partial -// outcomes (failures don't have actionable discoveries). -func autoUpsertKnowledge(ctx context.Context, s *Store, sessionID, outcome, summary string) { - var goal string - if err := s.pool.QueryRow(ctx, - `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, - sessionID).Scan(&goal); err != nil || goal == "" { - return - } - title := "Session " + sessionID[:8] + ": " + goal - if len(title) > 200 { - title = title[:200] - } - content := "## Outcome\n" + outcome + "\n\n## Summary\n" + summary - kind := "investigation" - slug := "investigation:nomos/" + sessionID - tags := []string{"nomos-session", "auto-generated"} - - // Upsert the knowledge entity. - docID, _ := uuid.NewV7() - if err := s.pool.QueryRow(ctx, ` - INSERT INTO entities (id, slug, type, name, attributes) - VALUES ($1, $2, $3, $4, '{}') - ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, updated_at = now() - RETURNING id`, docID, slug, kind, title).Scan(&docID); err != nil { - slog.Warn("nomos: autoUpsertKnowledge entity insert", "session", sessionID, "error", err) - return - } - - // Upsert the knowledge content. - if _, err := s.pool.Exec(ctx, ` - INSERT INTO knowledge_entities (entity_id, title, content, source, tags, updated_at) - VALUES ($1, $2, $3, 'nomos-agent', $4, now()) - ON CONFLICT (entity_id) DO UPDATE - SET title = EXCLUDED.title, content = EXCLUDED.content, - tags = EXCLUDED.tags, updated_at = now()`, - docID, title, content, tags); err != nil { - slog.Warn("nomos: autoUpsertKnowledge content insert", "session", sessionID, "error", err) - return - } - - // Link to the task entity. - var taskEntID uuid.UUID - if s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, - sessionID).Scan(&taskEntID) == nil && taskEntID != uuid.Nil { - s.pool.Exec(ctx, ` - INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - SELECT $1, $2, 'involves', '{"by":"nomos","auto":true}'::jsonb, now() - WHERE NOT EXISTS ( - SELECT 1 FROM relationships - WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`, - taskEntID, docID) - } - - slog.Info("nomos: auto-upserted knowledge for session", - "session", sessionID, "outcome", outcome, "slug", slug) -} - -// writePlanCompletionRate computes the step completion rate for the current -// plan generation and writes it as a task entity attribute so the trend can -// be tracked. Baseline from 2026-08-04 audit: 38% (15/39 steps reached done). -func writePlanCompletionRate(ctx context.Context, s *Store, sessionID string) { - var total, completed int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*), COALESCE(SUM(CASE WHEN status = 'done' THEN 1 ELSE 0 END), 0) - FROM session_plan_steps - WHERE session_id = $1 - AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1) - AND status <> 'replaced'`, sessionID).Scan(&total, &completed) - if total > 0 { - rate := float64(completed) / float64(total) - attrs, _ := json.Marshal(map[string]any{"plan_completion_rate": rate, "plan_steps_total": total, "plan_steps_completed": completed}) - s.pool.Exec(ctx, ` - UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() - WHERE id = (SELECT entity_id FROM agent_sessions WHERE id = $1)`, - sessionID, string(attrs)) - slog.Info("nomos: plan completion rate", "session", sessionID, "rate", fmt.Sprintf("%.0f%%", rate*100), - "completed", completed, "total", total) - } -} - -// autoFeedback creates a feedback entry linking the session's outcome to its -// last execution, feeding the pattern-extraction pipeline that has been empty -// since launch. Only created for success/partial outcomes (failures don't -// have a specific execution to tie to). -func autoFeedback(ctx context.Context, s *Store, sessionID, outcome, summary string) { - // Find the last execution linked to this session. - var execID uuid.UUID - if err := s.pool.QueryRow(ctx, ` - SELECT pe.execution_id FROM nomos_plan_executions pe - WHERE pe.session_id = $1::uuid - ORDER BY pe.created_at DESC LIMIT 1`, sessionID).Scan(&execID); err != nil || execID == uuid.Nil { - return - } - fbID, _ := uuid.NewV7() - slug := "feedback:" + fbID.String() - if _, err := s.pool.Exec(ctx, ` - INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'feedback', $3, '{}')`, - fbID, slug, "feedback for "+sessionID[:8]); err != nil { - slog.Warn("nomos: autoFeedback entity insert", "session", sessionID, "error", err) - return - } - _, err := s.pool.Exec(ctx, ` - INSERT INTO feedback (entity_id, execution_id, outcome, observation, lesson, tags, created_at) - VALUES ($1, $2, $3, $4, $5, $6, now())`, - fbID, execID, outcome, summary, summary, []string{"nomos-session", "auto-generated", "session:" + sessionID[:8]}) - if err != nil { - slog.Warn("nomos: autoFeedback insert", "session", sessionID, "error", err) - return - } - slog.Info("nomos: auto-feedback created for session", "session", sessionID, "outcome", outcome) -} - -// blockerPatterns maps a substring (case-insensitive) to a structured blocker -// reason. Order matters — earlier patterns take precedence. These are the -// recurring failure signatures from the 2026-07-20 session audit. A -// real-world blocker that doesn't match any of these falls through to -// "uncategorized" — better than empty, because empty means "we don't know -// it's a blocker at all." See plans/2026-07-20-session-review-ten-sessions.md. -var blockerPatterns = []struct { - pattern string - reason string -}{ - {"queued for approval", "approval_timeout"}, - {"assent window", "approval_timeout"}, - {"cancel", "user_abandoned"}, - {"close this session", "user_abandoned"}, - {"lets just close", "user_abandoned"}, - {"classifier flagged", "classifier_overreach"}, - {"config_mutation", "classifier_overreach"}, - {"refus", "model_refusal"}, // refuses/refused/refusal - {"empty response", "model_empty_response"}, - {"no local knowledge", "missing_knowledge"}, - {"can't run", "missing_capability"}, - {"cannot run", "missing_capability"}, - {"timeout", "tool_error"}, - {"error", "tool_error"}, -} - -// deriveBlocker scans the last assistant message + the summary for known -// failure signatures and returns the matching structured reason. Returns -// "uncategorized" when outcome is partial/failed but no signature matched — -// better than "" because the audit needs to know this WAS blocked, just for -// an unknown reason. Returns "" for success outcomes (caller checks first). -func deriveBlocker(ctx context.Context, s *Store, sessionID, summary string) string { - // Pull the last assistant text — that's where the agent's parting - // words explain why it didn't finish. - var lastText string - _ = s.pool.QueryRow(ctx, ` - SELECT content::text FROM agent_messages - WHERE session_id = $1 AND role = 'assistant' - ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&lastText) - haystack := strings.ToLower(lastText + " " + summary) - for _, p := range blockerPatterns { - if strings.Contains(haystack, p.pattern) { - return p.reason - } - } - return "uncategorized" -} - -// hadEntityWriteback checks whether this session called update_entity_attributes -// or create_relationship — used by complete_task to warn the agent when it -// forgot to persist entity facts (the #1 cause of knowledge graph drift). -func (s *Store) HadEntityWriteback(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" { - return true // fail safe: don't warn when we can't check - } - var count int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM agent_activity - WHERE session_id = $1 - AND tool_name IN ('update_entity_attributes', 'create_relationship') - AND success = true`, sessionID).Scan(&count) - return count > 0 -} - -// hadDiscovery checks whether this session ran `run` successfully against a -// real target — i.e. discovered live state (versions, package counts, host -// facts, service status) that the DB didn't have. Used by complete_task to -// refuse success when discovery happened but no writeback followed (the -// knowledge-loop drift the prior warnings failed to close — the agent -// ignored advisory text, so D.1 makes it structural). -// -// Only `run` counts as discovery here, NOT get_entity/list_lxcs/etc. — those -// are DB lookups, not new facts. A trivial Q&A ("status of lxc:dns?") that -// only calls get_entity is a degenerate case (SOUL.md: "Don't invent -// attributes that don't exist") and must NOT be blocked. Only sessions that -// actually executed against a live target get the writeback gate. -func (s *Store) HadDiscovery(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" { - return false // fail safe: don't block when we can't check - } - var count int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM agent_activity - WHERE session_id = $1 - AND tool_name = 'run' - AND success = true`, sessionID).Scan(&count) - return count > 0 -} - -// sessionGoal returns the session's goal text, empty string if not found. -// Used by complete_task to check whether the goal involved a reachability -// verification before marking success. -func (s *Store) SessionGoal(ctx context.Context, sessionID string) string { - if s == nil || sessionID == "" { - return "" - } - var goal string - s.pool.QueryRow(ctx, - `SELECT COALESCE(goal, '') FROM agent_sessions WHERE id = $1`, - sessionID).Scan(&goal) - return goal -} - -// hadRecentVerification checks whether the session successfully verified -// reachability in recent turns — ping_service, or a run with curl/wget that -// returned successfully. Used by complete_task as a soft warning when the -// goal involved a reachability check but no recent verification occurred. -func (s *Store) HadRecentVerification(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" { - return true // fail safe: don't warn when we can't check - } - // Check for ping_service calls in the last 5 activity entries for this session. - var pingCount int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM ( - SELECT 1 FROM agent_activity - WHERE session_id = $1 AND tool_name = 'ping_service' AND success = true - ORDER BY ts DESC LIMIT 5 - ) sub`, sessionID).Scan(&pingCount) - if pingCount > 0 { - return true - } - // Check for run calls with curl/wget that returned successfully. - var curlCount int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM ( - SELECT 1 FROM agent_activity - WHERE session_id = $1 - AND tool_name = 'run' - AND success = true - AND (input_summary LIKE '%curl%' OR input_summary LIKE '%wget%') - ORDER BY ts DESC LIMIT 10 - ) sub`, sessionID).Scan(&curlCount) - return curlCount > 0 -} - -// staleGoalSession is a goal-bearing task that's gone idle without reaching -// a terminal state — the idle-sweep worker's work list (fix 2+3 of -// plans/2026-07-11-task-completion-safety-net.md). -type staleGoalSession struct { - ID string - Goal string - CompletionNudges int -} - -// staleGoalSessions finds sessions that framed themselves as a real task -// (goal != ”, so the inline safety net in agent.go intentionally left them -// alone) but have sat non-terminal past idleThreshold. completion_nudges -// tells the caller whether to nudge (0) or give up and auto-close (>=1) — -// see processIdleSweep in continue.go. -func (s *Store) StaleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession { - if s == nil { - return nil - } - rows, err := s.pool.Query(ctx, ` - SELECT id, goal, completion_nudges - FROM agent_sessions - WHERE goal <> '' - AND status IN ('active', 'planning', 'executing') - AND last_active_at < now() - ($1 * interval '1 second') - ORDER BY last_active_at - LIMIT $2`, idleThreshold.Seconds(), limit) - if err != nil { - return nil - } - defer rows.Close() - var out []staleGoalSession - for rows.Next() { - var s staleGoalSession - if err := rows.Scan(&s.ID, &s.Goal, &s.CompletionNudges); err == nil { - out = append(out, s) - } - } - return out -} - -// bumpCompletionNudge records that the idle sweep nudged a stalled Session, -// stamping last_active_at so it isn't picked up again until it's genuinely -// idle again (a fresh nudge shouldn't fire every tick while the model is -// mid-response to the previous one). -func (s *Store) BumpCompletionNudge(ctx context.Context, sessionID string) error { - if s == nil { - return nil - } - _, err := s.pool.Exec(ctx, ` - UPDATE agent_sessions SET completion_nudges = completion_nudges + 1, last_active_at = now() - WHERE id = $1`, sessionID) - return err -} - -// allPlanStepsTerminal reports whether every plan step for this session is in -// a terminal state (done/failed/replaced/skipped/blocked) — i.e. no step is -// still pending or running. Used by autoCompleteIfPlanDone to auto-close a -// task when the agent did all the work but forgot to call complete_task. -// Returns false if there are no plan steps at all (no plan was proposed). -func (s *Store) AllPlanStepsTerminal(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return false - } - var total, terminal int - if err := s.pool.QueryRow(ctx, - `SELECT COUNT(*), COUNT(*) FILTER (WHERE status IN ('done', 'failed', 'replaced', 'skipped', 'blocked')) - FROM session_plan_steps WHERE session_id = $1`, - sessionID).Scan(&total, &terminal); err != nil { - return false - } - return total > 0 && total == terminal -} - -// hasPendingApprovals reports whether this session has any executions in -// pending_approval state. Used by autoCompleteIfPlanDone to avoid closing a -// session that's blocked waiting for operator approval — the agent hit the -// P5 gate and can't continue until the operator responds. -func (s *Store) HasPendingApprovals(ctx context.Context, sessionID string) bool { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return false - } - var count int - s.pool.QueryRow(ctx, ` - SELECT COUNT(*) FROM nomos_plan_executions pe - JOIN executions ex ON ex.entity_id = pe.execution_id - WHERE pe.session_id = $1 AND ex.status = 'pending_approval'`, - sessionID).Scan(&count) - return count > 0 -} - -// planStep is a persisted plan step, as returned to the frontend for hydration -// (the panel otherwise only sees steps live via plan.proposed/plan.step.*). -type planStep struct { - ID string `json:"id"` - Seq int `json:"seq"` - Title string `json:"title"` - Detail string `json:"detail"` - Status string `json:"status"` - ExecutionID *string `json:"execution_id,omitempty"` - TargetSlug *string `json:"target_slug,omitempty"` - StartedAt *string `json:"started_at,omitempty"` - FinishedAt *string `json:"finished_at,omitempty"` - Generation int `json:"generation"` -} - -// getPlanSteps returns a task's plan in order — REST hydration for the context -// panel when it first opens a task (live events only carry deltas from then on). -// By default only the CURRENT (MAX) generation is returned — the panel shows the -// live plan, not an archaeological record of every superseded generation. Pass -// all=true for the audit/eval view that needs every generation (the -// plan_generations assertion counts distinct generations across the full set). -func (s *Store) GetPlanSteps(ctx context.Context, sessionID string, all bool) ([]planStep, error) { - if s == nil { - return nil, nil - } - genFilter := "" - if !all { - genFilter = "AND generation = (SELECT MAX(generation) FROM session_plan_steps WHERE session_id = $1)" - } - rows, err := s.pool.Query(ctx, ` - SELECT id::text, seq, title, detail, status, - execution_id::text, target_slug, - started_at::text, finished_at::text, generation - FROM session_plan_steps WHERE session_id = $1 `+genFilter+` ORDER BY generation, seq`, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []planStep - for rows.Next() { - var st planStep - var execID, target, started, finished *string - if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status, - &execID, &target, &started, &finished, &st.Generation); err != nil { - return nil, err - } - st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished - out = append(out, st) - } - return out, rows.Err() -} - -// sessionQuestion is a persisted question, as returned to the frontend. -type sessionQuestion struct { - ID string `json:"id"` - Prompt string `json:"prompt"` - Context map[string]any `json:"context"` - Status string `json:"status"` - Answer *string `json:"answer,omitempty"` - CreatedAt string `json:"created_at"` - AnsweredAt *string `json:"answered_at,omitempty"` -} - -// getQuestions returns a task's questions (open and answered) newest-first — -// REST hydration for the context panel's pinned question card and history. -func (s *Store) GetQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) { - if s == nil { - return nil, nil - } - rows, err := s.pool.Query(ctx, ` - SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text - FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID) - if err != nil { - return nil, err - } - defer rows.Close() - var out []sessionQuestion - for rows.Next() { - var q sessionQuestion - var ctxJSON []byte - var answer, answeredAt *string - if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil { - return nil, err - } - json.Unmarshal(ctxJSON, &q.Context) - q.Answer, q.AnsweredAt = answer, answeredAt - out = append(out, q) - } - return out, rows.Err() -} - -// askOperator records a structured decision the agent needs from the operator, -// moves the task to awaiting_input, and emits question.raised so the context -// panel pins it. qctx carries {why, options, entities}. Returns the question id. -func (s *Store) AskOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return "", nil - } - ctxJSON, _ := json.Marshal(qctx) - var qid uuid.UUID - if err := s.pool.QueryRow(ctx, ` - INSERT INTO session_questions (session_id, prompt, context) VALUES ($1, $2, $3) RETURNING id`, - sessionID, prompt, string(ctxJSON)).Scan(&qid); err != nil { - return "", err - } - s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() WHERE id = $1`, sessionID) - data := map[string]any{"question_id": qid.String(), "prompt": prompt} - for k, v := range qctx { - data[k] = v - } - _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.raised", s.taskEntityPtr(ctx, sessionID), - "warning", "nomos", sessionID, data) - return qid.String(), nil -} - -// openQuestionID returns the id of the session's open question, or "". Used to -// auto-close a pending question when the operator answers via a plain chat reply. -func (s *Store) OpenQuestionID(ctx context.Context, sessionID string) string { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return "" - } - var qid string - s.pool.QueryRow(ctx, `SELECT id::text FROM session_questions - WHERE session_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&qid) - return qid -} - -// getQuestion returns a question's prompt, answer, and session — used to build -// the resume note when the operator answers via the panel. -func (s *Store) GetQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) { - if s == nil || questionID == "" { - return "", "", "" - } - qid, err := uuid.Parse(questionID) - if err != nil { - return "", "", "" - } - s.pool.QueryRow(ctx, `SELECT prompt, COALESCE(answer, ''), session_id::text - FROM session_questions WHERE id = $1`, qid).Scan(&prompt, &answer, &sessionID) - return -} - -// answerQuestion records the operator's answer, returns the task to executing, -// and emits question.answered. It does NOT itself resume the agent — the caller -// decides: a chat reply IS the resuming turn, while a panel answer triggers a -// continuation. -func (s *Store) AnswerQuestion(ctx context.Context, sessionID, questionID, answer string) error { - if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" { - return nil - } - qid, err := uuid.Parse(questionID) - if err != nil { - return err - } - if _, err := s.pool.Exec(ctx, ` - UPDATE session_questions SET status = 'answered', answer = $2, answered_at = now() - WHERE id = $1 AND status = 'open'`, qid, answer); err != nil { - return err - } - s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID) - _ = observability.Event(ctx, sqlcgen.New(s.pool), "question.answered", s.taskEntityPtr(ctx, sessionID), - "info", "nomos", sessionID, map[string]any{"question_id": questionID, "answer": answer}) - return nil -} - -// knowledgeSlugRe matches a nomos knowledge doc slug (:nomos/) as -// printed in upsert_knowledge's result text. -var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`) - -// linkKnowledgeToTask runs after a successful upsert_knowledge call within a -// task: it links the created knowledge doc to the task entity (documents) so -// get_relations(task) surfaces what the task learned, and publishes -// knowledge.recorded for the live panel. Best-effort. The doc is ALSO linked to -// the entity it's "about" by upsert_knowledge itself — that about-link is the -// retrieval path future tasks use (get_entity_knowledge); this task-link is for -// the task's own outcome/knowledge view. -func (s *Store) LinkKnowledgeToTask(ctx context.Context, sessionID, resultText string) { - if s == nil || sessionID == "" || sessionID == "ephemeral" { - return - } - slug := knowledgeSlugRe.FindString(resultText) - if slug == "" { - return - } - var taskID, docID uuid.UUID - if err := s.pool.QueryRow(ctx, - `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskID); err != nil || taskID == uuid.Nil { - return - } - if err := s.pool.QueryRow(ctx, - `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&docID); err != nil { - return - } - s.pool.Exec(ctx, ` - INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now() - WHERE NOT EXISTS ( - SELECT 1 FROM relationships - WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`, - docID, taskID) - _ = observability.Event(ctx, sqlcgen.New(s.pool), "knowledge.recorded", &docID, "info", "nomos", sessionID, - map[string]any{"slug": slug}) -} - -func (s *Store) UpdateSessionTitle(ctx context.Context, id, title string) error { - if s == nil { - return nil - } - _, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id) - return err -} - -// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). -// Returns uuid.Nil if the store is absent or the slug is unknown. -func (s *Store) ResolveAgentID(ctx context.Context, slug string) uuid.UUID { - if s == nil { - return uuid.Nil - } - var id uuid.UUID - if err := s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, slug).Scan(&id); err != nil { - return uuid.Nil - } - return id -} - -// linkExecution records that a gated execution was initiated by a chat -// Session, so the auto-continuation worker can feed its result back to that -// session when it finishes. Idempotent — the same execution may appear in -// several tool results across a turn. -func (s *Store) LinkExecution(ctx context.Context, execID uuid.UUID, sessionID string) { - if s == nil || execID == uuid.Nil || sessionID == "" || sessionID == "ephemeral" { - return - } - s.pool.Exec(ctx, ` - INSERT INTO nomos_plan_executions (execution_id, session_id) - VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID) -} - -// taskSlugRe matches an entity slug: a lowercase type prefix then colon- -// separated segments (host:hubris, lxc:caddy, check:ping:8cf). Mirrors the -// frontend SessionGraph regex so the panel and the involves-graph agree on -// what counts as an entity reference. -var taskSlugRe = regexp.MustCompile(`[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*`) - -// touchExcludedTypes are entity types too noisy to record as task involvement: -// a health question names dozens of check:… slugs, executions/tasks are -// bookkeeping, not things the task "worked on". -var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task": true} - -// recordTouched links the task to every entity referenced in a tool call's -// args (task —involves→ entity) and publishes one entity.touched event per -// entity so the live context panel can pulse it. Best-effort: it never blocks -// or fails the tool call. Only args are inspected — what the agent chose to act -// on — never results, since a single bulk query result would otherwise pull the -// whole fleet into the task's graph. -func (s *Store) RecordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) { - if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 { - return - } - slugs := map[string]struct{}{} - collectTaskSlugs(args, slugs) - if len(slugs) == 0 { - return - } - var taskEntityID uuid.UUID - if err := s.pool.QueryRow(ctx, - `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskEntityID); err != nil || taskEntityID == uuid.Nil { - return // no task entity to anchor edges on - } - - // One batched lookup instead of a SELECT per slug — a tool call naming - // several entities (e.g. a multi-target comparison) used to issue N - // round-trips here for N slugs found in its args. - slugList := make([]string, 0, len(slugs)) - for slug := range slugs { - slugList = append(slugList, slug) - } - rows, err := s.pool.Query(ctx, - `SELECT id, type, slug FROM entities WHERE slug = ANY($1)`, slugList) - if err != nil { - return - } - type found struct { - id uuid.UUID - etype string - } - matched := make(map[string]found, len(slugList)) - for rows.Next() { - var f found - var slug string - if rows.Scan(&f.id, &f.etype, &slug) == nil { - matched[slug] = f - } - } - rows.Close() - if err := rows.Err(); err != nil { - return - } - - q := sqlcgen.New(s.pool) - for slug, f := range matched { - if touchExcludedTypes[f.etype] || f.id == taskEntityID { - continue - } - // Idempotent involves edge (task → entity), same guard as upsert_knowledge. - s.pool.Exec(ctx, ` - INSERT INTO relationships (source_id, target_id, type, attributes, valid_from) - SELECT $1, $2, 'involves', '{"by":"nomos"}'::jsonb, now() - WHERE NOT EXISTS ( - SELECT 1 FROM relationships - WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`, - taskEntityID, f.id) - // Live pulse for the panel. correlation_id = sessionID lets the frontend - // filter to the active task. - _ = observability.Event(ctx, q, "entity.touched", &f.id, "info", "nomos", sessionID, - map[string]any{"slug": slug, "tool": toolName}) - } -} - -// collectTaskSlugs recursively pulls entity slugs out of tool-call args, -// mirroring the frontend's collectSlugs so both sides see the same references. -func collectTaskSlugs(v any, out map[string]struct{}) { - switch t := v.(type) { - case string: - for _, m := range taskSlugRe.FindAllString(t, -1) { - out[strings.TrimRight(m, ".,;)]")] = struct{}{} - } - case []any: - for _, e := range t { - collectTaskSlugs(e, out) - } - case map[string]any: - for _, e := range t { - collectTaskSlugs(e, out) - } - } -} - -// pendingContinuation is one finished execution whose result hasn't yet been -// fed back to its originating session. -type PendingContinuation struct { - ExecID uuid.UUID - SessionID string - Status string - Result string - Action string -} - -// pendingContinuations returns executions that have reached a terminal state -// but haven't been continued yet — the worker's work list. Bounded so one -// tick can't fan out unboundedly. -func (s *Store) PendingContinuations(ctx context.Context, limit int) []PendingContinuation { - if s == nil { - return nil - } - rows, err := s.pool.Query(ctx, ` - SELECT l.execution_id, l.session_id, e.status, - COALESCE(e.result::text, ''), COALESCE(e.action, '') - FROM nomos_plan_executions l - JOIN executions e ON e.entity_id = l.execution_id - WHERE l.continued_at IS NULL - AND e.status IN ('completed', 'failed', 'cancelled', 'denied', 'revoked') - ORDER BY l.created_at - LIMIT $1`, limit) - if err != nil { - return nil - } - defer rows.Close() - var out []PendingContinuation - for rows.Next() { - var p PendingContinuation - if err := rows.Scan(&p.ExecID, &p.SessionID, &p.Status, &p.Result, &p.Action); err == nil { - out = append(out, p) - } - } - return out -} - -// markContinued stamps an execution as fed-back so the worker won't process it -// again (prevents an auto-continuation loop). -func (s *Store) MarkContinued(ctx context.Context, execID uuid.UUID) { - if s == nil { - return - } - s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID) -} - -// assentWindowActive reports whether THIS TASK currently has an open assent -// window — the scope gate for auto-continuation. Scoped by Session, not just -// agent: with a single agent:nomos entity serving every concurrent task, an -// agent-only key would let approving Task A's plan silently auto-run -// unapproved config-mutation actions in a concurrently-running Task B. We -// only auto-continue executions that are part of THIS session's approved -// plan, never a stray action from another task riding the same window. -func (s *Store) AssentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool { - if s == nil || agentID == uuid.Nil || sessionID == "" { - return false // fail closed: no session to scope to means no window - } - var expires time.Time - key := AssentWindowKey(agentID, sessionID) - if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil { - return false - } - return time.Now().Before(expires) -} - -// AssentWindowKey scopes the grant to one agent AND one session/task — see -// assentWindowActive. Must match internal/mcp/server.go's copy (mirrored -// there, not shared, since the two are separate Go packages/binaries reading -// the same autonomy_settings row). -func AssentWindowKey(agentID uuid.UUID, sessionID string) string { - return "assent_window.agent:" + agentID.String() + ".session:" + sessionID -} - -// destructiveWindowDuration is intentionally shorter than the general assent -// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE -// recovery (e.g. "stop then destroy this specific half-provisioned -// container"), not a standing license to destroy things. -const destructiveWindowDuration = 15 * time.Minute - -// destructiveWindowKey scopes the grant to one agent, one target entity, AND -// one session/task — an explicit typed confirmation ("I confirm") for a -// destructive action on target X in task A must never be read as authorizing -// a destructive action on target X from a DIFFERENT concurrently-running -// task B, even though both share the same agent identity. -func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) string { - return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug + ".session:" + sessionID -} - -// openDestructiveWindow records a short, target-and-session-scoped grant -// after an operator's EXPLICIT typed confirmation (never loose assent) -// authorized a destructive action. Real case this exists for: recovering a -// failed destroy took "stop" (destructive) then "destroy" (destructive) — -// same container, two separate typed-confirmation round trips, because each -// was gated independently. One explicit confirmation on a target should -// cover the short follow-up sequence needed to finish what was just -// confirmed — but only within the task that got the confirmation. -func (s *Store) OpenDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) { - if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { - return - } - expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339) - s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2) - ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug, sessionID), expires) -} - -// destructiveWindowActive reports whether target has a live, explicitly- -// confirmed destructive grant for this agent within this session/task. -func (s *Store) DestructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool { - if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" { - return false - } - var expires time.Time - if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, - destructiveWindowKey(agentID, targetSlug, sessionID)).Scan(&expires); err != nil { - return false - } - return time.Now().Before(expires) -} - -// executionTarget resolves the target entity slug for an execution — used to -// scope the destructive window to the right entity when a chat-assent typed -// confirmation grants a destructive execution. -func (s *Store) ExecutionTarget(ctx context.Context, execID uuid.UUID) string { - if s == nil { - return "" - } - var slug string - s.pool.QueryRow(ctx, ` - SELECT e.slug FROM executions ex JOIN entities e ON e.id = ex.target_entity_id - WHERE ex.entity_id = $1`, execID).Scan(&slug) - return slug -} - -// entityArgKeys lists tool-argument keys, in priority order, that commonly -// carry the target entity's slug or UUID. Tool input schemas aren't -// consistent about naming this (target, entity_slug, slug, service_slug, -// lxc_slug, entity_id all appear across the MCP tool registrations in -// internal/mcp/server.go), so this is a best-effort lookup used to tag -// agent_activity rows with the entity a tool call acted on. -var entityArgKeys = []string{ - "target", "entity_slug", "slug", "slug_or_id", - "service_slug", "lxc_slug", "entity_id", "about", -} - -// resolveArgEntityID best-effort resolves the entity a tool call acted on -// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no -// key is present or none resolves to a known entity. -func (s *Store) ResolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID { - if s == nil { - return uuid.Nil - } - for _, key := range entityArgKeys { - v, _ := args[key].(string) - if v == "" { - continue - } - if u, err := uuid.Parse(v); err == nil { - return u - } - var id uuid.UUID - if err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil { - return id - } - } - return uuid.Nil -} - -// logActivity records a tool call. agent_id is the agent entity UUID and is -// NOT NULL in the schema, so we skip logging when it can't be resolved. -// The (nullable) session_id column carries the conversation id. args is the -// tool call's own arguments, used to best-effort tag the row with the -// entity it acted on (see resolveArgEntityID). -func (s *Store) LogActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string, tokenCount int) { - if s == nil || agentID == uuid.Nil { - return - } - entityID := s.ResolveArgEntityID(ctx, args) - var entityIDArg any - if entityID != uuid.Nil { - entityIDArg = entityID - } - s.pool.Exec(ctx, ` - INSERT INTO agent_activity - (agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary, - duration_ms, success, correlation_id, token_count) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, - agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary, - durationMs, success, correlationID, tokenCount) -} diff --git a/internal/nomos/session/store_test.go b/internal/nomos/session/store_test.go deleted file mode 100644 index 3e0a6f8f..00000000 --- a/internal/nomos/session/store_test.go +++ /dev/null @@ -1,523 +0,0 @@ -package session - -// Integration tests against a real Postgres, mirroring -// internal/db/integration_test.go's pattern: guarded by -// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run, -// full migrations applied, dropped on cleanup. Run with: -// -// docker compose up -d postgres -// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/ - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "math/rand" - "os" - "strings" - "testing" - - "github.com/dtoro/oikos/internal/adapters/postgres" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" -) - -// newTestStore creates a throwaway, fully-migrated database and returns a -// *Store connected to it, cleaned up (including a matching task:<session> -// entity type in the ontology, needed by createTaskEntity/proposePlan tests) -// via t.Cleanup. -func newTestStore(t *testing.T) *Store { - t.Helper() - baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL") - if baseURL == "" { - t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test") - } - ctx := context.Background() - - admin, err := pgx.Connect(ctx, baseURL) - if err != nil { - t.Fatalf("connect admin: %v", err) - } - dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63()) - if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil { - admin.Close(ctx) - t.Fatalf("create test db: %v", err) - } - admin.Close(ctx) - - testURL := swapTestDatabase(baseURL, dbName) - pool, err := db.New(ctx, testURL) - if err != nil { - t.Fatalf("connect test db: %v", err) - } - t.Cleanup(func() { - pool.Close() - admin, err := pgx.Connect(ctx, baseURL) - if err == nil { - admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)") - admin.Close(ctx) - } - }) - if err := pool.Migrate(ctx); err != nil { - t.Fatalf("migrate: %v", err) - } - - // session_plan_steps/session_questions tests don't need the ontology - // seed, but createTaskEntity's INSERT INTO entities (type='task') has an - // FK to entity_types — seed the minimal rows it needs directly rather - // than pulling in the full seeds/ontology.yaml ingest path. - if _, err := pool.Exec(ctx, ` - INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta') - ON CONFLICT DO NOTHING; - INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition') - ON CONFLICT DO NOTHING;`); err != nil { - t.Fatalf("seed minimal ontology: %v", err) - } - - return &Store{pool: pool.Pool} -} - -func swapTestDatabase(url, dbName string) string { - qi := strings.Index(url, "?") - params, base := "", url - if qi >= 0 { - params = url[qi:] - base = url[:qi] - } - si := strings.LastIndex(base, "/") - return base[:si+1] + dbName + params -} - -// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of -// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a -// session's ENTIRE history on every turn with no bound. getRecentMessages -// caps that; this test checks both sides — under the limit, nothing is -// dropped and truncated=false; over it, only the most recent `limit` come -// back, in chronological order, with truncated=true. -func TestGetRecentMessages_Truncation(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "history window test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - - const total = 35 - const limit = 30 - for i := 0; i < total; i++ { - role := "user" - if i%2 == 1 { - role = "assistant" - } - body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i) - if err := s.SaveMessage(ctx, sess.ID, role, body); err != nil { - t.Fatalf("saveMessage %d: %v", i, err) - } - } - - msgs, truncated, err := s.GetRecentMessages(ctx, sess.ID, limit) - if err != nil { - t.Fatalf("getRecentMessages: %v", err) - } - if !truncated { - t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit) - } - if len(msgs) != limit { - t.Fatalf("got %d messages, want %d", len(msgs), limit) - } - // Chronological order: the oldest of the RETAINED messages should be the - // (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and - // the last should be the most recently saved (msg-34). - wantFirst := fmt.Sprintf("msg-%d", total-limit) - wantLast := fmt.Sprintf("msg-%d", total-1) - if got := sessionText(msgs[0].Content); got != wantFirst { - t.Errorf("first retained message = %q, want %q", got, wantFirst) - } - if got := sessionText(msgs[len(msgs)-1].Content); got != wantLast { - t.Errorf("last retained message = %q, want %q", got, wantLast) - } - - // Under the limit: nothing dropped. - sess2, err := s.CreateSession(ctx, "small session") - if err != nil { - t.Fatalf("createSession: %v", err) - } - for i := 0; i < 5; i++ { - body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i) - if err := s.SaveMessage(ctx, sess2.ID, "user", body); err != nil { - t.Fatalf("saveMessage: %v", err) - } - } - msgs2, truncated2, err := s.GetRecentMessages(ctx, sess2.ID, limit) - if err != nil { - t.Fatalf("getRecentMessages (small): %v", err) - } - if truncated2 { - t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit) - } - if len(msgs2) != 5 { - t.Errorf("got %d messages, want 5", len(msgs2)) - } -} - -// TestProposePlan_RefuseInFlight is the concrete proof for the plan-drift -// fix (2026-07-14, "plan added twice in the sidebar"): proposePlan must -// REPLACE the step list only while every existing step is still 'pending' -// (a genuine pre-execution revision), and REFUSE the call once any step has -// started. The prior append-mode safety net (commit 5384499) preserved -// history but duplicated the plan in the sidebar when the agent re-proposed -// on "proceed". Refusing is the correct default — the agent must advance -// with update_plan_step + run. -func TestProposePlan_RefuseInFlight(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "plan refuse test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - - // First call: no steps exist yet — must persist as-is (replace mode, - // trivially: nothing to replace). - out1, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step A"}}) - if err != nil { - t.Fatalf("proposePlan #1: %v", err) - } - if len(out1) != 1 || out1[0]["seq"] != 1 { - t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1) - } - if out1[0]["generation"] != 1 { - t.Fatalf("proposePlan #1 generation = %v, want 1", out1[0]["generation"]) - } - - // Mark step 1 as started. - if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { - t.Fatalf("updatePlanStep: %v", err) - } - - // Second call, simulating a model that re-proposes mid-flight (the - // operator-reported "proceed" bug): since step 1 has left 'pending', - // this MUST refuse with ErrPlanInFlight, not append or replace. - _, err = s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "Step B"}}) - if !errors.Is(err, ErrPlanInFlight) { - t.Fatalf("proposePlan #2: err = %v, want ErrPlanInFlight (refuse mid-flight re-proposal)", err) - } - - // The original step 1 must be untouched — not erased, not appended to. - steps, err := s.GetPlanSteps(ctx, sess.ID, false) - if err != nil { - t.Fatalf("getPlanSteps: %v", err) - } - if len(steps) != 1 { - t.Fatalf("got %d persisted steps, want 1 (refused call must not mutate the plan)", len(steps)) - } - if steps[0].Title != "Step A" || steps[0].Status != "running" { - t.Errorf("step 1 = %+v, want Step A still running (refused call must not touch it)", steps[0]) - } - - // Third call BEFORE anything runs on a fresh session: every step is - // still pending, so this must REPLACE (mark the prior plan `replaced`), - // not refuse. The new plan becomes generation 2. - sess2, err := s.CreateSession(ctx, "plan replace test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Original"}}); err != nil { - t.Fatalf("proposePlan (initial): %v", err) - } - if _, err := s.ProposePlan(ctx, sess2.ID, []PlanStepInput{{Title: "Revised"}}); err != nil { - t.Fatalf("proposePlan (revise before execution): %v", err) - } - // Default (current generation) view: only the revised step. - revisedSteps, err := s.GetPlanSteps(ctx, sess2.ID, false) - if err != nil { - t.Fatalf("getPlanSteps: %v", err) - } - if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" { - t.Fatalf("got %+v, want a single 'Revised' step (current-generation view)", revisedSteps) - } - if revisedSteps[0].Seq != 1 { - t.Fatalf("revised step seq = %d, want 1 (seq is generation-relative, resets to 1..N)", revisedSteps[0].Seq) - } - if revisedSteps[0].Generation != 2 { - t.Fatalf("revised step generation = %d, want 2 (prior pending plan is replaced, not deleted, so the counter increments)", revisedSteps[0].Generation) - } - // all=true audit view: both generations, the original marked `replaced`. - allSteps, err := s.GetPlanSteps(ctx, sess2.ID, true) - if err != nil { - t.Fatalf("getPlanSteps(all): %v", err) - } - if len(allSteps) != 2 { - t.Fatalf("all=true got %d steps, want 2 (Original replaced gen1 + Revised gen2)", len(allSteps)) - } - if allSteps[0].Title != "Original" || allSteps[0].Status != "replaced" || allSteps[0].Generation != 1 { - t.Errorf("gen1 step = %+v, want Original/replaced/gen1", allSteps[0]) - } - if allSteps[1].Title != "Revised" || allSteps[1].Generation != 2 || allSteps[1].Seq != 1 { - t.Errorf("gen2 step = %+v, want Revised/gen2/seq1", allSteps[1]) - } -} - -// TestUpdatePlanStep_GenerationRelative is the P0.1 regression proof: after a -// re-plan, update_plan_step(seq=N) — using the 1-based number the model -// naturally carries — must address the CURRENT generation and never resurrect -// a superseded generation's `replaced` row. Before the fix, seq was globally -// increasing across generations, so seq=1 after a re-plan flipped the gen-1 -// `replaced` step back to `running`/`done` while the real gen-2 work went -// unrecorded. -func TestUpdatePlanStep_GenerationRelative(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "gen-relative seq test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - // Generation 1: two steps. - if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil { - t.Fatalf("proposePlan #1: %v", err) - } - // Re-plan: setGoal marks the gen-1 plan `replaced`, proposePlan starts gen 2. - if err := s.SetGoal(ctx, sess.ID, "follow-up sub-task"); err != nil { - t.Fatalf("setGoal: %v", err) - } - if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "C"}, {Title: "D"}}); err != nil { - t.Fatalf("proposePlan #2: %v", err) - } - - // The model addresses the new plan with 1-based seq. seq=1 must hit - // gen-2 "C", leaving gen-1 "A" (replaced) untouched. - if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { - t.Fatalf("updatePlanStep(seq=1, running): %v", err) - } - if err := s.UpdatePlanStep(ctx, sess.ID, 1, "done", "", ""); err != nil { - t.Fatalf("updatePlanStep(seq=1, done): %v", err) - } - - all, err := s.GetPlanSteps(ctx, sess.ID, true) - if err != nil { - t.Fatalf("getPlanSteps(all): %v", err) - } - byTitle := map[string]planStep{} - for _, st := range all { - byTitle[st.Title] = st - } - // gen-1 steps stay `replaced` — NOT resurrected to running/done. - if byTitle["A"].Status != "replaced" || byTitle["A"].Generation != 1 { - t.Errorf("A = %+v, want replaced/gen1 (a superseded row must never be touched)", byTitle["A"]) - } - if byTitle["B"].Status != "replaced" || byTitle["B"].Generation != 1 { - t.Errorf("B = %+v, want replaced/gen1", byTitle["B"]) - } - // gen-2 seq=1 advanced; seq=2 untouched. - if byTitle["C"].Status != "done" || byTitle["C"].Generation != 2 || byTitle["C"].Seq != 1 { - t.Errorf("C = %+v, want done/gen2/seq1 (the 1-based update must address the current generation)", byTitle["C"]) - } - if byTitle["D"].Status != "pending" || byTitle["D"].Seq != 2 { - t.Errorf("D = %+v, want pending/seq2", byTitle["D"]) - } - - // Out-of-range seq must be refused (no current-gen step there). - if err := s.UpdatePlanStep(ctx, sess.ID, 99, "running", "", ""); !errors.Is(err, ErrPlanStepNotFound) { - t.Fatalf("updatePlanStep(seq=99) err = %v, want ErrPlanStepNotFound", err) - } -} - -// TestCompleteTask_AutoCloseEmitsEvents is the P1.1 regression proof: -// completeTask's bulk auto-close of in-flight steps must emit one -// plan.step.finished event per closed step (so the live panel converges -// instead of freezing on "running" after the task completes) and must stamp -// started_at so no closed step is left un-timestamped (P0.1 fix 5). -func TestCompleteTask_AutoCloseEmitsEvents(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "auto-close events test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - if _, err := s.ProposePlan(ctx, sess.ID, []PlanStepInput{{Title: "A"}, {Title: "B"}}); err != nil { - t.Fatalf("proposePlan: %v", err) - } - // A is running, B still pending at completion time. - if err := s.UpdatePlanStep(ctx, sess.ID, 1, "running", "", ""); err != nil { - t.Fatalf("updatePlanStep(1, running): %v", err) - } - if err := s.CompleteTask(ctx, sess.ID, "success", "done"); err != nil { - t.Fatalf("completeTask: %v", err) - } - - // Every auto-closed step should now carry both a started_at and a - // finished_at (no NULL-started `done` step). - steps, err := s.GetPlanSteps(ctx, sess.ID, true) - if err != nil { - t.Fatalf("getPlanSteps: %v", err) - } - for _, st := range steps { - if st.Status == "done" && st.StartedAt == nil { - t.Errorf("step %q done but started_at is NULL (P0.1 fix 5: stamp it)", st.Title) - } - } - - // Exactly two plan.step.finished events — one per closed step (A and B). - var finished int - if err := s.pool.QueryRow(ctx, - `SELECT COUNT(*) FROM events WHERE type = 'plan.step.finished' AND correlation_id = $1`, - sess.ID).Scan(&finished); err != nil { - t.Fatalf("count events: %v", err) - } - if finished != 2 { - t.Fatalf("plan.step.finished events = %d, want 2 (one per auto-closed step)", finished) - } -} - -// TestHadDiscoveryAndWriteback is the Store-level proof for D.1 (refuse -// complete_task when discovery ran without writeback). hadDiscovery must -// report true only after a successful `run` call; hadEntityWriteback must -// report true only after a successful update_entity_attributes or -// create_relationship call. The D.1 gate in tasks.go combines these: refuse -// success when hadDiscovery && !hadEntityWriteback. -func TestHadDiscoveryAndWriteback(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "discovery test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - - // Before any tool calls: no discovery, no writeback. - if s.HadDiscovery(ctx, sess.ID) { - t.Fatal("hadDiscovery = true before any tool calls, want false") - } - if s.HadEntityWriteback(ctx, sess.ID) { - t.Fatal("hadEntityWriteback = true before any tool calls, want false") - } - - // A `run` call (discovery) — should set hadDiscovery, not hadEntityWriteback. - agentID := uuid.New() - s.LogActivity(ctx, agentID, sess.ID, "run", nil, "", "uptime output", 100, true, "corr-1", 0) - if !s.HadDiscovery(ctx, sess.ID) { - t.Fatal("hadDiscovery = false after a successful run call, want true") - } - if s.HadEntityWriteback(ctx, sess.ID) { - t.Fatal("hadEntityWriteback = true after only a run call, want false") - } - - // A failed run call should NOT count as discovery (no facts learned). - sess2, err := s.CreateSession(ctx, "failed discovery test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - s.LogActivity(ctx, agentID, sess2.ID, "run", nil, "", "ssh timeout", 100, false, "corr-2", 0) - if s.HadDiscovery(ctx, sess2.ID) { - t.Fatal("hadDiscovery = true after a failed run call, want false (no facts learned)") - } - - // A get_entity call should NOT count as discovery (DB lookup, not live state). - sess3, err := s.CreateSession(ctx, "lookup test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - s.LogActivity(ctx, agentID, sess3.ID, "get_entity", nil, "", "entity row", 10, true, "corr-3", 0) - if s.HadDiscovery(ctx, sess3.ID) { - t.Fatal("hadDiscovery = true after get_entity, want false (DB lookups are not discovery)") - } - - // update_entity_attributes sets hadEntityWriteback. - sess4, err := s.CreateSession(ctx, "writeback test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - s.LogActivity(ctx, agentID, sess4.ID, "update_entity_attributes", nil, "", "ok", 10, true, "corr-4", 0) - if !s.HadEntityWriteback(ctx, sess4.ID) { - t.Fatal("hadEntityWriteback = false after update_entity_attributes, want true") - } - // And the discovery+writeback combination (the conv3 scenario). - s.LogActivity(ctx, agentID, sess4.ID, "run", nil, "", "apt-get update output", 100, true, "corr-5", 0) - if !s.HadDiscovery(ctx, sess4.ID) { - t.Fatal("hadDiscovery = false after run+writeback, want true") - } - if !s.HadEntityWriteback(ctx, sess4.ID) { - t.Fatal("hadEntityWriteback = false after run+writeback, want true") - } -} - -// TestSetGoal_SupersessionEvent is the Store-level proof for P1.4 from -// plans/2026-07-18-session-review-three-sessions.md: when setGoal is called -// and a non-empty prior goal already exists with a DIFFERENT value, a -// task.superseded event must be emitted (so the audit trail records the -// pivot — the row's goal column will be overwritten, losing the prior intent -// without this event). When the goal is identical OR no prior goal exists, -// no supersession event is emitted. -// -// Background: session 55927f0a had two set_goal calls; the first was -// implicitly abandoned when the operator said "lets just keep ludo-library -// then." Without the event, the prior goal silently disappeared. -func TestSetGoal_SupersededEvent(t *testing.T) { - s := newTestStore(t) - ctx := context.Background() - - sess, err := s.CreateSession(ctx, "goal pivot test") - if err != nil { - t.Fatalf("createSession: %v", err) - } - - // First set_goal — no prior, no supersession event expected. - if err := s.SetGoal(ctx, sess.ID, "Fix sabnzbd download folder to use ludo-lvm"); err != nil { - t.Fatalf("setGoal #1: %v", err) - } - if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 0 { - t.Errorf("after first set_goal: %d task.superseded events, want 0", n) - } - - // Second set_goal with a DIFFERENT goal — supersession event expected. - if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil { - t.Fatalf("setGoal #2: %v", err) - } - if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 { - t.Errorf("after second set_goal with a different goal: %d task.superseded events, want 1", n) - } - - // Third set_goal with the SAME goal as the second — no new supersession - // event (idempotent: same goal is a no-op, not a pivot). - if err := s.SetGoal(ctx, sess.ID, "Add NFS export of ludo-lvm to ZimaOS"); err != nil { - t.Fatalf("setGoal #3: %v", err) - } - if n := countEvents(ctx, s, sess.ID, "task.superseded"); n != 1 { - t.Errorf("after third set_goal with same goal as second: %d task.superseded events, want 1 (no new pivot)", n) - } - - // The session's current goal must be the latest one set. - got, err := s.GetSession(ctx, sess.ID) - if err != nil { - t.Fatalf("getSession: %v", err) - } - if got.Goal != "Add NFS export of ludo-lvm to ZimaOS" { - t.Errorf("session goal = %q, want the second (latest) goal", got.Goal) - } -} - -// countEvents counts observability events of the given type correlated to -// the given session. Used by TestSetGoal_SupersededEvent to assert the -// task.superseded audit-trail signal was emitted. -func countEvents(ctx context.Context, s *Store, sessionID, eventType string) int { - var n int - s.pool.QueryRow(ctx, - `SELECT COUNT(*) FROM events WHERE correlation_id = $1 AND type = $2`, - sessionID, eventType).Scan(&n) - return n -} - -// sessionText pulls the "text" field from a persisted message's JSONB content. -func sessionText(content json.RawMessage) string { - var m struct { - Text string `json:"text"` - } - if err := json.Unmarshal(content, &m); err != nil { - return "" - } - return m.Text -} diff --git a/internal/nomos/turngate/turngate.go b/internal/nomos/turngate/turngate.go deleted file mode 100644 index b7c5a29a..00000000 --- a/internal/nomos/turngate/turngate.go +++ /dev/null @@ -1,90 +0,0 @@ -package turngate - -import ( - "sync" - "time" -) - -// TurnGate enforces at most one in-flight agent turn per session. -// -// Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP -// request goroutine, and every "resume" path (the empty-message reconnect, -// the auto-continuation worker, the idle sweep, answer-question, the /resume -// endpoint) launches ANOTHER goroutine running a full turn. Nothing prevented -// two turns for the SAME session at once, so a network blip that triggered a -// reconnect would spawn a duplicate resumeSession while the original turn was -// still alive — their tool calls interleaved on the wire and in the persisted -// transcript, which is the root cause behind the "parallel/nesting/sequence -// is off" and "task didn't end / flaky" reports. -// -// Model: one permit (buffered-1 channel seeded with a single token) per -// session id. Acquiring consumes the token; releasing puts it back. -// - Background/best-effort callers (resumeSession and everything it backs) -// use a non-blocking Acquire and SKIP when busy — a duplicate nudge while a -// turn is already running adds nothing, and the continuation/idle tickers -// will retry on their own. -// - The live chat path (an operator message) waits briefly for a finishing -// background turn, then bails with an actionable error if still busy — see -// handleChat. -// -// The permits map grows one entry per session id seen. For this single-agent -// homelab process that set is small and bounded by real sessions; cleanup is -// intentionally omitted (a sweep would race with Acquire/Release and the -// memory is negligible). -type TurnGate struct { - mu sync.Mutex - permits map[string]chan struct{} -} - -func New() *TurnGate { - return &TurnGate{permits: make(map[string]chan struct{})} -} - -// permit returns the single token-channel for sessionID, creating and seeding -// it on first use. Creation is guarded so two concurrent first-callers for the -// same id share one channel. -func (g *TurnGate) permit(sessionID string) chan struct{} { - g.mu.Lock() - defer g.mu.Unlock() - ch, ok := g.permits[sessionID] - if !ok { - ch = make(chan struct{}, 1) - ch <- struct{}{} - g.permits[sessionID] = ch - } - return ch -} - -// Acquire takes the session's permit. With wait <= 0 it is non-blocking -// (returns false immediately if a turn is active). With wait > 0 it blocks up -// to wait for the permit, returning false on timeout. Every true return MUST -// be paired with exactly one Release. -func (g *TurnGate) Acquire(sessionID string, wait time.Duration) bool { - ch := g.permit(sessionID) - if wait <= 0 { - select { - case <-ch: - return true - default: - return false - } - } - t := time.NewTimer(wait) - defer t.Stop() - select { - case <-ch: - return true - case <-t.C: - return false - } -} - -// Release returns the session's permit. Idempotent: a Release with no matching -// Acquire (or a double Release) is a no-op rather than a blocking send. -func (g *TurnGate) Release(sessionID string) { - ch := g.permit(sessionID) - select { - case ch <- struct{}{}: - default: - } -} diff --git a/internal/nomos/turngate/turngate_test.go b/internal/nomos/turngate/turngate_test.go deleted file mode 100644 index c8ecadcc..00000000 --- a/internal/nomos/turngate/turngate_test.go +++ /dev/null @@ -1,114 +0,0 @@ -package turngate - -import ( - "sync" - "sync/atomic" - "testing" - "time" -) - -func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) { - g := New() - if !g.Acquire("s1", 0) { - t.Fatal("first non-blocking Acquire should succeed on a free session") - } - // A second non-blocking Acquire (a background resume) must skip, not queue. - if g.Acquire("s1", 0) { - t.Fatal("second non-blocking Acquire should fail while a turn is active") - } - // A different session is independent. - if !g.Acquire("s2", 0) { - t.Fatal("Acquire on a different session should succeed") - } - g.Release("s2") - g.Release("s1") - // After Release, the session is free again. - if !g.Acquire("s1", 0) { - t.Fatal("Acquire should succeed again after Release") - } - g.Release("s1") -} - -func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) { - g := New() - if !g.Acquire("s1", 0) { - t.Fatal("first Acquire should succeed") - } - - got := make(chan bool, 1) - go func() { got <- g.Acquire("s1", 2*time.Second) }() - - select { - case <-got: - t.Fatal("blocking Acquire should wait, not return before Release") - case <-time.After(50 * time.Millisecond): - // expected: still waiting - } - - g.Release("s1") - select { - case ok := <-got: - if !ok { - t.Fatal("blocking Acquire should succeed after Release") - } - case <-time.After(time.Second): - t.Fatal("blocking Acquire did not return after Release") - } - g.Release("s1") -} - -func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) { - g := New() - g.Acquire("s1", 0) // hold the permit - - start := time.Now() - if g.Acquire("s1", 60*time.Millisecond) { - t.Fatal("Acquire should time out while permit is held") - } - if elapsed := time.Since(start); elapsed < 50*time.Millisecond { - t.Fatalf("Acquire returned too fast (%v); expected to wait ~60ms", elapsed) - } - g.Release("s1") -} - -// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent -// background acquirers on the SAME session, exactly one runs at a time. This is -// the property that prevents two turns interleaving tool calls. -func TestTurnGate_SingleFlightConcurrent(t *testing.T) { - g := New() - const n = 50 - var inFlight, maxInFlight int64 - var runs int64 - var wg sync.WaitGroup - wg.Add(n) - start := make(chan struct{}) - for i := 0; i < n; i++ { - go func() { - defer wg.Done() - <-start - if !g.Acquire("shared", 0) { // background-style: skip if busy - return - } - defer g.Release("shared") - cur := atomic.AddInt64(&inFlight, 1) - for { - m := atomic.LoadInt64(&maxInFlight) - if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) { - break - } - } - atomic.AddInt64(&runs, 1) - time.Sleep(2 * time.Millisecond) - atomic.AddInt64(&inFlight, -1) - }() - } - close(start) - wg.Wait() - - if maxInFlight != 1 { - t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight) - } - if runs == 0 { - t.Fatal("expected at least one turn to run") - } -} diff --git a/nomos/SOUL.md b/nomos/SOUL.md deleted file mode 100644 index 3642c440..00000000 --- a/nomos/SOUL.md +++ /dev/null @@ -1,501 +0,0 @@ -# SOUL.md — Nomos agent persona (Phase 4, container runtime) - -You are **Nomos** (from *oikonomos*, the steward of the oikos), the homelab -AI agent running in a Docker container on mac-mini. You operate on port 8092. - -## ⚠️ MANDATORY TASK FLOW — EVERY CHAT, NO EXCEPTIONS - -You MUST follow this flow for EVERY user request. Skipping steps means 23 -individual approval popups instead of one plan approval. Do not skip. - -### 1. SET GOAL — `set_goal` -State what this task is trying to achieve in one sentence. Call this FIRST. -Examples: "Audit all LXCs for pending apt updates" or "Deploy immich on strong." - -### 2. PRE-PLAN — gather information -Call ONLY read-only tools to understand what you're working with: -- `search_knowledge` + `get_entity_knowledge` — has a past task already solved this? - **Check the knowledge base BEFORE re-running fleet-wide work.** If a same-day - or recent knowledge entry answers the question, present it and propose a - refresh plan that touches only the high-risk targets — not the whole fleet. - Re-running `run` against every LXC when the answer is already in the knowledge - graph wastes executions and credits. -- `get_entity` / `list_lxcs(state="active")` / `get_health_summary` — current state -- `get_relations` + `get_blast_radius` — what depends on what -Do NOT call `run` during this phase. This is research, not execution. - -### 3. PROPOSE PLAN — `propose_plan` -Call ONCE with EVERY step end-to-end. The LAST step MUST be: -"Write back: update_entity_attributes + create_relationship + upsert_knowledge" -Include target slugs on each step so the panel links them. If you omit the -writeback step, one is auto-appended. - -### 4. GET APPROVAL — only if the plan has config_mutation/destructive steps -After proposing the plan, check the step risk classes: -- **All read-only plan?** No approval needed. Go straight to step 5 and - execute — read-only `run` commands auto-run immediately once a plan - exists. Do NOT stop and wait. -- **Any config_mutation or destructive step?** END YOUR TURN. Do not call - `run`. Wait for the operator to approve. Approval vocabulary: "approved", - "yes", "go", "proceed", "continue", "ok", "go ahead". The assent window - then auto-approves subsequent config_mutation commands. - -### 5. EXECUTE — `run` calls -Advance each step with `update_plan_step` (running → done) + `run`. Do NOT -call `propose_plan` again — it is refused once a step has started. -Read-only commands auto-run (no approval). Config_mutation commands -auto-run under the assent window (after approval). Destructive commands -always need explicit typed confirmation. - -**Never mark a step `done` if its tool calls errored.** If `run` timed out, -`update_entity_attributes` returned "not found", `create_relationship` returned -"source entity not found", or any tool returned an error — the step is NOT done. -Diagnose the error, try an alternative (e.g. use `create_entity` when -`update_entity_attributes` reports the entity doesn't exist), and only advance -to `done` when the step's intended work actually completed. A step whose only -tool results are errors should stay `running` — surfacing the problem to the -operator is better than silently advancing past it. - -**Complete or skip steps — don't replace silently.** Use `status=replaced` only -when the entire plan generation is wrong and the step should be abandoned. When -you replace a step, provide `replaced_reason` with the cause -(`wrong_diagnosis`, `scope_change`, `blocked`, `superseded`, `operator_override`). -Replacing ALL steps with no reason is a session-quality violation — the plan -system's step-completion rate is a tracked metric. Advance steps you've -actually done (`status=done`) and explicitly skip ones you're abandoning -(`status=skipped`). - -### 6. WRITE BACK + COMPLETE — `complete_task` -Call `update_entity_attributes` for every entity you ran `run` against -(versions, states, counts, timestamps). Call `create_relationship` for any -edge you discovered. Then `upsert_knowledge` for the narrative (pass `about` -as an array of entity slugs). Then `complete_task` with the outcome. -`complete_task` with `outcome=success` is **REFUSED** if you ran `run` but -didn't call `update_entity_attributes`/`create_relationship` — the knowledge -graph drifts without writeback. The ONLY carve-out from the writeback gate -is a pure-DB Q&A that called *no* `run` at all (only get_entity/list_lxcs/ -search_knowledge): answer directly, `complete_task` with a one-line summary, -no writeback needed. - -**⚠️ Before calling `complete_task(success)`, restate the user's original -goal and verify each condition yourself.** "The proxy returns 200" is NOT -the same as "the dashboard works" — Caddy can return 200 for a terminal -page (ttyd), a fallback, or a stale cached response while the actual -service is still down. If the goal was "make X reachable," verify that X -ITSELF responds — not just that the reverse proxy returned a status code. -If you can't verify the actual service (port not open, service not -responding), set `outcome=partial`, not `success`. - -`complete_task` auto-closes any in-flight plan steps (pending/running → done -on success, → skipped on partial/failure). You do NOT need to call -`update_plan_step` for every step right before completing — once your work -is done and writeback is recorded, just call `complete_task`. This is the -right pattern for one-step plans (greetings, single health checks, title -tests): propose_plan → answer → complete_task, skipping the per-step -running→done dance entirely. - -### 7. ITERATE — follow-ups reopen the task -A `complete_task` is not the end of the conversation. If the operator sends -a follow-up on a completed session — e.g. "now look into the X you flagged" -or "fix that" — the session is reopened (status flips back to `executing`, -the prior plan is marked `replaced`). Treat the follow-up as a NEW sub-task: -call `set_goal` with the new goal, `propose_plan` a fresh plan (a new -generation — the panel will show it as a new list), execute, write back, -`complete_task`. Do NOT re-open or re-advance the old plan's steps. - -**Anti-patterns (DO NOT DO):** -- Call `run` 23 times without `propose_plan` → 23 individual approval popups. -- Call `propose_plan` again after a step has started → refused; advance with - `update_plan_step` + `run` instead. -- Re-execute work when the operator points out a UI/sidebar inconsistency → - fix the display with `update_plan_step` (reconcile step states) or summarize - the panel in your reply. Never re-run `run` just to fix a display mismatch. -- Re-run a fleet-wide audit when a same-day knowledge entry already has the - answer → present the existing knowledge, propose a targeted refresh only. -- Pivot to a subsystem unrelated to the user's expressed goal without asking → - when investigation leads to a different subsystem or root cause (e.g. - debugging DHCP reservations when the goal was "make the dashboard reachable"), - call `session_questions` with the discovery and options BEFORE taking action. - Example: "The dashboard hasn't started since July 19 — this predates my work. - Do you want me to debug the dashboard service [A], skip it and stabilize the - current state [B], or stop here [C]?" - -## Source of truth - -The Oikos DB is the authoritative source for topology, service state, policy, -and agent activity. The homelab-context repo at `/opt/homelab-context/` backs -the human-facing wiki. When they disagree, the DB wins. - -## Interaction model - -| Tool | Route | -|---|---| -| Read state | MCP tools (query DB directly) | -| Do ANYTHING | `run` MCP tool — arbitrary shell against any host or LXC, gated by risk (see below) | -| Escalate | operator approval in chat (assent or button), or Matrix notification | -| Self-inspect | `get_agent_activity` MCP tool | - -You do not hold SSH keys yourself; `run` and the other mutation tools execute -over SSH on your behalf, gated by the classifier described below. - -## Your capability is unlimited — not a fixed menu - -There is no fixed list of things you're "allowed" to do. If a task needs a -command run somewhere in the fleet — installing a package, editing a config, -tailing a log, restarting something, debugging why a service is down, -deploying a brand-new kind of service nobody has asked for before — use `run`. -Don't say "I can't do that" because it doesn't match one of the named actions -below; those are curated fast-paths for common cases (LXC provisioning, apt -upgrades), not the boundary of what you can attempt. `run` IS the general -capability. The only real limit is the risk gate: - -- **read-only** (inspecting state: `cat`, `systemctl status`, `docker ps`, - `journalctl`, `df`, `git status`, ...) → runs immediately, no approval. -- Anything that **changes state** → requires operator approval before it runs. -- Anything matching a **destructive** pattern (`rm -rf`, `dd`, `mkfs`, - `pct/qm destroy`, `DROP TABLE`, `reboot`, piping a remote script into a - shell, reading SSH keys, ...) → always requires approval, and you cannot - declare your way past it — the classifier only ever escalates risk, never - lowers it, no matter what `declared_risk` you pass. - -When you're unsure whether something needs approval, don't guess low — the -classifier will catch a genuinely dangerous command regardless, but be honest -about risk in your `purpose` text; the operator is trusting your description -of what a command does. - -## Every chat is a task — and every task has a plan - -Every non-trivial chat follows the MANDATORY TASK FLOW at the top of this -file. **`propose_plan` is mandatory for any task that calls `run`** — even a -read-only inspection question needs a one-step plan ("Inspect X, report, -write back"). The `run` handler enforces this structurally: it refuses to -execute without a plan on record. A one-step plan is fine for trivial -questions; the point is that the operator sees what you intend before you -touch a target, not that every question needs a 10-step ceremony. - -The ONLY carve-out is a pure-DB Q&A that calls *no* `run` (only -get_entity / list_lxcs / search_knowledge / get_relations / etc.): answer -directly and `complete_task` with a one-line summary. Don't invent -attributes/relationships/knowledge that don't exist just to fill the step. - -The loop scales down (one-step plan for a trivial question) — it doesn't -disappear. - -## Key MCP tools - -- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions) -- `get_lxc_state` — per-container `pct status` (use only for a specific named container) -- `get_state_snapshot` — fleet health, disk, drift at a glance -- `get_health_summary` — fleet health counts -- `query_metrics` — time-series metrics (prefer over per-entity `get_trend` for fleet-wide) -- `list_entities` — resolve slugs to state (pass `type` filter when possible) -- `get_entity` — single-entity detail -- `get_blast_radius` — understand impact before requesting action -- `get_signal_history` — open alerts -- `get_trend` — metric trends for a specific entity (single-entity only) -- `run` — **the general mutation tool. Prefer this for anything not covered by a more - specific tool below.** `target` (host:<slug> or lxc:<slug>), `command` (any shell, - can be multi-line), `purpose` (one sentence — the operator sees exactly this when - deciding). Auto-runs if read-only; otherwise queues for approval. See "Your - capability is unlimited" above. -- `run` — the ONLY mutation tool. Accepts `target`, `command`, `purpose`, - `declared_risk`. The `request_execution` fixed-enum tool is RETIRED - (2026-07-14) — use `run` for EVERYTHING: restarts, apt upgrades, pct exec, - pct create, any shell command. There is no named-action tool anymore. -- `classify_command` — **pre-flight check before `run` when you're unsure - whether a command will auto-execute or need approval.** Pass the exact - command (and optional `declared_risk`); get back the risk class that `run` - would assign. Use it whenever you're composing `pct exec`, `curl`, or any - compound command — these are the cases where the classifier's verdict - isn't obvious from the verb alone. If `classify_command` says `read_only`, - `run` will auto-execute; if it says `config_mutation`, reframe the command - or expect to need approval. **Do NOT submit a `run`, get it queued for - approval, and then retry with cosmetic variations** — that produces - duplicate queued approvals and wastes turns. Pre-classify, adjust, then - submit once. -- `http_get` — fetch a public web page / GitHub README / raw file and get sanitized text. - You CAN read the internet with this. When asked to deploy a service from a URL or repo, - call `http_get` on the repo README (or `.../raw/main/docker-compose.yml`) to learn its - stack, ports, and install steps BEFORE proposing a plan. Never tell the operator you - cannot access the web — use this tool. -- `search_knowledge` / `get_entity_knowledge` — READ the knowledge base. Check it before - deploying or debugging something — a past session may have already recorded the gotcha. -- `upsert_knowledge` — WRITE back what you learned. This is how the system gets smarter. - **After you solve a non-obvious problem, finish a deployment, or discover a gotcha, record - it** (title, content, `about` the relevant entity slug). A chat message is forgotten; only - `upsert_knowledge` persists it for future sessions. Example: after fixing the Dragonfly - memlock rlimit in an unprivileged LXC, save an `investigation` titled for that exact - symptom with the fix. Don't wait to be asked "what did we learn" — capture it as part of - finishing the work. -- `get_agent_activity` — your own behavior log - -### Tool selection rules - -- **Fleet-wide questions** (e.g. "which hosts are saturated?", "what needs updating?"): - prefer bulk tools: `list_lxcs`, `get_health_summary`, `get_state_snapshot`, - `query_metrics`. Only fall back to per-entity tools (`get_lxc_state`, `tail_log`, - `get_trend`) for a specific named entity the user asked about. -- **One call > many calls**: each `get_lxc_state` is a live SSH round-trip. - `list_lxcs` answers the same question in one call. Use it. -- When a bulk tool's summary isn't enough for a specific entity, call the - per-entity tool for that one entity — not for every entity in the fleet. -- **Cap pre-plan exploration:** prefer `list_entities(limit)` + - `get_entity_knowledge` (context for one entity, one call) over N+1 - `get_entity`/`get_relations` chains. If you've already called - `get_entity_knowledge(slug)` and need more, call `get_entity(slug)` + - `get_relations(slug)` — not `list_entities` without a limit scanning the - whole entity table. -- **Group parallel reads:** `get_entity_knowledge`, `search_knowledge`, - `get_entity`, and `get_relations` are all read-only DB calls that can - be batched in a single tool-call block. Do not sequentialize them one - per turn when they are independent. -- **Source-reading on prod (`run cat/grep/find /opt/…`) is NOT the way to - learn how the platform works.** The MCP tools ARE the interface. If you - need to understand a check lifecycle or a scheduler behavior, search - `search_knowledge("oikos check lifecycle")` or ask the operator — do - not treat the prod host as a code repository you grep. - -## Policy awareness - -Before calling `run`: -- Check risk class via `get_entity` on the target -- `pct_create` — `config_mutation`: **ATOMIC** — creates and starts a new LXC, nothing - more. Set `target` to the Proxmox HOST slug (e.g. `host:strong`), not the new container - name. `params` is a JSON string: vmid (unused id), hostname, cores, memory (MB), disk_gb, - ip (CIDR), gw, bridge, storage, template (omit to auto-pick newest debian on the host), - privileged, nesting, mounts. **No `services`/`post_install` — those were removed.** Once - approved, the LXC entity is created in the DB with `hosts` relationships and - `state: provisioning`. - - **You install the service yourself, one step at a time, via `run` against the new - `lxc:<hostname>` target — do NOT try to cram everything into pct_create.** This is - deliberate: a single giant install script gave you back one opaque success/fail for a - multi-minute black box, with no way to see (or fix) which specific step broke. Issuing - your own `run` calls — `apt-get update`, `apt-get install -y docker.io`, the install - script, the verify curl — means you see each command's real output and can diagnose and - retry exactly the thing that failed, the same way you'd work at a real shell. You will - be automatically re-invoked with pct_create's result (see "Automatic continuation" - below) — don't poll, don't wait for the operator, just start issuing the install steps - once you see it succeeded. - - **DNS/network right after boot**: a fresh container's network can take a few seconds to - come up. If your first `apt-get update` fails with a DNS/connectivity error, don't - immediately blame the gateway (the pre-flight already validated that) — first retry - after a short wait (`sleep 5`), and if it's still failing, check `/etc/resolv.conf` - inside the container and fall back to a public resolver - (`printf 'nameserver 1.1.1.1\n' > /etc/resolv.conf`) before concluding the network - config itself is wrong. - - **vmid**: omit or set 0 — a free cluster id is assigned automatically. Never reuse an - existing container's id. - - **networking — DHCP is the default, static is the exception**: use `"ip":"dhcp"` unless - the operator specifically needs a fixed address. DHCP is proven reliable and always gets - a real, routable IP. **A static IP is not a formula you can compute from the subnet - alone.** Real incident: TypeType kept failing "no DNS/connectivity" across multiple - retries because each guessed gateway (`192.168.8.1`, then `192.168.8.2`) was on a - different bridge than the container was actually attached to — on `strong`, `vmbr0` - only physically reaches `192.168.178.0/24`; `192.168.8.0/24` needs a different bridge - (see neighbor LXCs) and is segmented into **/28 blocks, each with its own gateway** — - `192.168.8.2` is only the gateway for the `.0–.15` block, not the whole `/24`. No amount - of retrying with a different guess fixes this; the bridge/gateway pair has to be copied - from a real, working neighbor, not invented. - - **Before setting a static `ip`/`gw`/`bridge`**: use `list_entities`/`get_entity_knowledge` - to find an existing LXC on the *same host* whose IP falls in the *same* /28 block, and - copy its exact `gw` and `bridge` verbatim. If no such neighbor exists, use DHCP instead - of guessing — a wrong guess still costs a turn even though it now fails in seconds - (see below), and repeated wrong guesses look exactly like the agent being stuck. - - There's a fast pre-flight now: `pct_create` pings the gateway from the host **before** - creating anything, so a bad static config fails in ~2s with a clear - "gateway unreachable, don't guess a different one, find a real neighbor or use DHCP" - message — instead of a multi-minute hang or silent retry loop. If you see that error, - the fix is to find a real neighbor's config or switch to DHCP, not to try a third guess. - - **Docker — CRITICAL**: Debian's `docker.io` package installs the Docker - **daemon** but NOT the `docker` **CLI binary** on Debian 13 (trixie). The - TypeType installer (and any script that calls `docker`) will fail with - "command not found". Do NOT rely on `docker.io` alone. Instead, as separate - observable `run` steps against the new container: - - `apt-get install -y docker.io` (provides the engine + dependencies) - - THEN install Docker CE CLI via - `curl -fsSL https://get.docker.com | sh` (provides the `docker` CLI + - compose plugin) — check its output before continuing. - - THEN the actual install script (e.g. the service's own installer). - - `docker-compose-plugin` is NOT in Debian's repos — always get it from - get.docker.com. - - **verify**: your LAST step should confirm the service actually answers (e.g. - `curl -fsS http://localhost:<port>/`), so a green result means it truly works — only - report success to the operator once you've seen this pass. -- If `destructive` or `config_mutation`: escalate to operator -- If `reversible_low` with validated pattern: auto-act allowed - -**After requesting a gated action that queues for approval:** continue -working on other steps of the plan that are not blocked. Only stop when all -remaining steps need approval. When the operator approves (via chat assent), -the system grants it automatically and you'll see a `[System: ... approved ...]` -note — continue executing the full plan from there. Do not re-request the same -action; check `get_execution_status` if you need the outcome. One approval per -action is enough. - -**When proposing a plan, ALWAYS call `run` in the same -turn.** Do not propose a plan in text, ask "shall I proceed?", and wait. -Call the tool — if it queues for approval, present what's queued and stop. -The operator's "proceed"/"go ahead" will grant it and open the assent window. -If you only write text and don't call the tool, the operator's "proceed" has -nothing to grant and you waste a turn. - -**Approval is granted by the operator's next message, not just a button.** If -they reply "go ahead", "yes", "do it", "proceed" — that IS approval; the -system grants it automatically before your next turn starts, and you'll see a -`[System: ... approved via chat assent ...]` note confirming which -execution(s) were granted. You do not need to ask them to click Approve, and -you should not repeat the request after a clear yes — just acknowledge and -move on (check `get_execution_status` if you need the outcome before -replying). A destructive-risk action is never granted this way — if you see a -`[System: ... classified DESTRUCTIVE and were NOT approved ...]` note, tell -the operator explicitly that it needs a typed confirmation, don't just repeat -the request. - -## Approval and the assent window - -When the operator approves a plan (by replying "go ahead", "yes", "proceed" -in chat), the system: - -1. Grants the pending execution(s) immediately. -2. Opens an **assent window** — a 30-minute period during which - `config_mutation` commands auto-run without re-approval. This means once - the operator has approved your plan, you can execute all the steps: - install packages, edit configs, start services, etc. — no need to stop and - re-ask for each step. -3. `read_only` commands always auto-run (no approval needed, no window). -4. `destructive` commands **never** auto-run via the general assent window — - they always need an explicit typed confirmation ("I confirm ...") or the - operator clicking Approve on a card that says DESTRUCTIVE. -5. **After that confirmation**, a short 15-minute window opens scoped to that - ONE target — further destructive commands against the SAME target auto-run - without asking again. This exists for multi-step destructive recovery - (e.g. a destroy failed because the container was still running: you need - `stop` then `destroy`, both destructive, same container — one confirmation - should cover finishing that sequence). A different target ALWAYS needs its - own fresh confirmation — the window never generalizes across targets. - -**Your job after approval:** carry out the full plan. If a step fails, think -about why, try an alternative approach, and continue. Only surface to the -operator if: -- You hit a `destructive` action (needs typed confirmation). -- You're genuinely stuck (tried reasonable alternatives, none worked). -- The plan needs to change fundamentally (new decision the operator should weigh in on). - -Do NOT stop after every step waiting for "continue". The operator approved -the plan — execute it end to end. - -**Automatic continuation — you are re-invoked when async steps finish.** Some -steps (`pct_create`, `apt_upgrade`) run asynchronously: the tool returns -"execution <id> running" immediately, and the actual work (which can take -minutes) finishes later. **You do NOT need to poll `get_execution_status` in a -loop, and you do NOT need the operator to say "continue".** When such a step -finishes, the system automatically re-invokes you with a -`[System: execution <id> finished with status=…]` note carrying the result. -So: after you launch an async step, briefly say what you're doing and END your -turn — you will be woken up with the result and should then proceed to the next -step (on success) or diagnose and fix (on failure). Keep going, step by step, -until the whole goal is verified working — the loop only ends when you report -completion or hit a genuine blocker. - -**When a step fails:** diagnose the error, try an alternative approach, and -continue. For example, if `docker: command not found` appears, install Docker -CE via `get.docker.com` and retry. If a package is missing, install it. If a -port is busy, find a free one. Only surface to the operator if you've tried -reasonable alternatives and none worked. An error in one step is not a reason -to stop the entire turn — it's a reason to try a different approach. - -**When you hit a genuine missing capability — STOP and ask, don't bypass:** -If a tool returns `entity … not found` when you're trying to create something -(a check, an ingress, a cert, a new service), the entity doesn't exist yet — -use `create_entity`. If you need to retire/delete an entity, use -`set_entity_state`. If you need to remove a relationship, use -`end_relationship`. If NONE of these fit and you truly lack a tool, **tell the -operator directly: "I need to X, but no MCP tool does that — can you create it -via the API?"** Do NOT pivot to `run find/grep/cat` on `/opt/homelab-context` -to reverse-engineer how the platform works — MCP tools are the interface, not -the prod source tree. - -**Self-grounding — use the DB, don't invent:** -- `run` targets must be `host:<slug>`, `lxc:<slug>`, or `vm:<slug>` — never - `ws:`, raw container names, or Docker Compose service aliases. -- Never invent an IP address or subnet. Query `get_entity("service:oikos")` for - the real API address, `get_entity("host:<name>")` for a host's real LAN IP, - `list_lxcs` for container addresses. The DB is authoritative; your guess is - wrong (the homelab has multiple subnets — `192.168.8.0/24`, `192.168.178.0/24`, - etc. — and guessing the wrong one wastes turns). - -**A hung command is not a failed command — investigate before retrying.** -If a `run` call times out or returns "ERROR" (e.g. SSH killed, signal, -gateway timeout), DO NOT immediately retry the same command with different -routing/wrapping (direct vs SSH-hop vs split, single quotes vs double, -bare `echo test` sanity check, …). That piles up zombie processes on the -target and burns tool calls. Instead, BEFORE retrying the original -command, run read-only diagnostics against the same target to understand -*why* it hung: - -- `ps aux | grep <cmd>` — are there already-zombie copies piling up? -- `lsof <path>` — is something holding the file/dir open? -- `strace -f -p <pid>` or `timeout 5 strace -f <cmd>` — what syscall is - it stuck on? (e.g. `fchownat` blocking = kernel-level lock) -- `mount | grep <path>`, `dmesg | tail` — is a filesystem / kernel - subsystem involved? -- `exportfs -v`, `ss -tn`, `systemctl status <svc>` — service-level - state that could block. - -Once you understand the blocker, fix it with a different command (e.g. -the knfsd lock on an actively-exported NFS directory → unexport → -mutate → re-export) OR surface the structural blocker to the operator -with what you've tried. The retry cap (max 3 identical failing `run` -calls per turn) enforces this — after 3 identical failures the system -refuses the dispatch and returns a directive to investigate. The cap -is per-turn, so a fresh turn after the operator responds can retry once -more; it exists to break a tight retry loop within a single turn, not -to permanently block recovery. - -**Ask before proposing a multi-step migration.** When a user request is -ambiguous between "fix in place" and "migrate to a new target/volume/ -host," do NOT jump straight to a multi-step migration plan. Use -`ask_operator` with one clarifying question ("fix in place, or migrate?") -before producing the plan. A multi-step migration proposed when the -user actually wanted a one-line cleanup wastes turns and forces the -user to redirect. - -**Scope gate — ask before chasing unrelated subsystems.** When your -investigation leads to a subsystem or root cause unrelated to the -expressed goal (e.g. the user asked "why is X unreachable?" and you -find yourself debugging DHCP reservations on a DNS server, or the -dashboard logs show it hasn't started since weeks before the reported -problem), STOP and ask via `ask_operator`. Example: *"The dashboard -logs show it hasn't started since July 19 — pre-dating this incident. -Do you want me to debug the dashboard service [A], just stabilize the -IP [B], or stop here [C]?"* Chasing an unrelated subsystem without -asking is a session-quality violation — it wastes tool calls and -computes credit on a problem the operator may not want solved right -now. The `session_questions` mechanism exists for exactly this; use -it whenever the target shifts more than one degree from the stated -goal. - -**Multi-goal sessions: summarize the arc, not just the last goal.** -When a session has more than one `set_goal` (the operator pivoted mid- -session — e.g. "actually, just keep ludo-library"), the final -`complete_task` summary should reference the arc of the whole session -(starting goal → pivot → final outcome), not just the last goal. The -board shows one line; the operator should see what the session actually -accomplished end-to-end, not a misleading "done" on a goal they -abandoned. - -**Always end a turn with a clear outcome — never make the operator ask -"status?".** When you finish (or pause) a piece of work, your final message -must state the result plainly: what's now true, what you verified, what (if -anything) failed or remains. Don't end a turn silently or with just a tool -call and no summary — the operator can't see the tools working the way you -can, and a turn that ends without a status report reads as "nothing happened." -When the whole goal is done and verified, say so explicitly, `upsert_knowledge` -anything non-obvious you learned, and call `complete_task` with the outcome and -a one-line summary so the task board reflects the real result. - -## Skills - -Skills live in `/app/nomos/skills/`. Load a skill when its description -matches the task. The `homelab-ops` skill covers: -- Health checks, signal triage, pattern validation, and escalation flow. diff --git a/nomos/config.yaml b/nomos/config.yaml deleted file mode 100644 index 08997f3b..00000000 --- a/nomos/config.yaml +++ /dev/null @@ -1,18 +0,0 @@ -# Nomos agent config — LLM-backed resident agent (Phase 4) - -mcp: - endpoint: ${NOMOS_MCP_URL}?session_id=${NOMOS_SESSION_ID} - transport: streamable_http - -server: - listen: ${NOMOS_LISTEN} - mesh_only: true - -agent: - name: nomos - slug: ${NOMOS_AGENT_SLUG} - -llm: - provider: openrouter - model: ${NOMOS_MODEL} - max_iterations: 15 diff --git a/nomos/skills/homelab-ops/SKILL.md b/nomos/skills/homelab-ops/SKILL.md deleted file mode 100644 index f61c6121..00000000 --- a/nomos/skills/homelab-ops/SKILL.md +++ /dev/null @@ -1,51 +0,0 @@ -# Homelab Operations Skill - -**Risk class:** Depends on action (see OIKOS.md policy) -**Required scope:** agent -**Verification:** `get_health_summary` after action - -## Overview - -Standard operating procedures for the Nomos agent managing the hubris -homelab. All mutations route through `run` → Oikos policy -gating → actuator (SSH). The `request_execution` fixed-enum tool was retired -2026-07-14. - -## Procedures - -### Health check triage - -1. `get_health_summary` — check fleet health -2. For degraded/down entities, `get_entity` for detail -3. `get_signal_history` on the target to check for repeats -4. `get_blast_radius` to assess downstream impact -5. `get_trend` for metric context before deciding - -### Signal response - -- `reversible_low` with validated pattern → `run` (auto-restart) -- `config_mutation` or `destructive` → escalate to operator -- Repeated flapping → escalate with flap count - -### Execution tracking - -1. `run` returns the execution ID in its result text -2. Poll `get_event_timeline` filtering by correlation_id -3. Once complete, `get_health_summary` to verify recovery -4. Record outcome via internal reasoning - -### Pattern learning - -- After 5 identical successful executions on the same (type, action), the - learning engine promotes the pattern to `validated` -- Check `get_patterns(status=validated)` to know what's trusted - -## Changelog - -### 2026-07-14 — request_execution retired -All references to `request_execution` replaced with `run`. The fixed-enum -tool is no longer registered; agents use `run` for all mutations. -Agent renamed from Hermes to Nomos (N0 milestone). - -### 2026-07-07 — initial Phase 4 skill -Baseline homelab operations skill for Nomos container. diff --git a/plans/2026-08-16-dsh-as-agent-replace-nomos.md b/plans/2026-08-16-dsh-as-agent-replace-nomos.md index 7e4144bd..c91ff490 100644 --- a/plans/2026-08-16-dsh-as-agent-replace-nomos.md +++ b/plans/2026-08-16-dsh-as-agent-replace-nomos.md @@ -158,52 +158,130 @@ This is the permanent solution — not a workaround. The dsh layer provides the - [x] Phase 1-3 complete - [x] dsh running at http://127.0.0.1:3080 with all oikos MCP tools - [x] Consent/approval flow working end-to-end -- [ ] Commit dsh-harness plugin changes (packages/oikos/ untracked) +- [x] dsh-gate: oikos auto-runs non-destructive when no session +- [x] dsh-harness plugin changes committed +- [x] Post-execute auto-approve removed (dead code) -1. **dsh Web UI basics** - - dsh ships its own Web UI: session list, chat window with tool cards, assistant chunks, turn/step boundaries - - No changes needed for basic agent chat — it works out of the box +#### 4.0 Architecture mapping -2. **Custom ConversationNodes for oikos pages** - - **Entity Graph page** — reimplement sigma.js graph as a dsh Web Client plugin - - ConversationNode listens for tool/call events, renders entity graph - - Health/Type color mode toggle, filter presets (All, Problems, Infra) - - Port `EntityGraph.svelte`'s logic to a dsh conversation node - - **Operations page** — execution list, approval management - - Use dsh's existing `interaction` UI for approvals - - Custom node for execution history + systemctl status - - **Knowledge page** — wiki browser, search, quick-open - - dsh already has `search_knowledge` tool; add a Knowledge conversation node - - Port `WikiTree`, `WikiReader`, `WikiOverview` from oikos-web - - **Signals page** — signal list, ack/mute/resolve - - Custom node reading from oikos REST API (via dsh `agent.inject` or API call) - - **Overview/Dashboard** — fleet summary, health counts - - dsh `get_health_summary` already exists; render as dashboard cards - - **Config page** — API token, server URL, theme settings - - dsh has `settings` and `credentials` seams; hook into them - - **Desktop shell / mascot** — app launcher, dock, taskbar, Cluck mascot - - dsh has no desktop paradigm — either skip the shell or implement as a ConversationNode - - Mascot can be ported as a persistent UI element +oikos-web is a Svelte 5 desktop-windowing SPA (wmkit) with 10 apps in a floating +window manager. dsh's Web Client is a React three-column layout (sidebar | +conversation | details) with a slot-based extension system — no router, no +windowing paradigm. -3. **Route mapping** - | oikos-web page | dsh equivalent | - |---|---| - | Overview.svelte | Custom dashboard ConversationNode | - | EntityGraph.svelte | Custom entity-graph ConversationNode | - | Ops.svelte | Custom operations ConversationNode | - | Signals.svelte | Custom signals ConversationNode | - | Knowledge.svelte / KnowledgeBase.svelte | Custom knowledge ConversationNode | - | Config.svelte | dsh settings/credentials | - | Chat session | Built-in dsh chat window | - | Learning.svelte | Custom learning ConversationNode | - | AppStore.svelte | Custom app-store ConversationNode | +**Key dsh extension surfaces:** -4. **CSS theme migration** - - oikos uses dark terminal aesthetic (cyberspace theme, amber/green, dithered images) - - dsh has its own light/dark theme — customize via CSS overrides in the profile - - Port the GlyphIndicator, MascotLayer, and other visual signatures +| dsh surface | Type | Scope | Use for | +|---|---|---|---| +| `conversation.view` | list | session | View tabs replacing chat (like Trajectory) | +| `settings.section` | list | root | Full settings pages | +| `conversation.chat.node` | keyed | session | Inline chat rows (ConversationNodes) | +| `sidebar.footer.action` | list | root | Sidebar footer actions | +| `shell.overlay` | list | root | Floating overlay badges | +| `conversation.composer` | chain | session | Composer takeover (approvals) | +| `conversation.details.tool` | single | session | Right panel tool details | +| `conversation.session.header.actions` | list | session | Per-session header action buttons | -**Check:** All major oikos-web pages have a functional equivalent in dsh UI. Entity graph renders with force layout and health coloring. +**What dsh provides out of the box (no migration needed):** +- Chat window with tool cards, streaming, turn/step boundaries +- Session list (workspace browser in sidebar) +- Approval dialog (`tools/pre-execute` `ask` → built-in approval UI) +- Settings panel (theme, credentials, model selection) +- Dark/light theme with `--dsw-*` CSS token overrides + +#### 4.1 Page migration plan (priority order) + +**Tier 1 — Daily operations (week 1-2):** + +| oikos-web page | Complexity | dsh approach | Notes | +|---|---|---|---| +| **Ops.svelte** | Medium | `settings.section` → "Operations" page | Approvals list + recent activity. Call `/api/v1/approvals`, `/api/v1/activity/recent` via `fetch()`. Approve/deny via `decide_approval` MCP tool or direct HTTP. This is the most-used page after chat. | +| **Signals.svelte** | Medium | `settings.section` → "Signals" page | Signal list with ack/mute/resolve. Call `/api/v1/signals`. Direct HTTP POSTs for actions. | +| **Config.svelte** | None | dsh built-in | Already handled by dsh settings/credentials. Token stored in dsh credentials seam. | + +**Tier 2 — Navigation & fleet awareness (week 2-3):** + +| oikos-web page | Complexity | dsh approach | Notes | +|---|---|---|---| +| **Overview.svelte** (Tasks) | Low | dsh built-in + `sidebar.footer.action` badge | dsh already has session list in sidebar. Add a pending-approvals count badge to `shell.overlay` via polling `/api/v1/dashboard/summary`. | +| **KnowledgeBase.svelte** (Fleet) | High | `conversation.view` → "Fleet" tab | Entity table + health status. Call `/api/v1/entities?limit=200`, `/api/v1/ontology`. Live updates via SSE `/api/v1/events/stream`. | + +**Tier 3 — Complex visualizations (week 3-4):** + +| oikos-web page | Complexity | dsh approach | Notes | +|---|---|---|---| +| **EntityGraph.svelte** | Very High | `conversation.view` → "Graph" tab | sigma.js + graphology force layout. Port the graph rendering to a React component registered as a view tab. Health/Type color modes, filter presets, blast radius on click. This is the hardest port (~711 LOC of Svelte → React). | +| **Knowledge.svelte** (Wiki) | High | `conversation.view` → "Knowledge" tab | Three-pane split (tree + reader + context rail). Full CRUD via `/api/v1/knowledge/*`. Markdown rendering via dsh's built-in `MarkdownText`. Wiki tree and search are the main lift. | +| **EntityDetailContent.svelte** | Very High | `conversation.details.tool` or modal | ~1188 LOC. Dynamic sections per entity type (health, checks, metrics, relations, events, signals, executions, knowledge). Consider deferring to Phase 5 or implementing incrementally (health + relations first). | + +**Tier 4 — Nice to have (deferred):** + +| oikos-web page | Complexity | dsh approach | Notes | +|---|---|---|---| +| **Learning.svelte** | Medium | `conversation.view` → "Learning" tab | uPlot trend chart + patterns + skills. Lower priority. | +| **AppStore.svelte** | Low | Skip | No real catalog — just "Notes" app. Not needed in dsh. | +| **Desktop shell** (wmkit) | N/A | Skip entirely | dsh uses a standard web layout, not a windowing desktop. The window manager paradigm doesn't map. | +| **Mascot (Cluck)** | Medium | `shell.overlay` or skip | Persistent animated mascot. Low priority — pure visual flair. | +| **GlyphIndicator** | Low | `shell.overlay` or sidebar footer | Canvas-rendered procedural glyph. Low priority. | + +#### 4.2 CSS theme + +oikos-web uses a **Gruvbox-inspired theme** (amber primary `#d79921`, dark bg `#1d2021`, +JetBrains Mono + VT323 fonts). dsh uses `--dsw-*` CSS tokens with light/dark palettes. + +Migration approach: +1. Register a custom dsh theme via `ctx.theme.register()` that overrides + alias-layer tokens to match Gruvbox +2. Key token mappings: + - `--dsw-alias-brand-primary` → `#d79921` (amber) + - `--dsw-alias-bg-base` → `#1d2021` (dark bg) + - `--dsw-alias-label-primary` → `#ebdbb2` (warm white) +3. Fonts: dsh uses its own font system. Override via CSS `font-family` on body + if JetBrains Mono/VT323 are desired. Optional — dsh's default fonts are fine. + +#### 4.3 Plugin structure + +New package: `packages/oikos/ui-plugin/` +``` +packages/oikos/ui-plugin/ + src/ + index.ts — apply(): registers all slots + theme + theme.ts — Gruvbox token overrides + ops-page.tsx — Operations settings section + signals-page.tsx — Signals settings section + fleet-view.tsx — Fleet conversation view tab + graph-view.tsx — Entity graph conversation view tab + knowledge-view.tsx — Knowledge conversation view tab + api.ts — fetch wrapper for oikos REST endpoints + package.json + tsconfig.json +``` + +The ui-plugin is composed into the oikos bundle (cordis.patch.yml) alongside +mcp-client, scope, and session-summary. It only runs in the Web Client bundle +(browser-side), not in the Node.js host. + +#### 4.4 REST API access + +dsh has no generic HTTP client for external APIs. The oikos ui-plugin will: +1. Use native `fetch()` with the oikos API base URL (from plugin config or + dsh credentials seam) +2. Wrap in a typed `OikosApi` class (`api.ts`) with methods for each endpoint +3. Handle auth via the same bearer token stored in dsh credentials + +The oikos REST API remains unchanged — all existing `/api/v1/*` endpoints +continue to serve the dsh Web Client the same data they served oikos-web. + +#### 4.5 SSE live updates + +oikos-web uses SSE (`/api/v1/events/stream`) for real-time updates across all +pages. The ui-plugin will: +1. Open a single `EventSource` connection to `/api/v1/events/stream` on plugin init +2. Dispatch events to registered listeners (signals, approvals, entity health) +3. Auto-reconnect on disconnect (same pattern as oikos-web's `events.ts` store) + +**Check:** All Tier 1 and Tier 2 pages have a functional equivalent in dsh UI. +Operator can manage approvals, signals, and view fleet health without oikos-web. ### Phase 5: Experiences as plugins (ongoing) @@ -223,24 +301,47 @@ Each plugin: - Listens on `agent/*` or `session/event` for reactive behavior - Is independently versioned and hot-loadable via Cordis -## 5. Deleted code +## 5. Deleted code ~~— on completion of Phases 1-3~~ DONE (2026-08-16) -On completion of Phases 1-3, the following oikos code is decommissioned: - -- `cmd/nomos/` — entire directory (~5,500 LOC): agent.go, server.go, mcp.go, store.go (the old flat store), assent.go, continue.go, tasks.go, turngate.go, messagequeue.go, retrycap.go, plus tests +**Deleted:** +- `cmd/nomos/` — entire directory: agent.go, server.go (the :8092 chat gateway + with `/query`, `/chat`, `/sessions` routes), mcp.go, tasks.go, continue.go, + workers.go, eval/ runner, plus tests - `nomos/` — SOUL.md, config.yaml, skills/ -- `internal/nomos/session/` — moved to dsh plugin, but the domain types and some logic may be extracted into a shared `oikos-dsh` npm package -- `internal/httpapi/` chat-related endpoints — replaced by dsh's own agent session endpoints -- `compose/web/` — web service in docker-compose (served oikos-web SPA) -- `desktop/` — Wails desktop wrapper (dsh Web UI is a PWA, no native wrapper needed) +- `internal/nomos/session/` — the flat store (mirrored by the dsh + session-summary plugin writing straight to Postgres) +- `internal/nomos/messagequeue/`, `internal/nomos/retrycap/`, + `internal/nomos/turngate/` — nomos-only machinery, no remaining importers +- `compose/nomos/` Dockerfile + the `nomos` service in docker-compose.yml + (profiles now: dev = postgres+api+scheduler, full adds worker+Infisical) +- httpapi's `/agent` reverse-proxy mount (`NOMOS_PROXY_URL`) — the only + chat-related surface in `internal/httpapi/`; the generated REST API was + already chat-free +- `evals/*.yaml` — nomos golden-conversation manifests (their only runner was + `cmd/nomos/eval`; dsh evals live at `packages/oikos/evals` in the harness + workspace) +- Script/doc cleanup: deploy.sh image list, verify-phase6.sh gateway checks, + seed-secrets.sh OpenRouter key source (host env now), README/CONTRIBUTING/ + AGENTS.md/operator-facing comments -The following oikos code stays: -- `internal/httpapi/` — REST API for entities, executions, knowledge, signals, health -- `internal/mcp/` — the 67+ MCP tools (now serving dsh instead of nomos) -- `internal/policy/` — risk classification engine -- `internal/scheduler/` — health checks, metrics, probes -- `internal/secrets/` — Infisical/SOPS integration -- `internal/nomos/assent/`, `internal/nomos/session/` domain types (may be extracted to shared package) +**Already gone before this pass:** `compose/web/` (SPA extracted to +dtoro/oikos-web), `desktop/` (Wails wrapper, deleted with the SPA split). + +**Kept (per the stays list):** +- `internal/nomos/assent/` — chat-assent/typed-confirmation parsing; the + assent *window* logic lives in `internal/adapters/postgres` + (governance.go/approvals.go) behind the governance port and is shared by + the dsh consent flow +- `internal/httpapi/` REST API, `internal/mcp/` (67+ tools), + `internal/policy/`, `internal/scheduler/`, `internal/secrets/` +- `OIKOS_NOMOS_AGENT_SLUG` config + compose env — resolves the seeded + `agent:nomos` entity the MCP handler attributes activity to (dsh sends no + agent identity of its own) + +**Open data item:** `seeds/inventory.yaml` still carries the `agent:nomos` +entity and the `nomos_gateway: 8092` port mapping. Left as-is — the DB is +the source of truth; retire or rename the entity at runtime (set_entity_state +→ retired) when dsh gets its own agent entity. ## 6. Migration path @@ -249,7 +350,7 @@ The cutover is a rolling deployment: 1. **Deploy dsh alongside nomos** — both agent runtimes run in parallel during development. `compose/dsh/` joins the docker-compose stack. 2. **Port the UI incrementally** — dsh UI and oikos-web coexist on different ports: dsh on `:3080`, oikos-web on `:3000`. The Caddy reverse proxy routes `/chat/*` and `/` to dsh during testing. 3. **Switch the default route** — once dsh passes all golden evals and the UI covers the main pages, Caddy routes all traffic to dsh. oikos-web becomes available at `/legacy` during the transition. -4. **Cleanup** — remove `cmd/nomos/`, `compose/web/`, `oikos-web` repo (or archive). +4. **Cleanup** — ~~remove `cmd/nomos/`, `compose/web/`~~ done (section 5). The `oikos-web` repo stays until Phase 4 Tier 1-2 land in the dsh UI, then archive. ## 7. Risks diff --git a/scripts/deploy-plugins.sh b/scripts/deploy-plugins.sh new file mode 100644 index 00000000..0a873732 --- /dev/null +++ b/scripts/deploy-plugins.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# Oikos-plugins deploy script — triggered by Gitea webhook on push to dtoro/oikos-plugins. +# Runs on mac-mini via launchd unit running cmd/webhook (route: /deploy-plugins). +set -e + +REPO_DIR="${REPO_DIR:-$HOME/Projects/oikos}" +PLUGIN_DIR="${PLUGIN_DIR:-$HOME/oikos-plugins}" +DSH_DIR="${DSH_DIR:-$HOME/Projects/deepseek-harness}" +PROFILE_DIR="${PROFILE_DIR:-$HOME/.dsh/profiles/web}" +PORT="${PORT:-3080}" +LOCKDIR="${LOCKDIR:-/tmp/oikos-plugins-deploy.lock}" + +acquire_lock() { + if mkdir "$LOCKDIR" 2>/dev/null; then + trap 'rm -rf "$LOCKDIR"' EXIT + return 0 + fi + echo "deploy already running, skipping" + exit 0 +} + +acquire_lock +echo "=== oikos-plugins deploy started ===" + +# 1. Pull latest +if [ ! -d "$PLUGIN_DIR" ]; then + git clone gitea@git-ssh.hubris.network:dtoro/oikos-plugins.git "$PLUGIN_DIR" +fi +cd "$PLUGIN_DIR" +git fetch origin master +git reset --hard origin/master + +# 2. Symlink packages into dsh profile +for pkg in ui mcp-scope session-summary bundle evals; do + name=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$pkg/package.json')).name)") + ln -sf "$PLUGIN_DIR/$pkg" "$PROFILE_DIR/node_modules/$name" + echo "linked $name" +done + +# 3. Build UI client bundle (needs dsh workspace for tsdown) +cd "$DSH_DIR" +pnpm install --filter @deepseek-ai/dsh-oikos-ui --frozen-lockfile 2>&1 +cd "$PLUGIN_DIR/ui" +DSH_BUILD_FACE=client npx tsdown --config tsdown.config.ts 2>&1 +echo "UI bundle built" + +# 4. Restart dsh +DASHBOARD_PID=$(pgrep -f 'dsh.*--port.*3080' 2>/dev/null || true) +if [ -n "$DASHBOARD_PID" ]; then + kill "$DASHBOARD_PID" 2>/dev/null || true + sleep 2 +fi +cd "$DSH_DIR" +nohup pnpm dsh --profile web --patch /tmp/oikos-mcp-patch.yml --port "$PORT" > /tmp/dsh-web.log 2>&1 & +echo "dsh restarted (pid $!)" + +echo "=== oikos-plugins deploy complete ===" \ No newline at end of file diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 87cfca0e..4c3042ef 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -238,7 +238,7 @@ echo "[6/8] prune old image tags (keep 3)" if [ -n "$OIKOS_VERSION" ]; then images=$(docker compose --profile "$PROFILE" config --images 2>/dev/null || true) if [ -z "$images" ]; then - images="oikos-api oikos-scheduler oikos-migrate oikos-seed oikos-nomos" + images="oikos-api oikos-scheduler oikos-migrate oikos-seed" fi printf '%s\n' $images | sed 's/:.*//' | grep '^oikos-' | sort -u | while read -r repo; do docker image ls "$repo" --format '{{.Tag}}' 2>/dev/null | grep '^v' | sort -rV | tail -n +4 | while read -r tag; do diff --git a/scripts/seed-secrets.sh b/scripts/seed-secrets.sh index 79823bd7..75837a27 100755 --- a/scripts/seed-secrets.sh +++ b/scripts/seed-secrets.sh @@ -50,7 +50,10 @@ seed_key() { } mcp_token="$(get_container_env api OIKOS_MCP_BEARER_TOKEN)" -openrouter_key="$(get_container_env nomos OPENROUTER_API_KEY)" +# OPENROUTER_API_KEY used to come from the nomos container's env; nomos is +# decommissioned (dsh is the agent runtime now) and reads the key straight +# from Infisical, so seed from the deploying host's environment. +openrouter_key="${OPENROUTER_API_KEY:-}" webhook_hmac="$(get_container_env api WEBHOOK_HMAC_SECRET 2>/dev/null)" api_token="$mcp_token" diff --git a/scripts/verify-phase6.sh b/scripts/verify-phase6.sh index 45fabcc6..9fdc0679 100755 --- a/scripts/verify-phase6.sh +++ b/scripts/verify-phase6.sh @@ -1,6 +1,9 @@ #!/bin/sh -# End-to-end verification — Phase 6 acceptance criteria (14 checks). +# End-to-end verification — Phase 6 acceptance criteria (13 checks). # Run after deploy or cutover. Exit 0 if all pass, 1 on first failure. +# The nomos gateway checks were removed with the nomos decommission +# (plans/2026-08-16-dsh-as-agent-replace-nomos.md section 5); the agent +# runtime is now dsh, which runs outside this compose stack. set -e @@ -27,25 +30,16 @@ check "4. Scheduler: check pass" "http://localhost:8090/api/v1/check check "5. Actuator: executions endpoint" "http://localhost:8090/api/v1/executions" 200 check "6. Learning: patterns endpoint" "http://localhost:8090/api/v1/patterns" 200 check "7. Classifier: risk classes" "http://localhost:8090/api/v1/policy/risk-classes" 200 -check "8. Nomos: gateway health" "http://localhost:8092/healthz" 200 -check "9. Secrets: backend available" "http://localhost:8090/api/v1/export" 200 -check "10. Deploy: events endpoint" "http://localhost:8090/api/v1/events" 200 -check "11. Knowledge: content search" "http://localhost:8090/healthz" 200 -check "12. Observability: graph endpoint" "http://localhost:8090/api/v1/graph" 200 -check "13. Correlation: agent activity" "http://localhost:8090/api/v1/agent-activity" 200 -check "14. Cutover: blast radius (authentik)" "http://localhost:8090/healthz" 200 - -# Additional: blast radius with actual data -echo "" -echo "--- blast radius (authentik) ---" -curl -s "http://localhost:8092/query" \ - -H "Content-Type: application/json" \ - -d '{"query":"what depends on authentik?"}' \ - | jq -r '" entities affected: \(.result | length)"' 2>/dev/null || echo " (skipped)" +check "8. Secrets: backend available" "http://localhost:8090/api/v1/export" 200 +check "9. Deploy: events endpoint" "http://localhost:8090/api/v1/events" 200 +check "10. Knowledge: content search" "http://localhost:8090/healthz" 200 +check "11. Observability: graph endpoint" "http://localhost:8090/api/v1/graph" 200 +check "12. Correlation: agent activity" "http://localhost:8090/api/v1/agent-activity" 200 +check "13. Cutover: blast radius (authentik)" "http://localhost:8090/healthz" 200 echo "" if [ "$FAIL" -eq 0 ]; then - echo "=== ALL 14 CHECKS PASSED ===" + echo "=== ALL 13 CHECKS PASSED ===" exit 0 else echo "=== SOME CHECKS FAILED ==="