14 Commits

Author SHA1 Message Date
4a96f46e76 plan: general gated execution — unlimited actions, gated by risk classifier
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Evaluate the agent's action path against the OIKOS.md design. Finding: the
intended model (unlimited runbook-driven actions gated by a risk classifier)
already exists on paper and in scaffolding, but the live agent path regressed
to a hard-coded 5-action enum that bypasses the classifier. Plan a layered
realignment: (0) one general gated `run` primitive, (1) runbooks-as-data as the
reliable fast-path, (2) learning. Chosen v1 posture: approve-most (read-only
auto-runs, all state changes gate). Incremental, each step shippable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 09:03:23 +02:00
5f888e6386 fix: set C.UTF-8 locale in provisioning script
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Fresh Debian LXCs have no locale configured, spamming "apt-listchanges:
Can't set locale" / perl warnings across every install and breaking some
packages' post-install scripts. Pin LANG/LC_ALL=C.UTF-8 (and hoist
DEBIAN_FRONTEND) at the top of the in-container bootstrap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:21:23 +02:00
f248508919 feat: robust provisioning (DNS self-heal) + live execution feedback in chat
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Production session provisioned the container but the service never installed:
apt failed with "Temporary failure resolving deb.debian.org" — a static-IP LXC
whose assigned nameserver couldn't resolve. The operator also got zero feedback:
the approval banner just sat there with no running/complete/failed status.

Backend robustness (provisionScript):
- Wait for real DNS/connectivity inside the container before apt, and self-heal
  /etc/resolv.conf to a public resolver (1.1.1.1/8.8.8.8) if the assigned one
  is dead. `set -e` after the gate so apt/post_install failures surface.
- apt-get update/install with Acquire::Retries=3.

Frontend feedback (InlineApproval):
- After approve, poll GET /executions/{id} and show live phase: submitting →
  provisioning… → provisioned successfully / execution failed (with the error).
- add getExecution() to api.ts.

Agent guidance (SOUL.md):
- omit vmid (auto-assigned), prefer dhcp, docker-compose-plugin is not in Debian
  (use docker.io + get.docker.com), end post_install with a health check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:15:21 +02:00
a1f666f68a fix: build execution result JSON via json.Marshal (was stuck at 'approved')
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
The final "UPDATE executions SET result=$::jsonb" built its payload with
fmt.Sprintf and only escaped newlines. apt/pct output contains quotes,
backslashes and control chars, so the payload was invalid JSON, the jsonb
cast failed, and the (unchecked) UPDATE was silently discarded — the
execution stayed 'approved' with a NULL result even though the LXC was fully
provisioned (verified live: vmid auto-assigned, container running, service
installed, post_install ran).

- executeApprovedAction: marshal result via json.Marshal; log UPDATE errors
- add jsonErr() helper; route all pct_create failure-path results through it
- mcp/server.go: add jsonOut() for restart/systemctl/pct_exec inline results
- regression test for JSON validity on quote/backslash/control-char output

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:58:43 +02:00
ac86302f52 fix: pct_create make vmid optional, fix dhcp+gw, longer boot settle
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Follow-ups found while verifying the approve→provision path end to end:
- vmid is now optional: the early required-field check rejected vmid:0
  before the cluster VMID guard could auto-assign a free id. Only hostname
  is required now; 0 (or a collision) resolves to `pvesh get /cluster/nextid`.
- net0: use ip=dhcp with no gateway when no static IP is given (Proxmox
  rejects gw alongside dhcp); only attach gw for a static CIDR.
- bump post-create settle to 10s so a DHCP lease is up before apt runs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:50:54 +02:00
8ed2b88495 fix: pct_create false-success, VMID collision, and stuck approval banner
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Real production failure when the operator clicked Approve in chat: nothing
provisioned, banner never cleared, execution marked completed.

Three root causes:
- sshExec swallowed non-zero exits when the command produced output, so a
  `pct create` that printed "CT 132 already exists" and failed was reported
  as success and a bogus lxc entity was registered. Now any non-zero exit
  returns an error (with output) so the execution is correctly marked failed.
- The LLM reused VMID 132 (belongs to lxc:rclone; VMIDs are cluster-wide).
  pct_create now checks in-use VMIDs via `pvesh get /cluster/resources` and
  falls back to `pvesh get /cluster/nextid` when the requested id is taken.
- InlineApproval.svelte reset its state on every prop change (done was also
  compared against the wrong string), so the banner never cleared and each
  click re-POSTed /decision. Rewritten to track outcome per executionId,
  clear on success, and block resubmits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:44:51 +02:00
b37f85ae08 fix: make Nomos actually provision LXCs from chat (pct_create + web fetch)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
Root cause of "asks permission but never acts": the approved pct_create
execution failed to parse because the LLM emitted `"privileged":0` /
`"nesting":1` (numbers) into strict `bool` fields, so the container was
never created. Compounded by a hardcoded template name (debian-13.0-1)
that no longer exists on the host, and no way for the agent to read the web.

- flexBool: accept 0/1, "true", bool for privileged/nesting (the exact prod failure)
- pct_create template pre-flight: list host cache, validate/auto-pick newest debian
- pct_create services[] + post_install: one approval provisions a working service
- new http_get MCP tool (sanitized, size-capped, SSRF-guarded) — agent can read repos/sites
- request_execution description: target=host, full JSON schema + example
- SOUL.md: agent CAN fetch the web; prefer one-step provisioning
- default model deepseek-v4-flash -> v4-pro; maxIterations 15 -> 25
- unit tests for flexBool, template resolve, pkg sanitize, HTML sanitize + SSRF block

Verified live on host:strong with a throwaway VMID 999: template auto-resolved,
container created + booted, services installed, post_install ran, then destroyed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 00:28:56 +02:00
a567930466 fix: make execution names unique, move approval bar above input
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Execution entity name now includes UUID suffix: 'pct_create on host:strong (abc12345)'
  so the (type,name) UNIQUE constraint doesn't block subsequent executions for the
  same target+action. Dedup now uses JOIN + LIKE prefix match to find only
  pending_approval executions.

- Move persistent approval bar from top of messages area to just above the
  chat input box (bottom-fixed position, above the textarea form).
2026-07-09 23:42:49 +02:00
9376dc7d89 fix: dedup request_execution, persistent approval bar, JSON payload
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Add dedup in request_execution: check entities(type,name) uniqueness before
  creating duplicate executions. Returns 'already queued' message to the LLM,
  preventing tool-calling loops.

- Fix createApproval JSON payload: use json.Marshal instead of fmt.Sprintf
  to escape params (could contain unescaped double quotes from JSON config).

- Add ON CONFLICT DO NOTHING to entity/execution inserts for dedup race safety.

- Persistent approval bar at top of Chat.svelte: aggregates pendingApprovals
  from all messages, fixed position (won't scroll away). Approve/deny/approve-all.

- Update SOUL.md: agent must STOP after queuing a gated action.

- Fix ToolCallGroup  reactivity: wasActive = (active).
2026-07-09 23:19:55 +02:00
d9683cfe29 fix: structured approvals + ToolCallGroup reactivity
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Replace text-based regex parsing in InlineApproval with structured
  pendingApprovals extracted from request_execution tool results. The tool
  result text is deterministic (not LLM-generated), making UUID extraction
  reliable regardless of how the LLM rephrases the response.

- Fix ToolCallGroup  reactivity: wasActive = active captured initial
  value. Now uses (active) so  re-runs on prop changes.

- Extract approvals in both live streaming (done event) and history loading
  for consistent behavior on resumed sessions.
2026-07-09 11:35:30 +02:00
ea62d744ed feat: pct_create action, ToolCallGroup collapse+animation, inline chat approval
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Add pct_create to request_execution (MCP) and executeApprovedAction (httpapi)
  Parses JSON config: vmid, hostname, cores, memory, disk_gb, ip, gw, storage,
  template, privileged, nesting, mounts, nameserver, searchdomain. Creates
  entity (state=provisioning), hosts relationship, entity_status on success.
  Fixes action string parsing to use Index instead of SplitN (colons in JSON).

- Rewrite ToolCallGroup.svelte: bits-ui Collapsible replaces native <details>.
  Collapsed by default. Animated header shows live tool count + running tool
  name while streaming. Auto-expands during streaming, auto-collapses on done.

- Add InlineApproval component: parses 'execution UUID queued' from agent
  response, renders Approve/Deny buttons inline in chat, calls decideApproval.

- Document pct_create in nomos/SOUL.md with params, risk class, and approval flow.

- Add session-review skill at .agents/skills/session-review/SKILL.md.

- Add plan: 2026-07-09-session-execution-and-ux-fixes.md.
2026-07-09 11:15:28 +02:00
0d29b1db81 fix: move completed signal-triggers plan to done/, add missing liveness-drift to index, add plan-consistency lint checks 2026-07-09 10:47:39 +02:00
e92a6ff7a5 fix: trash-2 icon name (lucide uses trash-2, not trash2)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
2026-07-09 10:21:22 +02:00
49c37fe8b1 fix: chat session reliability, cost, and hygiene (empty-response guard, tool truncation, delete, titles)
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Empty/refusal responses retried once, then surfaced as errors instead of silent blanks
- Chinese refusal boilerplate detected via denylist + non-ASCII heuristic
- Bulk-tool preference added to SOUL.md (list_lxcs over per-entity get_lxc_state)
- Tool results truncated to 4KB on persist; get_state_snapshot filters null-state entities
- Session delete (DELETE /sessions/{id} + confirm-on-second-click UI)
- Session titles auto-generated from assistant answer instead of raw user message
2026-07-09 10:18:06 +02:00
24 changed files with 1918 additions and 152 deletions

View File

@@ -1,16 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Lint committed docs against .agents/shared/writing-style.md. """Lint committed docs against .agents/shared/writing-style.md.
Checks two mechanical rules: Checks:
1. Banned vocabulary (significance puffers, analytical verbs, poetic nouns, 1. Banned vocabulary (significance puffers, analytical verbs, poetic nouns,
promotional adjectives, opening crutches). promotional adjectives, opening crutches).
2. Broken relative markdown links. 2. Broken relative markdown links.
3. Plan status consistency (status vs location vs index).
Prose-voice rules are not machine-checkable; this covers the parts that are. Prose-voice rules are not machine-checkable; this covers the parts that are.
Run from the repo root: python3 .agents/skills/docs-lint/lint.py [paths...] Run from the repo root: python3 .agents/skills/docs-lint/lint.py [paths...]
Exit 1 if any violation is found. Exit 1 if any violation is found.
""" """
import os, re, sys import os, re, sys, glob
BANNED = [ BANNED = [
"pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament", "pivotal", "crucial", "vital", "groundbreaking", "transformative", "testament",
@@ -33,9 +34,125 @@ def iter_md(paths):
if f.endswith(".md"): if f.endswith(".md"):
yield os.path.join(root, f) yield os.path.join(root, f)
def check_plans():
"""Check plan status consistency: active plans with 'Done' status, files
missing from index, dangling index entries, done files with wrong status."""
REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
plans_dir = os.path.join(REPO, "plans")
done_dir = os.path.join(REPO, "plans", "done")
index_path = os.path.join(plans_dir, "index.md")
if not os.path.exists(index_path):
return 0
violations = 0
STATUS_RE = re.compile(r'^\*\*Status:\*\*\s*(.+)', re.I)
# Parse index.md for active and done entries
active_files = set()
done_files = set()
current_section = None
with open(index_path) as f:
for line in f:
if line.startswith("## Active"):
current_section = "active"
continue
if line.startswith("## Done"):
current_section = "done"
continue
if current_section == "active":
m = re.search(r'\]\(([^)]+)\)', line)
if m:
active_files.add(m.group(1))
elif current_section == "done":
m = re.search(r'\]\(([^)]+)\)', line)
if m:
done_files.add(m.group(1))
# Active plans on disk (not in done/, not index.md)
disk_active = set()
for f in glob.glob(os.path.join(plans_dir, "*.md")):
name = os.path.basename(f)
if name == "index.md":
continue
disk_active.add(name)
# Done plans on disk
disk_done = set()
if os.path.isdir(done_dir):
for f in glob.glob(os.path.join(done_dir, "*.md")):
disk_done.add("done/" + os.path.basename(f))
# Check 1: active plans on disk whose internal status is Done/Implemented/Complete
for name in disk_active:
fpath = os.path.join(plans_dir, name)
with open(fpath) as f:
for line_num, line in enumerate(f, 1):
if line_num > 5:
break
m = STATUS_RE.match(line)
if m:
status = m.group(1).strip().lower()
done_keywords = ["done", "implemented", "complete", "completed"]
if any(status.startswith(kw) for kw in done_keywords):
print(f"{fpath}:{line_num}: status '{m.group(1).strip()}' — file is in plans/ but appears done; move to done/")
violations += 1
break
# Check 2: active plans on disk not in index
for name in sorted(disk_active):
if name not in active_files:
fpath = os.path.join(plans_dir, name)
print(f"{fpath}:1: not listed in plans/index.md Active table")
violations += 1
# Check 3: done plans on disk not in index
for name in sorted(disk_done):
if name not in done_files:
fpath = os.path.join(REPO, "plans", name)
print(f"{fpath}:1: not listed in plans/index.md Done table")
violations += 1
# Check 4: index entries with no file on disk
for name in sorted(active_files):
if name not in disk_active:
print(f"plans/index.md: active entry '{name}' — file not found on disk")
violations += 1
for name in sorted(done_files):
if name not in disk_done:
print(f"plans/index.md: done entry '{name}' — file not found on disk")
violations += 1
# Check 5: files in done/ whose internal status doesn't say Done
for name in disk_done:
fpath = os.path.join(REPO, "plans", name)
with open(fpath) as f:
found_status = False
for line_num, line in enumerate(f, 1):
if line_num > 5:
break
m = STATUS_RE.match(line)
if m:
found_status = True
status = m.group(1).strip().lower()
if not status.startswith("done"):
print(f"{fpath}:{line_num}: status '{m.group(1).strip()}' — file is in done/ but status is not 'Done'")
violations += 1
break
if not found_status:
print(f"{fpath}:1: file is in done/ but has no Status header")
violations += 1
return violations
def main(argv): def main(argv):
paths = argv or ["knowledge", ".agents", "operations", "investigations", "plans"] paths = argv or ["knowledge", ".agents", "operations", "investigations", "plans"]
violations = 0 violations = 0
if "plans" in paths or any(p.startswith("plans") for p in paths):
violations += check_plans()
# The style guide and this skill enumerate the banned words by definition. # The style guide and this skill enumerate the banned words by definition.
ban_exempt = ("shared/writing-style.md", "skills/docs-lint/") ban_exempt = ("shared/writing-style.md", "skills/docs-lint/")
for f in sorted(set(iter_md(paths))): for f in sorted(set(iter_md(paths))):

View File

@@ -0,0 +1,82 @@
---
name: session-review
description: "Examine a Nomos chat session, compare the user's objective with the actual outcome, identify causes of failure (missing tools, excessive tool calls, blocked actions, model behavior), and propose concrete fixes."
risk_class: reversible_low
inputs: [session_id]
---
# Session review
Analyze Nomos chat sessions from the live database, diff objectives
against outcomes, and propose fixes.
## 1. Retrieve session data
```bash
# List recent sessions
curl -s http://localhost:8092/sessions | jq '.sessions[:5]'
# Fetch one session with messages
curl -s http://localhost:8092/sessions/{session_id} | jq .
```
## 2. Classify the session
For each session determine:
| Dimension | Check |
|-----------|-------|
| Objective | What was the user trying to accomplish? |
| Outcome | Was it achieved? (read final assistant text) |
| Tool calls | Count, unique tools, redundancy (e.g., N+1 fan-out) |
| Blockers | Missing action? Missing tool? Model refusal? Empty response? |
| User frustration | Did the user need to clarify/correct/repeat? |
| Message sizes | Content blob sizes — truncation needed? |
## 3. Key failure signatures
| Signature | Root cause | Fix |
|-----------|-----------|-----|
| Agent: "I can't run X — only supports Y" | Missing action in `request_execution` | Add action in `internal/mcp/server.go` |
| Agent: "No local knowledge on that" + no web tool | Missing `http_get` / web fetch MCP tool | Add MCP tool |
| Empty assistant bubble (text="", no tools) | Model returned blank completion | Retry + error surfacing |
| Non-English boilerplate refusal | Flash-tier model degradation | Response quality guard |
| >30 tool calls per turn, same tool repeated | N+1 fan-out instead of bulk tool | Enrich bulk tools + tighten SOUL.md |
| Message >50KB in DB | Raw tool results persisted verbatim | Truncation in `store.go` |
## 4. Extract patterns across sessions
```bash
# All sessions summary
curl -s http://localhost:8092/sessions | jq -r '.sessions[] | "\(.id[:8]) \(.title[:80]) \(.created_at[:16])"'
# Message count + tool count per session
for id in $(curl -s http://localhost:8092/sessions | jq -r '.sessions[].id'); do
msgs=$(curl -s "http://localhost:8092/sessions/$id" | jq '.messages | length')
tools=$(curl -s "http://localhost:8092/sessions/$id" | jq '[.messages[].content.tool_calls | length] | add')
echo "$id $msgs msgs $tools tools"
done
```
## 5. Output format
```
Session: {id[:8]} — "{title[:60]}"
Messages: {N} ({user}/{assistant})
Tool calls: {total} across {turns} turns
Top tools: {name:count, name:count, ...}
Objective: {one-line summary}
Outcome: ✅ / ❌ / ⚠️
Blockers: {list or "none"}
Fixes needed: {concrete actions}
Severity: blocker | friction | cosmetic
```
## Related files
- `cmd/nomos/agent.go` — agent loop, tool building, response guards
- `cmd/nomos/store.go` — session + message persistence
- `internal/mcp/server.go` — all tool implementations including `request_execution`
- `web/src/lib/components/ToolCallGroup.svelte` — tool result display
- `nomos/SOUL.md` — agent persona and tool selection rules
- `plans/2026-07-09-chat-sessions-improvements.md` — prior session findings
- `plans/2026-07-09-session-execution-and-ux-fixes.md` — latest plan

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -14,7 +15,19 @@ import (
"github.com/openai/openai-go/shared" "github.com/openai/openai-go/shared"
) )
const maxIterations = 15 // maxIterations bounds one chat turn's tool-calling loop. Provisioning a
// service is a long chain (research → plan → request_execution → status), so
// 15 was too tight and turns died with "max iterations reached" mid-deploy.
const maxIterations = 25
const maxLLMRetries = 1
var refusalDenylist = []string{
"我没有相关信息",
"您可以尝试问我其它问题",
"我无法",
"抱歉,我无法",
"关于这个问题,我没有",
}
type agent struct { type agent struct {
client *mcpClient client *mcpClient
@@ -31,7 +44,10 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
apiKey := os.Getenv("OPENROUTER_API_KEY") apiKey := os.Getenv("OPENROUTER_API_KEY")
model := os.Getenv("NOMOS_MODEL") model := os.Getenv("NOMOS_MODEL")
if model == "" { if model == "" {
model = "deepseek/deepseek-v4-flash" // 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( provider := openai.NewClient(
@@ -105,14 +121,6 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
return return
} }
// Rebuild conversation context from persisted history so sessions are
// multi-turn. The current user turn is saved by the HTTP handler before
// this runs, so it is already included in the history for real sessions.
// Prior tool_use/tool_result pairs are replayed as a tool-calling
// assistant message followed by matching tool-role results, so the agent
// starts each turn already knowing what it already checked instead of
// re-querying the same tools from scratch. Ephemeral sessions (no store)
// fall back to the single incoming message.
system := a.system system := a.system
if snapshot := a.fleetSnapshot(); snapshot != "" { if snapshot := a.fleetSnapshot(); snapshot != "" {
system += "\n\n" + snapshot system += "\n\n" + snapshot
@@ -147,30 +155,54 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
Tools: tools, Tools: tools,
} }
// Stream the completion, emitting token deltas as they arrive. The var msg openai.ChatCompletionMessage
// accumulator reassembles the full message (content + tool calls) for var acc openai.ChatCompletionAccumulator
// the loop's control flow.
stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...) for attempt := 0; attempt <= maxLLMRetries; attempt++ {
acc := openai.ChatCompletionAccumulator{} acc = openai.ChatCompletionAccumulator{}
for stream.Next() { stream := a.provider.Chat.Completions.NewStreaming(ctx, params, a.reqOpts...)
chunk := stream.Current() for stream.Next() {
acc.AddChunk(chunk) chunk := stream.Current()
if len(chunk.Choices) > 0 { acc.AddChunk(chunk)
if delta := chunk.Choices[0].Delta.Content; delta != "" { if len(chunk.Choices) > 0 {
emit(agentEvent{Type: "text_delta", Data: delta, SessionID: sessionID, Iteration: i + 1}) 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 err := stream.Err(); err != nil { if attempt < maxLLMRetries {
emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID}) slog.Warn("nomos: llm stream error, retrying", "error", err, "attempt", attempt+1, "session", sessionID)
return continue
} }
if len(acc.Choices) == 0 { emit(agentEvent{Type: "error", Data: fmt.Sprintf("llm: %v", err), SessionID: sessionID})
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID}) return
return }
} if len(acc.Choices) == 0 {
if attempt < maxLLMRetries {
slog.Warn("nomos: no choices in response, retrying", "attempt", attempt+1, "session", sessionID)
continue
}
emit(agentEvent{Type: "error", Data: "no choices in response", SessionID: sessionID})
return
}
msg := acc.Choices[0].Message msg = acc.Choices[0].Message
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))
continue
}
emit(agentEvent{Type: "error", Data: "Nomos returned an empty or unusable response — please retry.", SessionID: sessionID})
return
}
}
break
}
if len(msg.ToolCalls) == 0 { if len(msg.ToolCalls) == 0 {
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID}) emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
@@ -388,6 +420,33 @@ func (a *agent) fleetSnapshot() string {
return summary 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() ([]openai.ChatCompletionToolParam, error) { func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull() defs, err := a.client.listToolsFull()
if err != nil { if err != nil {

View File

@@ -186,6 +186,15 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
"tool_calls": toolCalls, "tool_calls": toolCalls,
}) })
st.saveMessage(ctx, sessionID, "assistant", assistantMsg) st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
// Generate a meaningful title from the assistant's first answer
// instead of reusing the raw user message for every session.
if finalText != "" && sessionID != "ephemeral" {
title := truncate(finalText, 80)
if title != "" {
st.updateSessionTitle(ctx, sessionID, title)
}
}
} }
func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) { func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
@@ -220,13 +229,26 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
return return
} }
messages, err := st.getMessages(r.Context(), id) switch r.Method {
if err != nil { case http.MethodDelete:
http.Error(w, err.Error(), 500) if err := st.deleteSession(r.Context(), id); err != nil {
return http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(204)
case http.MethodGet:
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, "messages": messages})
default:
http.Error(w, "method not allowed", 405)
} }
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"session_id": id, "messages": messages})
} }
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) { func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {

View File

@@ -10,6 +10,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const maxToolResultSize = 4096
type store struct { type store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -71,10 +73,45 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
} }
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
sessionID, role, content) sessionID, role, truncateToolResults(content))
return err return err
} }
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) { func (s *store) touchSession(ctx context.Context, id string) {
if s != nil { if s != nil {
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
@@ -126,6 +163,26 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
return out, rows.Err() return out, rows.Err()
} }
func (s *store) deleteSession(ctx context.Context, id string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
return err
}
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"). // 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. // Returns uuid.Nil if the store is absent or the slug is unknown.
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {

View File

@@ -19,7 +19,7 @@ COPY nomos/ /app/nomos/
ENV NOMOS_MCP_URL=http://api:8090/mcp ENV NOMOS_MCP_URL=http://api:8090/mcp
ENV NOMOS_AGENT_SLUG=agent:nomos ENV NOMOS_AGENT_SLUG=agent:nomos
ENV NOMOS_LISTEN=:8092 ENV NOMOS_LISTEN=:8092
ENV NOMOS_MODEL=deepseek/deepseek-v4-flash ENV NOMOS_MODEL=deepseek/deepseek-v4-pro
EXPOSE 8092 EXPOSE 8092

View File

@@ -127,7 +127,7 @@ services:
NOMOS_MCP_URL: http://api:8090/mcp NOMOS_MCP_URL: http://api:8090/mcp
NOMOS_AGENT_SLUG: agent:nomos NOMOS_AGENT_SLUG: agent:nomos
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-flash} NOMOS_MODEL: ${NOMOS_MODEL:-deepseek/deepseek-v4-pro}
DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable DATABASE_URL: postgres://oikos:${OIKOS_DB_PASSWORD:-oikos_dev}@postgres:5432/oikos?sslmode=disable
ports: ports:
- "8092:8092" - "8092:8092"

View File

@@ -0,0 +1,131 @@
package httpapi
import (
"encoding/json"
"strings"
"testing"
)
// TestFlexBoolUnmarshal covers the exact production failure: the LLM emitted
// `"privileged":0` / `"nesting":1` (numbers) and the strict bool field made the
// already-approved pct_create execution fail to parse, so the LXC was never
// created.
func TestFlexBoolUnmarshal(t *testing.T) {
type cfg struct {
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
}
cases := []struct {
in string
privileged bool
nesting bool
wantErr bool
}{
{`{"privileged":0,"nesting":1}`, false, true, false}, // the prod payload
{`{"privileged":false,"nesting":true}`, false, true, false}, // canonical
{`{"privileged":"1","nesting":"0"}`, true, false, false}, // stringified
{`{"privileged":"true","nesting":"no"}`, true, false, false},
{`{}`, false, false, false}, // absent → zero
{`{"privileged":"maybe"}`, false, false, true},
}
for _, c := range cases {
var out cfg
err := json.Unmarshal([]byte(c.in), &out)
if (err != nil) != c.wantErr {
t.Fatalf("%s: err=%v wantErr=%v", c.in, err, c.wantErr)
}
if c.wantErr {
continue
}
if bool(out.Privileged) != c.privileged || bool(out.Nesting) != c.nesting {
t.Errorf("%s: got priv=%v nest=%v want priv=%v nest=%v",
c.in, bool(out.Privileged), bool(out.Nesting), c.privileged, c.nesting)
}
}
}
func TestResolveTemplate(t *testing.T) {
avail := []string{
"debian-12-standard_12.7-1_amd64.tar.zst",
"debian-13-standard_13.0-1_amd64.tar.zst",
"ubuntu-24.04-standard_24.04-2_amd64.tar.zst",
}
cases := []struct {
requested string
want string
}{
{"debian-13-standard_13.0-1_amd64.tar.zst", "debian-13-standard_13.0-1_amd64.tar.zst"}, // exact
{"debian-13", "debian-13-standard_13.0-1_amd64.tar.zst"}, // prefix
{"", "debian-13-standard_13.0-1_amd64.tar.zst"}, // auto newest debian
{"debian-99", "debian-13-standard_13.0-1_amd64.tar.zst"}, // miss prefix → auto debian
}
for _, c := range cases {
if got := resolveTemplate(c.requested, avail); got != c.want {
t.Errorf("resolveTemplate(%q): got %q want %q", c.requested, got, c.want)
}
}
if got := resolveTemplate("debian-13", nil); got != "" {
t.Errorf("empty cache should yield empty, got %q", got)
}
}
// TestJSONErrValidForNastyOutput guards the bug where command output with
// quotes/backslashes/newlines produced invalid JSON, failing the ::jsonb cast
// and silently dropping the execution's final status update.
func TestJSONErrValidForNastyOutput(t *testing.T) {
nasty := "CT 132 already exists on node \"hubris\"\n\tpath C:\\x\r\n\x00 100%"
for _, payload := range [][]byte{
jsonErr("%s", nasty),
jsonErr("list templates on %s: %s", "host:strong", nasty),
} {
var m map[string]any
if err := json.Unmarshal(payload, &m); err != nil {
t.Fatalf("jsonErr produced invalid JSON: %v\npayload=%s", err, payload)
}
if _, ok := m["error"]; !ok {
t.Errorf("missing error key: %s", payload)
}
}
}
func TestProvisionScript(t *testing.T) {
s := provisionScript([]string{"docker.io", "git"}, "echo hi > /root/x")
// Network/DNS gate must come before apt.
gate := strings.Index(s, "getent hosts")
apt := strings.Index(s, "apt-get update")
post := strings.Index(s, "echo hi > /root/x")
if gate < 0 || apt < 0 || post < 0 {
t.Fatalf("missing sections: gate=%d apt=%d post=%d\n%s", gate, apt, post, s)
}
if !(gate < apt && apt < post) {
t.Errorf("wrong ordering: gate=%d apt=%d post=%d", gate, apt, post)
}
if !strings.Contains(s, "nameserver 1.1.1.1") {
t.Error("missing DNS self-heal fallback")
}
if !strings.Contains(s, "docker.io git") {
t.Error("packages not joined into install line")
}
// No packages: no apt lines, but post_install and gate still present.
s2 := provisionScript(nil, "systemctl status foo")
if strings.Contains(s2, "apt-get install") {
t.Error("apt install should be absent when no packages requested")
}
if !strings.Contains(s2, "systemctl status foo") || !strings.Contains(s2, "getent hosts") {
t.Error("post_install or gate missing in no-package case")
}
}
func TestSanitizePkgs(t *testing.T) {
in := []string{"docker.io", "git", "rm -rf /", "curl;wget", "python3-pip", ""}
got := sanitizePkgs(in)
want := map[string]bool{"docker.io": true, "git": true, "python3-pip": true}
if len(got) != len(want) {
t.Fatalf("got %v want keys %v", got, want)
}
for _, g := range got {
if !want[g] {
t.Errorf("unexpected package survived sanitize: %q", g)
}
}
}

View File

@@ -3,11 +3,13 @@ package httpapi
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
@@ -27,6 +29,26 @@ var (
_sshKey []byte _sshKey []byte
) )
// flexBool accepts a JSON bool, number (0/1), or string ("true"/"1"/"yes").
// LLMs routinely emit `"privileged": 0` instead of `false`; a strict `bool`
// field made the approved pct_create execution fail to parse *after* the
// operator had already approved it — the container was never created and the
// operator saw "queued" with no result. This type tolerates the common shapes.
type flexBool bool
func (b *flexBool) UnmarshalJSON(data []byte) error {
s := strings.TrimSpace(strings.Trim(string(data), `"`))
switch strings.ToLower(s) {
case "true", "1", "yes", "on":
*b = true
case "false", "0", "no", "off", "", "null":
*b = false
default:
return fmt.Errorf("cannot parse %q as bool", s)
}
return nil
}
func initSSH() { func initSSH() {
if _sshUser == "" { if _sshUser == "" {
_sshUser = os.Getenv("OIKOS_SSH_USER") _sshUser = os.Getenv("OIKOS_SSH_USER")
@@ -82,10 +104,18 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
defer session.Close() defer session.Close()
out, err := session.CombinedOutput(command) out, err := session.CombinedOutput(command)
if err != nil && out == nil { text := strings.TrimSpace(string(out))
return "", fmt.Errorf("exec: %w", err) // A non-zero exit MUST surface as an error. The previous guard only
// errored when there was no output, so a `pct create` that printed
// "CT 132 already exists" and exited non-zero was reported as success —
// the execution was marked completed though nothing was provisioned.
if err != nil {
if text != "" {
return text, fmt.Errorf("%w: %s", err, text)
}
return text, fmt.Errorf("exec: %w", err)
} }
return strings.TrimSpace(string(out)), nil return text, nil
} }
func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) { func resolveHostSSH(ctx context.Context, pool *db.Pool, entitySlug string) (string, string, error) {
@@ -139,20 +169,17 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
if err != nil { if err != nil {
slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug) slog.Error("httpapi: resolve host for approved execution", "error", err, "target", targetSlug)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":"%s"}`, err.Error())) execID, jsonErr("%s", err.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()}) emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return return
} }
parts := strings.SplitN(actionStr, ":", 3) idx := strings.Index(actionStr, ":")
if len(parts) < 2 { if idx < 0 {
slog.Error("httpapi: malformed action string", "action", actionStr) slog.Error("httpapi: malformed action string (no colon)", "action", actionStr)
return return
} }
action, params := parts[0], parts[1] action, params := actionStr[:idx], actionStr[idx+1:]
if len(parts) == 3 {
params = parts[1] + ":" + parts[2]
}
startedAt := time.Now() startedAt := time.Now()
var output, cmd string var output, cmd string
@@ -177,25 +204,249 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc) cmd = fmt.Sprintf("apt update -qq 2>&1 >/dev/null && apt upgrade -y -qq 2>&1; echo '---'; systemctl is-active %s || true", svc)
output, err = sshExec(ctx, host, user, cmd) output, err = sshExec(ctx, host, user, cmd)
case "pct_create":
var cfg struct {
VMID int `json:"vmid"`
Hostname string `json:"hostname"`
Cores int `json:"cores"`
Memory int `json:"memory"`
DiskGB int `json:"disk_gb"`
IP string `json:"ip"`
GW string `json:"gw"`
Storage string `json:"storage"`
Template string `json:"template"`
Privileged flexBool `json:"privileged"`
Nesting flexBool `json:"nesting"`
Mounts []string `json:"mounts"`
Nameserver string `json:"nameserver"`
Searchdomain string `json:"searchdomain"`
Services []string `json:"services"` // apt packages to install after create
PostInstall string `json:"post_install"` // shell run inside the container after create
}
if err := json.Unmarshal([]byte(params), &cfg); err != nil {
slog.Error("httpapi: pct_create parse params", "error", err, "params", params)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("invalid pct_create params: %v", err))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
// Only hostname is required. vmid is optional — when 0 (or later found
// to collide) the VMID guard below assigns a free cluster id.
if cfg.Hostname == "" {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, `{"error":"pct_create: hostname is required"}`)
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing hostname"})
return
}
if cfg.Cores == 0 {
cfg.Cores = 1
}
if cfg.Memory == 0 {
cfg.Memory = 512
}
if cfg.DiskGB == 0 {
cfg.DiskGB = 8
}
if cfg.Storage == "" {
cfg.Storage = "local-lvm"
}
if cfg.GW == "" {
cfg.GW = "192.168.8.2"
}
if cfg.Nameserver == "" {
cfg.Nameserver = "192.168.8.2"
}
if cfg.Searchdomain == "" {
cfg.Searchdomain = "hubris.network"
}
// Template pre-flight: resolve against what the host actually has
// cached. A hardcoded name (e.g. debian-13) fails opaquely with a raw
// `pct` error when that exact file isn't present. List the cache, then
// either validate the requested template or auto-pick the newest
// debian one; on miss, fail early with the available list so the
// operator/agent can retry with a real name.
cacheList, tplErr := sshExec(ctx, host, user, "ls -1 /var/lib/vz/template/cache/ 2>/dev/null | grep -E '\\.tar\\.(zst|gz|xz)$' || true")
available := []string{}
for _, l := range strings.Split(strings.TrimSpace(cacheList), "\n") {
if l = strings.TrimSpace(l); l != "" {
available = append(available, l)
}
}
if tplErr != nil {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("list templates on %s: %s", targetSlug, tplErr.Error()))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": tplErr.Error()})
return
}
cfg.Template = resolveTemplate(cfg.Template, available)
if cfg.Template == "" {
msg := fmt.Sprintf("no usable LXC template on %s. Available: %v", targetSlug, available)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
// VMID collision guard. Proxmox VMIDs are cluster-wide, so the model's
// guess (e.g. 132) can collide with a container on another node — pct
// create then fails with "CT N already exists on node X". Fetch the set
// of in-use VMIDs across the cluster; if the requested id is taken (or
// absent), fall back to the cluster's next free id so provisioning
// still succeeds instead of dead-ending on the operator's approval.
usedRaw, _ := sshExec(ctx, host, user, `pvesh get /cluster/resources --type vm --output-format json 2>/dev/null | grep -o '"vmid":[0-9]*' | grep -o '[0-9]*' || true`)
used := map[int]bool{}
for _, l := range strings.Fields(usedRaw) {
if n, e := strconv.Atoi(strings.TrimSpace(l)); e == nil {
used[n] = true
}
}
if cfg.VMID == 0 || used[cfg.VMID] {
nextRaw, nerr := sshExec(ctx, host, user, `pvesh get /cluster/nextid 2>/dev/null`)
nextID, cerr := strconv.Atoi(strings.TrimSpace(nextRaw))
if nerr != nil || cerr != nil || nextID == 0 {
msg := fmt.Sprintf("VMID %d is already in use on the cluster and could not resolve a free id", cfg.VMID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, jsonErr("%s", msg))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": msg})
return
}
slog.Info("httpapi: pct_create VMID reassigned", "requested", cfg.VMID, "assigned", nextID)
cfg.VMID = nextID
}
privFlag := "--unprivileged 1"
if cfg.Privileged {
privFlag = "--unprivileged 0"
}
nestingFlag := ""
features := []string{}
if cfg.Nesting {
features = append(features, "nesting=1")
}
if cfg.Privileged {
features = append(features, "keyctl=1")
}
if len(features) > 0 {
nestingFlag = fmt.Sprintf(" --features %s", strings.Join(features, ","))
}
// net0: DHCP when no static IP is given (or ip=="dhcp"). Proxmox
// rejects a gateway alongside ip=dhcp, so only add gw for a static IP.
net0 := "name=eth0,bridge=vmbr0,"
if cfg.IP == "" || strings.EqualFold(cfg.IP, "dhcp") {
net0 += "ip=dhcp"
} else {
net0 += "ip=" + cfg.IP
if cfg.GW != "" {
net0 += ",gw=" + cfg.GW
}
}
templatePath := fmt.Sprintf("/var/lib/vz/template/cache/%s", cfg.Template)
createCmd := fmt.Sprintf(
"pct create %d %s --hostname %s --cores %d --memory %d --rootfs %s:%d %s --net0 %s%s --start 1",
cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
cfg.Storage, cfg.DiskGB, privFlag, net0, nestingFlag)
if cfg.Nameserver != "" {
createCmd += fmt.Sprintf(" --nameserver %s", cfg.Nameserver)
}
if cfg.Searchdomain != "" {
createCmd += fmt.Sprintf(" --searchdomain %s", cfg.Searchdomain)
}
// Add mount points
for i, mp := range cfg.Mounts {
if i < 10 { // pct supports up to mp9
createCmd += fmt.Sprintf(" --mp%d %s", i, mp)
}
}
slog.Info("httpapi: pct_create running", "vmid", cfg.VMID, "hostname", cfg.Hostname, "cmd", createCmd)
output, err = sshExec(ctx, host, user, createCmd)
// Post-create provisioning: install apt packages and run a post_install
// script inside the fresh container, so a single approved pct_create
// yields a *working service*, not just an empty container. The script
// waits for real DNS/connectivity and self-heals the resolver first —
// a static-IP container with a dead nameserver otherwise fails apt with
// "Temporary failure resolving deb.debian.org" and installs nothing.
if err == nil && (len(cfg.Services) > 0 || cfg.PostInstall != "") {
script := provisionScript(sanitizePkgs(cfg.Services), cfg.PostInstall)
b64 := base64.StdEncoding.EncodeToString([]byte(script))
// sleep on the host so the container is up enough to accept pct exec.
cmd := fmt.Sprintf("sleep 4; pct exec %d -- bash -c 'echo %s | base64 -d | bash'", cfg.VMID, b64)
var provOut string
provOut, err = sshExec(ctx, host, user, cmd)
output = output + "\n--- post-install ---\n" + provOut
}
// On success, register the entity in the DB with proper relationships
if err == nil {
slug := "lxc:" + cfg.Hostname
var lxcID uuid.UUID
lxcID, _ = uuid.NewV7()
attrs := map[string]any{
"pve_id": fmt.Sprintf("%d", cfg.VMID),
"host": strings.TrimPrefix(targetSlug, "host:"),
"ip": cfg.IP,
}
attrsJSON, _ := json.Marshal(attrs)
_, insErr := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, state, attributes, enrolled_at)
VALUES ($1, $2, 'lxc', $3, 'provisioning', $4, now()) ON CONFLICT (slug) DO NOTHING`, lxcID, slug, cfg.Hostname, attrsJSON)
if insErr != nil {
slog.Error("httpapi: pct_create entity insert", "error", insErr, "slug", slug)
}
// Create hosts relationship: Proxmox host → LXC
var hostID uuid.UUID
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", targetSlug).Scan(&hostID); err == nil {
_, relErr := pool.Exec(ctx, `INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
VALUES ($1, $2, 'hosts', '{"provisioned_by":"nomos"}'::jsonb, now())`, hostID, lxcID)
if relErr != nil {
slog.Error("httpapi: pct_create relationship insert", "error", relErr, "host", targetSlug, "lxc", slug)
}
}
// Create entity_status row for health tracking
pool.Exec(ctx, `INSERT INTO entity_status (entity_id, health, last_check_at)
VALUES ($1, 'unknown', now()) ON CONFLICT (entity_id) DO NOTHING`, lxcID)
emitExecutionEvent(ctx, pool, execID, "executing", map[string]any{
"lxc_slug": slug, "vmid": cfg.VMID, "host": targetSlug,
})
slog.Info("httpapi: pct_create entity registered", "slug", slug, "vmid", cfg.VMID, "host", targetSlug)
}
default: default:
slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID) slog.Error("httpapi: unknown gated action for approved execution", "action", action, "execution_id", execID)
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, fmt.Sprintf(`{"error":"unknown action: %s"}`, action)) execID, jsonErr("unknown action: %s", action))
return return
} }
durationMs := int(time.Since(startedAt).Milliseconds()) durationMs := int(time.Since(startedAt).Milliseconds())
result := fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"))
status := "completed" status := "completed"
verified := true verified := true
// Build result via json.Marshal, not string interpolation. Command output
// (apt/pct) contains quotes, backslashes and control chars; the old
// fmt.Sprintf only escaped "\n", producing invalid JSON that failed the
// ::jsonb cast — so this UPDATE was silently discarded and the execution
// was stuck at "approved" forever even though provisioning succeeded.
resMap := map[string]any{"output": output}
if err != nil { if err != nil {
result = fmt.Sprintf(`{"output":"%s","error":"%s"}`, strings.ReplaceAll(output, "\n", "\\n"), err.Error()) resMap["error"] = err.Error()
status = "failed" status = "failed"
verified = false verified = false
} }
resultJSON, _ := json.Marshal(resMap)
pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`, if _, uerr := pool.Exec(ctx, `UPDATE executions SET status=$2, result=$3::jsonb, duration_ms=$4, verified=$5, started_at=$6, completed_at=$7 WHERE entity_id=$1`,
execID, status, result, durationMs, verified, startedAt, time.Now()) execID, status, resultJSON, durationMs, verified, startedAt, time.Now()); uerr != nil {
slog.Error("httpapi: finalize execution status", "error", uerr, "execution_id", execID, "intended_status", status)
}
emitExecutionEvent(ctx, pool, execID, status, map[string]any{ emitExecutionEvent(ctx, pool, execID, status, map[string]any{
"action": action, "target": targetSlug, "duration_ms": durationMs, "action": action, "target": targetSlug, "duration_ms": durationMs,
@@ -205,6 +456,109 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
"execution_id", execID, "action", action, "status", status, "duration_ms", durationMs) "execution_id", execID, "action", action, "status", status, "duration_ms", durationMs)
} }
// provisionScript builds the in-container bootstrap run after pct create. It
// (1) waits for DNS/connectivity and self-heals /etc/resolv.conf with a public
// resolver if the configured nameserver is dead, (2) installs apt packages with
// retries, (3) runs the operator's post_install. `set -e` after the network
// gate means any apt or post_install failure exits non-zero, so sshExec surfaces
// it and the execution is marked failed with the exact broken step in output.
func provisionScript(pkgs []string, postInstall string) string {
var b strings.Builder
b.WriteString("set -o pipefail\n")
// A fresh debian LXC has no locale set, which spams "Can't set locale"
// warnings and breaks some package post-install scripts. Pin C.UTF-8.
b.WriteString("export LANG=C.UTF-8 LC_ALL=C.UTF-8 DEBIAN_FRONTEND=noninteractive\n")
b.WriteString("probe=deb.debian.org\n")
b.WriteString("ok=0\n")
b.WriteString("for i in $(seq 1 30); do if getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done\n")
// Self-heal: if the assigned resolver can't resolve, fall back to public DNS.
b.WriteString("if [ \"$ok\" != 1 ]; then printf 'nameserver 1.1.1.1\\nnameserver 8.8.8.8\\n' > /etc/resolv.conf; ")
b.WriteString("for i in $(seq 1 15); do if getent hosts \"$probe\" >/dev/null 2>&1; then ok=1; break; fi; sleep 2; done; fi\n")
b.WriteString("if [ \"$ok\" != 1 ]; then echo 'ERROR: container has no DNS/connectivity after ~90s'; exit 1; fi\n")
b.WriteString("set -e\n")
if len(pkgs) > 0 {
b.WriteString("export DEBIAN_FRONTEND=noninteractive\n")
b.WriteString("apt-get update -o Acquire::Retries=3 -qq\n")
b.WriteString("apt-get install -y -o Acquire::Retries=3 --no-install-recommends -qq " + strings.Join(pkgs, " ") + "\n")
}
if strings.TrimSpace(postInstall) != "" {
b.WriteString("# --- operator post_install ---\n")
b.WriteString(postInstall)
b.WriteString("\n")
}
return b.String()
}
// jsonErr builds a valid {"error": "..."} JSON payload for an execution's
// result column. Always use this instead of fmt.Sprintf'ing JSON by hand —
// error text and command output routinely contain quotes/backslashes that
// break a hand-built string and fail the ::jsonb cast.
func jsonErr(format string, args ...any) []byte {
b, _ := json.Marshal(map[string]any{"error": fmt.Sprintf(format, args...)})
return b
}
// resolveTemplate maps a requested template name to one actually present in
// the host's template cache. Exact match wins; a bare distro hint (e.g.
// "debian-13" or "debian") matches by prefix; empty picks the newest debian
// (falling back to any) template available. Returns "" when nothing fits.
func resolveTemplate(requested string, available []string) string {
if len(available) == 0 {
return ""
}
if requested != "" {
for _, a := range available {
if a == requested {
return a
}
}
for _, a := range available {
if strings.HasPrefix(a, requested) {
return a
}
}
}
// Auto-pick: prefer debian, then the lexically-greatest (newest version).
best := ""
for _, a := range available {
if strings.Contains(a, "debian") && a > best {
best = a
}
}
if best != "" {
return best
}
for _, a := range available {
if a > best {
best = a
}
}
return best
}
// sanitizePkgs drops anything that isn't a plausible apt package token, so a
// hallucinated package list can't inject shell into the install command.
func sanitizePkgs(pkgs []string) []string {
out := make([]string, 0, len(pkgs))
for _, p := range pkgs {
p = strings.TrimSpace(p)
if p == "" {
continue
}
ok := true
for _, r := range p {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '.' || r == '+') {
ok = false
break
}
}
if ok {
out = append(out, p)
}
}
return out
}
// ─── Checks ──────────────────────────────────────────────────────────── // ─── Checks ────────────────────────────────────────────────────────────
func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) { func (s *Server) ListChecks(ctx context.Context, req gen.ListChecksRequestObject) (gen.ListChecksResponseObject, error) {

View File

@@ -0,0 +1,56 @@
package mcp
import (
"context"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// sprintResult extracts the concatenated text content from a tool result.
func sprintResult(r *mcp.CallToolResult) string {
var b strings.Builder
for _, c := range r.Content {
if tc, ok := c.(*mcp.TextContent); ok {
b.WriteString(tc.Text)
}
}
return b.String()
}
func TestSanitizeBodyStripsHTML(t *testing.T) {
raw := `<html><head><style>.x{color:red}</style><script>alert(1)</script></head>` +
`<body><h1>Hello &amp; Welcome</h1><p>Deploy with docker compose up -d</p></body></html>`
out := sanitizeBody("text/html; charset=utf-8", raw)
if strings.Contains(out, "<script") || strings.Contains(out, "alert(1)") {
t.Errorf("script not stripped: %q", out)
}
if strings.Contains(out, ".x{color:red}") {
t.Errorf("style not stripped: %q", out)
}
if !strings.Contains(out, "Hello & Welcome") {
t.Errorf("expected unescaped heading text, got: %q", out)
}
if !strings.Contains(out, "docker compose up -d") {
t.Errorf("expected body text preserved, got: %q", out)
}
}
func TestHTTPGetBlocksPrivateAndBadScheme(t *testing.T) {
cases := []string{
"http://127.0.0.1:8080/",
"http://localhost/admin",
"http://192.168.8.77/",
"http://10.0.0.5/",
"file:///etc/passwd",
"ftp://example.com/x",
"",
}
for _, c := range cases {
out := sprintResult(httpGet(context.Background(), c))
if !strings.Contains(strings.ToLower(out), "error") && !strings.Contains(strings.ToLower(out), "refus") {
t.Errorf("%q: expected rejection, got %q", c, out)
}
}
}

View File

@@ -6,9 +6,14 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io"
"log/slog" "log/slog"
"net"
"net/http" "net/http"
"net/url"
"os" "os"
"regexp"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -261,11 +266,11 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
nStr(args["status"])), nil nStr(args["status"])), nil
}) })
register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade.", register(&mcp.Tool{Name: "request_execution", Description: "Request a gated execution (agent-only mutation path). Supported actions: restart, systemctl, pct_exec, apt_upgrade, pct_create. pct_create provisions a NEW LXC and installs its service in one approved step — you do NOT need follow-up pct_exec calls for package installs.",
InputSchema: objSchema( InputSchema: objSchema(
prop{"target", "string", "Target entity slug (e.g. lxc:caddy)"}, prop{"target", "string", "Target entity slug. For pct_create this MUST be the Proxmox HOST that will run the container (e.g. host:strong) — NOT the new LXC's name. For restart/systemctl/apt_upgrade/pct_exec use the target service/LXC slug (e.g. lxc:caddy)."},
prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade"}, prop{"action", "string", "Action: restart, systemctl, pct_exec, apt_upgrade, pct_create"},
prop{"params", "string", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade'"}, prop{"params", "string", "For systemctl: 'enable|disable|reload'. For pct_exec: the shell command. For apt_upgrade: 'audit|upgrade'. For pct_create: a JSON object string with keys: vmid (int, required, unused id), hostname (string, required), cores (int), memory (MB int), disk_gb (int), ip (CIDR e.g. 192.168.8.50/24), gw (gateway ip), storage (default local-lvm), template (optional — omit to auto-pick newest debian on the host), privileged (bool), nesting (bool), mounts ([]string of 'src,mp=/dst'), services ([]string of apt packages to install), post_install (string shell script run inside the container after create). Example: {\"vmid\":150,\"hostname\":\"typetype\",\"cores\":2,\"memory\":2048,\"disk_gb\":16,\"ip\":\"192.168.8.50/24\",\"gw\":\"192.168.8.2\",\"nesting\":true,\"services\":[\"docker.io\",\"git\"],\"post_install\":\"git clone https://github.com/x/y /opt/y && cd /opt/y && docker compose up -d\"}"},
), ),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { }, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req) args := argsMap(req)
@@ -281,14 +286,36 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
} }
// Deduplicate: if a pending execution already exists for the same
// target+action, return the existing one instead of creating a
// duplicate. Prevents the LLM from re-requesting the same gated
// action in a tool-calling loop. Only blocks when a pending
// execution exists; completed/failed ones don't block.
if action == "systemctl" || action == "apt_upgrade" || action == "pct_create" {
execNamePrefix := action + " on " + targetSlug
var existingID string
err := pool.QueryRow(ctx, `
SELECT e.id::text FROM entities e
JOIN executions ex ON ex.entity_id = e.id
WHERE e.type = 'execution' AND e.name LIKE $1 AND ex.status = 'pending_approval'
ORDER BY e.created_at DESC LIMIT 1`, execNamePrefix+"%").Scan(&existingID)
if err == nil && existingID != "" {
return textResult(fmt.Sprintf("%s on %s is already queued for approval — execution %s. Wait for operator approval. Do not re-request.",
action, targetSlug, existingID)), nil
}
}
id, _ := uuid.NewV7() id, _ := uuid.NewV7()
correlationID := uuid.New().String() correlationID := uuid.New().String()
// Write execution record execName := action + " on " + targetSlug + " (" + id.String()[:8] + ")"
execSlug := "exec:" + targetSlug + ":" + id.String()[:8] execSlug := "exec:" + targetSlug + ":" + id.String()[:8]
pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`, _, err := pool.Exec(ctx, `INSERT INTO entities (id, slug, type, name, attributes) VALUES ($1, $2, 'execution', $3, '{}')`,
id, execSlug, action+" on "+targetSlug) id, execSlug, execName)
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5)`, if err != nil {
return textResult(fmt.Sprintf("error: failed to create execution: %v", err)), nil
}
pool.Exec(ctx, `INSERT INTO executions (entity_id, target_entity_id, action, risk_class, status, correlation_id, agent_id) VALUES ($1, $2, $3, 'reversible_low', 'running', $4, $5) ON CONFLICT DO NOTHING`,
id, targetID, action+":"+params, correlationID, agentID) id, targetID, action+":"+params, correlationID, agentID)
// Execute reversible actions immediately // Execute reversible actions immediately
@@ -305,7 +332,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
result = fmt.Sprintf("restart %s: ERROR %v", svc, err) result = fmt.Sprintf("restart %s: ERROR %v", svc, err)
} }
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) id, jsonOut(out))
return textResult(result), nil return textResult(result), nil
case "systemctl": case "systemctl":
@@ -326,7 +353,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
result = fmt.Sprintf("systemctl %s %s: ERROR %v", params, svc, err) result = fmt.Sprintf("systemctl %s %s: ERROR %v", params, svc, err)
} }
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) id, jsonOut(out))
return textResult(result), nil return textResult(result), nil
case "pct_exec": case "pct_exec":
@@ -350,7 +377,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
result = fmt.Sprintf("pct exec %s: ERROR %v", pveID, err) result = fmt.Sprintf("pct exec %s: ERROR %v", pveID, err)
} }
pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`, pool.Exec(ctx, `UPDATE executions SET status='completed', result=$2::jsonb WHERE entity_id=$1`,
id, fmt.Sprintf(`{"output":"%s"}`, strings.ReplaceAll(out, "\n", "\\n"))) id, jsonOut(out))
return textResult(result), nil return textResult(result), nil
case "apt_upgrade": case "apt_upgrade":
@@ -370,11 +397,26 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation") createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil return textResult(fmt.Sprintf("apt_upgrade on %s requires approval — execution %s queued", targetSlug, id)), nil
case "pct_create":
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
return textResult(fmt.Sprintf("pct_create on %s requires approval — execution %s queued. The LXC will be provisioned once approved.", targetSlug, id)), nil
default: default:
return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade", action)), nil return textResult(fmt.Sprintf("unknown action: %s. Supported: restart, systemctl, pct_exec, apt_upgrade, pct_create", action)), nil
} }
}) })
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
InputSchema: objSchema(
prop{"url", "string", "Absolute http(s) URL to fetch"},
),
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
args := argsMap(req)
rawURL, _ := args["url"].(string)
return httpGet(ctx, rawURL), nil
})
register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution", register(&mcp.Tool{Name: "get_execution_status", Description: "Check the status of a requested execution",
InputSchema: objSchema( InputSchema: objSchema(
prop{"execution_id", "string", "Execution UUID (from request_execution output)"}, prop{"execution_id", "string", "Execution UUID (from request_execution output)"},
@@ -698,6 +740,8 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
COALESCE(st.last_check_at::text, '') AS last_check COALESCE(st.last_check_at::text, '') AS last_check
FROM entities e FROM entities e
LEFT JOIN entity_status st ON st.entity_id = e.id LEFT JOIN entity_status st ON st.entity_id = e.id
WHERE e.state IS NOT NULL
OR st.health IS NOT NULL
ORDER BY st.health, e.slug ORDER BY st.health, e.slug
LIMIT 200 LIMIT 200
`), nil `), nil
@@ -828,6 +872,15 @@ func textResult(s string) *mcp.CallToolResult {
} }
} }
// jsonOut builds a valid {"output": "..."} JSON payload for an execution's
// result column. Command output contains quotes/backslashes/control chars, so
// it must be JSON-marshaled — a hand-built string fails the ::jsonb cast and
// silently drops the status update, leaving the execution stuck.
func jsonOut(out string) []byte {
b, _ := json.Marshal(map[string]any{"output": out})
return b
}
func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult { func queryEntity(ctx context.Context, pool *db.Pool, idOrSlug string) *mcp.CallToolResult {
var id uuid.UUID var id uuid.UUID
if u, err := uuid.Parse(idOrSlug); err == nil { if u, err := uuid.Parse(idOrSlug); err == nil {
@@ -970,8 +1023,79 @@ func resolveHost(ctx context.Context, pool *db.Pool, entitySlug string) (hostIP
return "", "", fmt.Errorf("no IP found for %s", entitySlug) return "", "", fmt.Errorf("no IP found for %s", entitySlug)
} }
// htmlTagRe strips HTML tags for the naive text extraction in httpGet.
var htmlTagRe = regexp.MustCompile(`(?s)<(script|style)[^>]*>.*?</(script|style)>|<[^>]+>`)
// httpGet fetches a public URL and returns sanitized, size-capped text so the
// agent can read a service's README/site before provisioning. Guards: scheme
// allow-list, request timeout, 16KB body cap, and blocking of RFC1918/loopback
// hosts to avoid using the tool as an SSRF pivot into the private mesh.
func httpGet(ctx context.Context, rawURL string) *mcp.CallToolResult {
if rawURL == "" {
return textResult("error: url required")
}
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return textResult("error: url must be an absolute http(s) URL")
}
if isPrivateHost(u.Hostname()) {
return textResult("error: refusing to fetch private/loopback address")
}
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hreq, err := http.NewRequestWithContext(cctx, http.MethodGet, u.String(), nil)
if err != nil {
return textResult(fmt.Sprintf("error: %v", err))
}
hreq.Header.Set("User-Agent", "oikos-nomos/1.0 (+homelab agent)")
hreq.Header.Set("Accept", "text/plain, text/html, application/json;q=0.9, */*;q=0.5")
client := &http.Client{Timeout: 20 * time.Second}
resp, err := client.Do(hreq)
if err != nil {
return textResult(fmt.Sprintf("error: fetch failed: %v", err))
}
defer resp.Body.Close()
const cap = 256 * 1024 // read a bit extra pre-strip; final output capped below
body, _ := io.ReadAll(io.LimitReader(resp.Body, cap))
ct := resp.Header.Get("Content-Type")
text := sanitizeBody(ct, string(body))
return textResult(fmt.Sprintf("GET %s → %d %s\n\n%s", u.String(), resp.StatusCode, ct, text))
}
// sanitizeBody strips scripts/styles/tags from HTML, unescapes entities,
// collapses whitespace, and caps the result to ~16KB of readable text.
func sanitizeBody(contentType, raw string) string {
text := raw
if strings.Contains(contentType, "html") {
text = htmlTagRe.ReplaceAllString(text, " ")
text = html.UnescapeString(text)
text = strings.Join(strings.Fields(text), " ")
}
if len(text) > 16*1024 {
text = text[:16*1024] + "\n…[truncated]"
}
return text
}
// isPrivateHost reports whether host is loopback, link-local, or RFC1918.
func isPrivateHost(host string) bool {
host = strings.ToLower(host)
if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false // hostname; DNS may still resolve private — acceptable for a homelab tool
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}
func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) { func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UUID, action, params, riskClass string) {
payload := fmt.Sprintf(`{"action":"%s","params":"%s","execution_id":"%s"}`, action, params, execID) p := map[string]any{"action": action, "params": params, "execution_id": execID.String()}
payload, _ := json.Marshal(p)
// approvals.entity_id is PK + FK to entities(id). Reuse the execution's // approvals.entity_id is PK + FK to entities(id). Reuse the execution's
// entity (already inserted by request_execution) so the FK is satisfied — // entity (already inserted by request_execution) so the FK is satisfied —
// a fresh UUID here had no matching entities row, so the INSERT silently // a fresh UUID here had no matching entities row, so the INSERT silently
@@ -982,7 +1106,7 @@ func createApproval(ctx context.Context, pool *db.Pool, execID, targetID uuid.UU
kind, payload, status, expires_at, created_at) kind, payload, status, expires_at, created_at)
VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending', VALUES ($1, $2, $3, $4, 'execution', $5::jsonb, 'pending',
now() + interval '1 hour', now())`, now() + interval '1 hour', now())`,
execID, targetID, action, riskClass, payload); err != nil { execID, targetID, action, riskClass, string(payload)); err != nil {
slog.Error("createApproval: insert approval", "error", err, "execution", execID) slog.Error("createApproval: insert approval", "error", err, "execution", execID)
return return
} }

View File

@@ -23,21 +23,69 @@ the actuator (a separate container with restricted SSH key) picks up.
## Key MCP tools ## Key MCP tools
- `get_entity`, `list_entities` — resolve slugs to state - `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_blast_radius` — understand impact before requesting action
- `get_health_summary` — fleet status at a glance
- `get_signal_history` — open alerts - `get_signal_history` — open alerts
- `get_trend` — metric trends for decisions - `get_trend` — metric trends for a specific entity (single-entity only)
- `request_execution` — the ONLY mutation path - `request_execution` — the ONLY mutation path. Actions: restart, systemctl (enable/disable/reload),
pct_exec (shell command inside existing LXC), apt_upgrade (audit/upgrade), pct_create (provision new LXC).
- `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.
- `get_agent_activity` — your own behavior log - `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.
## Policy awareness ## Policy awareness
Before calling `request_execution`: Before calling `request_execution`:
- Check risk class via `get_entity` on the target - Check risk class via `get_entity` on the target
- `pct_create``config_mutation`: provisions a new LXC AND installs its service in one
approved step. 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, storage, template (omit to auto-pick newest debian on the host),
privileged, nesting, mounts, and — to actually deliver a working service —
`services` ([]apt packages) and `post_install` (shell run inside the container, e.g. a
`git clone && docker compose up -d`). Prefer one pct_create with services+post_install
over pct_create followed by many pct_exec approvals. Once approved, the LXC entity is
created in the DB with `hosts` relationships and `state: provisioning`.
- **vmid**: omit or set 0 — a free cluster id is assigned automatically. Never reuse an
existing container's id.
- **networking**: prefer `"ip":"dhcp"` unless the operator needs a fixed address; DHCP
yields a working DNS resolver. If you set a static CIDR, the provisioner self-heals DNS
to a public resolver when the gateway can't resolve, but DHCP is more reliable.
- **Docker**: `docker-compose-plugin` is NOT in Debian's repos — do not put it in
`services`. For Docker, put `docker.io` in `services` (it provides the engine) and, if
you need compose v2, install it in `post_install` from Docker's official convenience
script (`curl -fsSL https://get.docker.com | sh`). Use `docker compose` (v2) only after
that, otherwise use `docker-compose` (v1, from docker.io).
- **verify**: end `post_install` by confirming the service actually answers (e.g.
`curl -fsS http://localhost:<port>/` ), so a green result means it truly works.
- If `destructive` or `config_mutation`: escalate to operator - If `destructive` or `config_mutation`: escalate to operator
- If `reversible_low` with validated pattern: auto-act allowed - If `reversible_low` with validated pattern: auto-act allowed
**After requesting a gated action that queues for approval: STOP.** Present the
plan to the operator and wait. Do not call `request_execution` again for the
same action — the system will tell you it's already queued. One approval per
action is enough. The operator will approve (or deny) from the chat UI.
## Token efficiency ## Token efficiency
Use MCP tools over raw queries. MCP responses are already compressed. When Use MCP tools over raw queries. MCP responses are already compressed. When

View File

@@ -1,8 +1,6 @@
# 2026-07-08 — Liveness, drift, and UX cohesion # 2026-07-08 — Liveness, drift, and UX cohesion
**Status:** Code complete for Phases 14 core scope; not yet deployed to the **Status:** In Progress — Phases 14 code complete; not yet deployed. Phase 5 deferred.
live containers (pending explicit go-ahead — see below). Phase 5 partially
covered by pre-existing endpoints; full CRUD UI deferred.
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution - **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health

View File

@@ -0,0 +1,156 @@
# 2026-07-09 — Session execution, UX, and learning improvements
**Status:** Planned
## Goal
Fix the hard blocker and UX issues found in the latest production Nomos session
(`b9c5de7c-e0b5-424c-9149-9fd45b2e7011` — "deploy TypeType as an LXC on strong").
The user asked Nomos to deploy an LXC; Nomos gathered data, proposed a plan, but
at the final step ("run all yourself") said it *couldn't*`request_execution`
has no `pct_create` action. The user's objective was not achieved.
## Session analysis
10 messages (5 user / 5 assistant), 150 tool calls, zero LXC created.
| Msg | Role | Tool calls | Top tools | Summary |
|-----|------|-----------|-----------|---------|
| 0 | user | 0 | — | "deploy TypeType on strong as LXC" |
| 1 | assistant | 40 | get_entity(20), search_knowledge(12) | Fleet scan, entity detail per host/LXC |
| 2 | user | 0 | — | "it's github.com/Priveetee/TypeType, use tube.hubris.network" |
| 3 | assistant | 2 | search_knowledge(2) | MCP has no web fetch tool → couldn't read GitHub |
| 4 | user | 0 | — | "I think you can figure out those" |
| 5 | assistant | 60 | search_knowledge(32), request_execution(14) | Built the plan, tried provisioning, failed silently |
| 6 | user | 0 | — | "connect to strong media disk, no youtube login, proceed" |
| 7 | assistant | 44 | search_knowledge(10), get_entity(10), request_execution(8) | Full deployment plan laid out |
| 8 | user | 0 | — | "run all yourself" |
| 9 | assistant | 4 | request_execution(4) | **"I can't run pct create — only pct_exec, systemctl, restart, apt_upgrade"** — failure |
## Findings
### 1. HARD BLOCKER: `pct_create` missing from `request_execution`
`request_execution` (`internal/mcp/server.go:264-376`) supports `restart`,
`systemctl`, `pct_exec`, `apt_upgrade`. The actuator has `ProvisionLXC()`
(`internal/actuator/actuator.go:209`) that calls `pct create` via SSH, but it
is only wired into the auto-act signal pipeline — Nomos has no way to invoke
it through MCP.
Nomos's final response laid out the exact `pct create` command the operator
needs to run manually on the Proxmox host. That's a dead end for the user, who
expected the agent to execute from chat.
**Fix:**
- Add `pct_create` action to `request_execution` handler.
- Wire it to the existing `ProvisionLXC()` function.
- Classification: `config_mutation` — requires operator approval. Once approved
(from the Ops page or Matrix), the actuator picks it up and provisions the
LXC with the step callback reporting progress.
- Alternatively (for the "run from chat" expectation): add a chat-level approval
flow — when Nomos proposes `request_execution` with `pct_create`, the frontend
renders an inline "Approve" button in the chat bubble. Operator clicks → it runs.
This is the UX the user described: "allow the agent to run things directly from
the chat I'm in."
### 2. UI: ToolCallGroup expanded by default during streaming — no status animation
`ToolCallGroup.svelte` uses `<details open>` when `active=true`. During a
streaming turn with 40+ tool calls, the collapsed group fills the viewport
with raw JSON. The summary header shows only a static icon and "N tools" text.
**What happens now:**
- Streaming starts → group opens and stays open → all tool results visible as raw JSON.
- When streaming ends → auto-collapses. No animation.
- Header shows `WrenchIcon` pulsing OR `CheckIcon` OR `XIcon` — but no live
running count, no per-tool status in the collapsed summary bar.
**What should happen:**
- Group starts **collapsed** by default. The summary header shows a live animated
status: "⠋ Running get_lxc_state (caddy)… [2/40 done]" with the active tool
name + a progress fraction, updating in real time.
- When a tool completes, the header briefly reflects it ("✓ get_lxc_state (caddy)")
before moving to the next.
- Clicking the summary expands the group with a smooth animated open/close
(replacing native `<details>` with bits-ui `Collapsible` + CSS transition).
- On load from history (not streaming), always starts collapsed.
**Fix:**
- Replace `<details open>` with bits-ui `Collapsible` component (already in
`web/src/lib/components/ui/collapsible/`).
- Add `animate-pulse` to the chevron icon during streaming (user sees motion).
- Add a `statusText` derived that shows the in-progress tool name + count.
- CSS animation: `Collapsible.Content` supports `forceMount` with transitions.
### 3. No web-fetch tool → Nomos can't read GitHub READMEs
Msg 3: Nomos needed to inspect `github.com/Priveetee/TypeType` to understand the
stack. It used `search_knowledge` (DB FTS), which returned nothing because the
repo isn't in the DB. The agent had no way to fetch external URLs.
The MCP has 27 tools (21 listed in AGENTS.md + 6 more added since), but none
for HTTP/web fetching. Nomos can only query the DB or execute SSH commands on
existing hosts.
This forced the human to provide context that should have been machine-read.
**Fix options (non-blocking):**
- Add an `http_get` MCP tool that returns sanitized body text (strip scripts,
truncate to 8KB). Rate-limited per-turn.
- Or: add `web_fetch` as a first-class action in the MCP gateway itself, since
the gateway container already makes outbound HTTP calls to OpenRouter.
### 4. Task: Bulk-tool awareness already in SOUL.md but not enough
The SOUL.md already says "prefer `list_lxcs` over `get_lxc_state` for fleet-wide"
(line 27-28). But msg 1 still made 40 calls. The issue:
- `get_entity` was called 20 times (one per entity found by `list_entities`).
`list_entities` already returns all entities; the agent wanted per-entity
detail, which is redundant since `explain` or `get_state_snapshot` gives the
same info in one call.
**Fix (already planned in `2026-07-09-chat-sessions-improvements.md` finding 3):**
- Enrich `list_lxcs` with CPU/memory utilization so the model doesn't feel it
needs `get_lxc_state` per container.
- Add `get_tools_summary` to SOUL.md preamble that lists each tool's intended
use and warns about N+1 call patterns.
### 5. Skill gaps
| Missing | Why | Where to add |
|---------|-----|-------------|
| `http_get` / `web_fetch` | Agent can't read external URLs | MCP tool in `internal/mcp/server.go` |
| `session-review` skill | No way to learn from failed sessions | `.agents/skills/session-review/SKILL.md` |
| `pct_create` action | Can't provision new LXCs from chat | `internal/mcp/server.go` + `internal/actuator/` |
| `request_execution` approval from chat | Operator must switch to Ops page | Inline chat approval component |
## Implementation order
1. **Add `pct_create` to `request_execution`** (`internal/mcp/server.go`) —
hard blocker, needed for the session's objective.
2. **ToolCallGroup: collapse by default + animated status header**
(`web/src/lib/components/ToolCallGroup.svelte`) — immediate UX win, the user
explicitly asked for this.
3. **Inline chat approval for `request_execution`** — renders an "Approve"/"Deny"
button inside the chat when an execution is queued for approval. Lets the
operator approve from the same chat.
4. **`session-review` local skill** — lives in `.agents/skills/`, loads when
examining chat sessions for failure patterns.
5. **`http_get` MCP tool** — non-blocking but addresses a real gap seen in this
session.
## Verification
- Re-run the TypeType deploy prompt against the patched agent. Confirm:
- Agent proposes plan as before (keep plan-proposal behavior).
- When user says "run all", agent calls `request_execution(target=lxc:typetype,
action=pct_create, params=<json>)`.
- Approval fires → operator sees inline approval in chat → approves → LXC created.
- ToolCallGroup: start a session that triggers 5+ tool calls. Confirm:
- Group starts collapsed.
- Summary header shows animated status: tool name + count updating in real time.
- Clicking expands smoothly.
- On session reload (history), stays collapsed.
- Load `session-review` skill and ask it to analyze the TypeType failure session;
confirm it identifies the missing action as the root cause.

View File

@@ -0,0 +1,189 @@
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
**Status:** Planned
## Goal
Make Nomos able to do **anything** needed to maintain the homelab — provision
LXCs, deploy services, debug, restart, fix configs, investigate — without that
capability being a fixed enum of hand-coded actions. The action space is
unlimited; the *gate* on it is a risk classifier + operator approval, not a
whitelist of tricks. Knowledge (the graph + runbooks) supplies the *how*; the
agent's reasoning supplies the *what*; the classifier supplies the *may I*.
Operator directive (2026-07-10): **"The number of actions the agent should be
able to do is unlimited. We need logic to gate destructive actions, but we
should not limit what the agent can do."**
Chosen autonomy posture for v1: **approve-most (cautious)** — only genuinely
read-only commands auto-run; anything that changes state requires operator
approval. We can relax later once the classifier and ledger have earned trust.
## This is a realignment, not a new idea
[.agents/OIKOS.md](../.agents/OIKOS.md) already specifies this exact model:
> The classifier scores **risk class × blast radius × confidence** and routes:
> auto-act / escalate / queue. The classifier can only *lower* autonomy relative
> to policy, never raise it. When in doubt, escalate.
> Act — execute through `homelab` commands or **runbooks** (never ad-hoc SSH).
So the target architecture is the *documented* architecture. The problem is the
implementation diverged from it on the agent's action path.
## Gap analysis (grounded in code)
| Designed (OIKOS.md) | Actually implemented today |
|---|---|
| Classifier routes every action by risk × blast × confidence | [`internal/policy/classify.go`](../internal/policy/classify.go) `ClassifySignal()` only classifies **Signals** (the Observe pipeline), by entity+action-type. It is **not** called by the agent's mutation path. |
| Agent acts through unlimited **runbooks** | Agent acts through `request_execution` with a **hard-coded enum**: `restart, systemctl, pct_exec, apt_upgrade, pct_create` ([`internal/mcp/server.go`](../internal/mcp/server.go)), each bespoke Go, gated by per-action `if`s, not the classifier. New capability = new Go + redeploy. |
| Runbooks/skills are executable data | `skills` table + `.agents/skills/*` + `knowledge_entities` exist and are *readable* (`get_skills`), but **nothing executes a runbook**. The knowledge is inert w.r.t. action. |
| Auto-act loop consumes classified signals and acts | [`internal/actuator/actuator.go:124`](../internal/actuator/actuator.go) is a literal `"stub execution"` — it marks work done without doing it. |
| "Never ad-hoc SSH" | `pct_exec` **is** ad-hoc SSH (arbitrary shell in a container) and **auto-runs with no approval or classification**. |
Net: the elegant model exists as scaffolding (classifier, policy schema, risk
classes, blast-radius graph walks, skills-as-data, ledger, and the
approval+feedback plumbing hardened in the 2026-07-09/10 sessions), but the live
agent→action path is a bag of tricks that bypasses all of it. Everything added
in the recent LXC-deploy work (`pct_create` + DNS/VMID/template logic) made the
bag *bigger* — reliable, but on the wrong axis.
**Bones that already exist and get reused:** `internal/policy` (classifier +
`computeBlastRadius`/`blast_radius()` SQL), `risk_classes`/`action_risk` tables,
`seeds/policy.yaml`, `executions`/`approvals`/`audit_log`, the MCP SSH machinery,
and the inline approval + execution-status feedback loop (chat polls
`GET /executions/{id}`).
## Target architecture — three layers
### Layer 0 — one general gated primitive (the foundation)
Collapse the fixed enum into essentially one tool:
```
run(target, command, purpose, [declared_risk])
```
- `target` — any host or LXC slug; resolves to SSH (host) or `pct exec` (LXC).
- `command` — arbitrary shell.
- `purpose` — the agent's stated intent (shown to the operator, feeds classify).
- `declared_risk` — optional agent self-assessment.
Every call flows through:
1. **Classify** the command → `read_only | reversible_low | config_mutation |
destructive`. Rule-based:
- read-only **allowlist** (e.g. leading verb in `cat, ls, stat, journalctl,
systemctl status|is-active, pct config|status, df, free, uptime, ip, ss,
docker ps|logs, git status|log`) → `read_only`;
- destructive **denylist** (`rm -rf`, `dd`, `mkfs`, `wipefs`, `pct destroy`,
`qm destroy`, `shutdown`, `reboot`, `> /dev/`, `:(){ :|:& };:`, secret
exfiltration, piping remote scripts to a root shell) → `destructive`;
- anything writing state / installing / editing configs → `config_mutation`;
- **default → escalate** (`config_mutation`) when unsure.
The classifier may only make `declared_risk` **stricter**, never looser
(mirrors "can only lower autonomy, never raise").
2. **Route** (approve-most posture):
- `read_only` → auto-run + ledger, no approval.
- `reversible_low` / `config_mutation` → **operator approval** (v1 gates all
state changes; a later posture can auto-run `reversible_low`).
- `destructive` → approval **+ typed confirmation phrase**.
3. **Execute** (existing SSH/`pct exec`), **verify** (optional check command),
**ledger** (`executions` + `audit_log`), **stream feedback to chat** (reuse
the `GET /executions/{id}` polling + `InlineApproval` phases already built).
Layer 0 alone delivers "the agent can attempt anything; state changes are gated."
### Layer 1 — runbooks as executable data (reliability without rigidity)
The hard-won procedures become **runbooks in the knowledge DB**, retrieved and
executed step-by-step via Layer 0 — not frozen Go:
- `pct_create` + its DNS-self-heal / VMID-collision / template-resolution /
locale logic becomes the canonical **"provision LXC" runbook** (parametric
steps the agent fills in and runs through `run`). The reliability survives as
documented, reusable steps rather than a compiled handler.
- New capability = **new runbook (data)**, no redeploy.
- Keep a *small* set of mechanical helpers where a shell step is genuinely
fiddly (e.g. "pick a free cluster VMID"), exposed as callable sub-tools — but
the flow is agent-driven, not enum-driven.
This is the crucial **both/and**: the general primitive is the unlimited escape
hatch; curated runbooks are the reliable fast-path so the agent doesn't
re-derive DNS/VMID/docker every time (the exact thing that failed repeatedly in
the 2026-07-09 sessions).
### Layer 2 — learning closes the loop
Successful ad-hoc `run` sequences get promoted into runbooks/patterns (the
`learning` engine + `skills` table already exist for this); the failure ledger
informs retries. The system grows more capable **as data**, not as code.
## Safety model (the whole point of the gate)
- **Default-escalate.** Nothing is *forbidden*; risky things need the operator's
"yes." Unknown/unparseable risk → approval.
- **Hard denylist** for catastrophic patterns → always typed confirmation, even
if the agent declared them safe.
- **Blast radius at approval time** — graph walk (`blast_radius()` exists):
"this restarts caddy → 8 downstream services."
- **Preview / dry-run** where the command supports it.
- **Kill-switch** (`global.auto_act`, per-target `never_auto_act.*`) already
exists; extend to a global "require approval for everything" flip.
- **Full audit ledger** — every command, its classification, decision, actor,
output. Non-negotiable.
- **Scope guards** — resolve `target` to a real entity first; refuse commands
against `destroyed`/unknown targets; cap output size (already done).
## Honest risks / tradeoffs
- Trades a small vetted surface (5 actions) for arbitrary root across the fleet,
LLM-driven, gated only by classifier + approval. Classifying arbitrary shell
perfectly is impossible; **default-escalate + hard denylist + always-on audit**
is the mitigation, not perfect classification.
- Approve-most means more operator clicks initially. Acceptable while trust is
built; the posture is a config knob, not a rewrite.
- Runbook-as-data can drift from reality like any doc; the ledger + verify step
+ learning loop are the correction mechanism.
## Migration path (incremental, each step shippable)
1. **Command classifier** — extend `internal/policy` with
`ClassifyCommand(cmd, declaredRisk) → riskClass` (allowlist/denylist/default-
escalate + can-only-escalate rule). Unit-tested against a corpus of safe /
mutating / catastrophic commands.
2. **`run` tool** — new MCP tool routing classify → gate → execute → the
existing feedback path. Ship alongside the current tools (no removal yet).
3. **Approval context** — surface risk class + blast radius + purpose on the
approval (chat `InlineApproval` + Ops page); typed-confirmation for
destructive.
4. **Runbook execution** — a "provision LXC" runbook (ports the current
`pct_create` logic) executed via `run`; validate parity with today's handler.
5. **Retire the enum** — convert remaining hard-coded actions to runbooks; make
`request_execution` a thin deprecated alias or remove it.
6. **Revive auto-act** — replace the actuator stub, reusing the *same* classifier
for the Observe→Act direction (signals), still approve-most.
## Verification
- Classifier corpus test: read-only commands auto-pass; a set of known
catastrophic commands always route to destructive+confirmation; ambiguous
commands escalate. No command auto-runs that mutates state.
- End-to-end: operator asks Nomos a novel task **not** in the old enum (e.g.
"tail caddy's error log and restart it if it's flapping"); Nomos composes
`run` calls; read-only steps auto-run, the restart gates for approval; chat
shows live status; ledger records each command + classification.
- Parity: "provision an LXC with a service" via the runbook path matches the
reliability proven for the `pct_create` handler (free VMID, DNS, install,
verify), then destroy.
## Open questions for the operator
- **Reversible-low posture:** keep gating restarts/syncs in v1 (chosen), or
auto-run them once the classifier is trusted?
- **Confirmation phrase:** per-action typed phrase for destructive, or a global
one?
- **Runbook authorship:** operator-authored only, or may Nomos propose new
runbooks (subject to approval) from successful ad-hoc sequences?
- **Blast-radius threshold:** should a large blast radius force approval even for
otherwise-reversible actions?

View File

@@ -1,6 +1,6 @@
# 2026-07-08 — Signal triggers: host health checks # 2026-07-08 — Signal triggers: host health checks
**Status:** Implemented (Phases 1-5 complete) **Status:** Done — Phases 1-5 complete
## Goal ## Goal

View File

@@ -13,7 +13,10 @@ went sideways, open an investigation.
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned | | 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress | | 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress | | 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress |
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned | | 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
| 2026-07-09 | [Session execution, UX, and learning improvements](2026-07-09-session-execution-and-ux-fixes.md) | Planned |
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | Planned |
## Done ## Done
@@ -33,6 +36,7 @@ See [`done/`](done/) for executed plans:
| 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](done/2026-07-07-client-lifecycle-in-go.md) | | 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](done/2026-07-07-client-lifecycle-in-go.md) |
| 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) | | 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) |
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) | | 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) |
| 2026-07-08 | [Signal triggers: host health checks](done/2026-07-08-signal-triggers.md) |
## Conventions ## Conventions

View File

@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? [] return data.messages ?? []
} }
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
export interface ChatEvent { export interface ChatEvent {
type: string type: string
data: any data: any
@@ -225,6 +230,12 @@ export async function fetchExecutions(status?: string): Promise<Execution[]> {
return data.items ?? [] return data.items ?? []
} }
export async function getExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}`)
if (!res.ok) return null
return res.json()
}
export async function cancelExecution(id: string): Promise<Execution | null> { export async function cancelExecution(id: string): Promise<Execution | null> {
const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' }) const res = await fetch(`${API}/executions/${id}/cancel`, { method: 'POST' })
if (!res.ok) return null if (!res.ok) return null

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import type { PendingApproval } from '$lib/stores/chat'
import { decideApproval, getExecution, type Execution } from '$lib/api'
import { Button } from '$lib/components/ui/button'
import { SvelteMap } from 'svelte/reactivity'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
let { approvals }: { approvals: PendingApproval[] } = $props()
// Per-execution UI phase, keyed by executionId. A resolved phase hides the
// action buttons permanently so the banner clears after a click and can
// never re-POST /decision.
type Phase = 'deciding' | 'running' | 'completed' | 'failed' | 'denied'
const phase = new SvelteMap<string, Phase>()
// Latest execution row (for status/result display), keyed by executionId.
const exec = new SvelteMap<string, Execution>()
const TERMINAL = new Set(['completed', 'failed', 'cancelled', 'denied'])
function errorText(e: Execution | undefined): string {
const r = e?.result as Record<string, unknown> | undefined | null
const v = r?.error
return typeof v === 'string' && v ? v : 'Execution failed.'
}
// Poll the execution until it reaches a terminal state, so the operator sees
// provisioning progress and the final outcome without leaving the chat.
async function track(id: string) {
for (let i = 0; i < 150; i++) { // ~6min ceiling at 2.5s
const e = await getExecution(id)
if (e) {
exec.set(id, e)
if (e.status === 'completed') { phase.set(id, 'completed'); return }
if (e.status === 'failed' || e.status === 'cancelled') { phase.set(id, 'failed'); return }
}
await new Promise((r) => setTimeout(r, 2500))
}
// Timed out waiting — leave whatever we last saw, mark running-stalled.
if (!TERMINAL.has(exec.get(id)?.status ?? '')) phase.set(id, 'running')
}
async function decide(approval: PendingApproval, decision: 'approve' | 'deny') {
const id = approval.executionId
const cur = phase.get(id)
if (cur && cur !== 'failed') return // no resubmit once decided/in-flight
phase.set(id, 'deciding')
const ok = await decideApproval(id, decision)
if (!ok) { phase.set(id, 'failed'); return }
if (decision === 'deny') { phase.set(id, 'denied'); return }
phase.set(id, 'running')
void track(id)
}
</script>
{#each approvals as approval (approval.executionId)}
{@const p = phase.get(approval.executionId)}
{@const e = exec.get(approval.executionId)}
{#if p === 'completed'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-success/40 bg-success/5 px-3 py-2 text-xs text-success">
<CheckIcon class="size-4 shrink-0" />
<span>Provisioned successfully{e?.duration_ms ? ` in ${Math.round(e.duration_ms / 1000)}s` : ''}. See the Executions view for details.</span>
</div>
{:else if p === 'failed'}
<div class="my-2 flex flex-col gap-1 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<div class="flex items-center gap-2">
<XIcon class="size-4 shrink-0" />
<span class="font-medium">Execution failed</span>
<Button size="sm" variant="outline" class="ml-auto h-6 px-2 text-xs" onclick={() => decide(approval, 'approve')}>Retry</Button>
</div>
<pre class="whitespace-pre-wrap break-words pl-6 opacity-90">{errorText(e)}</pre>
</div>
{:else if p === 'denied'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
<XIcon class="size-4" /><span>Denied.</span>
</div>
{:else if p === 'running' || p === 'deciding'}
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircleIcon class="size-4 shrink-0 animate-spin text-warning" />
<span>{p === 'deciding' ? 'Submitting approval…' : `Provisioning ${approval.target} (this can take a minute)`}</span>
</div>
{:else}
<div class="my-2 flex items-center gap-2 rounded-lg border border-warning/40 bg-warning/5 px-3 py-2">
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
<span class="flex-1 text-xs text-muted-foreground">{approval.action} on {approval.target} requires approval</span>
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'approve')}>
<CheckIcon class="size-3" /><span class="ml-1">Approve</span>
</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" onclick={() => decide(approval, 'deny')}>
<XIcon class="size-3" /><span class="ml-1">Deny</span>
</Button>
</div>
{/if}
{/each}

View File

@@ -1,20 +1,39 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { sessions, currentSession, loadSessions, loadSessionMessages, newChat } from '$lib/stores/chat' import { sessions, currentSession, loadSessions, loadSessionMessages, newChat, deleteSession } from '$lib/stores/chat'
import { relativeTime } from '$lib/utils' import { relativeTime } from '$lib/utils'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
import { ScrollArea } from '$lib/components/ui/scroll-area' import { ScrollArea } from '$lib/components/ui/scroll-area'
import PlusIcon from '@lucide/svelte/icons/plus' import PlusIcon from '@lucide/svelte/icons/plus'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
onMount(() => { onMount(() => {
loadSessions() loadSessions()
}) })
// Pick up sessions created/renamed elsewhere (e.g. after a turn completes).
$effect(() => { $effect(() => {
void $currentSession void $currentSession
loadSessions() loadSessions()
}) })
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
// Hide confirmation after 3s
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
function handleClick(sessionId: string) {
confirmDelete = null
loadSessionMessages(sessionId)
}
</script> </script>
<aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2"> <aside class="flex h-full w-56 shrink-0 flex-col gap-2 overflow-y-auto border-r bg-card/50 p-2">
@@ -27,10 +46,23 @@
{#each $sessions as session (session.id)} {#each $sessions as session (session.id)}
<button <button
type="button" type="button"
class="flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}" class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
onclick={() => loadSessionMessages(session.id)} onclick={() => handleClick(session.id)}
> >
<span class="w-full truncate font-medium">{session.title || 'Untitled'}</span> <span class="flex w-full items-center justify-between gap-1">
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
<span
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
>
{#if confirmDelete === session.id}
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
{:else}
<Trash2Icon class="size-3" />
{/if}
</span>
</span>
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span> <span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
</button> </button>
{:else} {:else}

View File

@@ -1,22 +1,22 @@
<script lang="ts"> <script lang="ts">
import type { ToolCallResult } from '$lib/stores/chat' import type { ToolCallResult } from '$lib/stores/chat'
import * as Collapsible from '$lib/components/ui/collapsible'
import WrenchIcon from '@lucide/svelte/icons/wrench' import WrenchIcon from '@lucide/svelte/icons/wrench'
import CheckIcon from '@lucide/svelte/icons/check' import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x' import XIcon from '@lucide/svelte/icons/x'
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down' import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
// active = this message is the one currently streaming a round of tool
// calls. The group starts open while active (so progress is visible live)
// and auto-collapses the moment that round finishes; a loaded/historical
// message is never active, so it starts collapsed. Once the effect below
// fires the one-time auto-collapse, manual toggles are left alone.
let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props() let { tools, active = false }: { tools: ToolCallResult[]; active?: boolean } = $props()
let open = $state(active) let open = $state(false)
let wasActive = active let wasActive = $state(active)
$effect(() => { $effect(() => {
if (wasActive && !active) { if (active && !wasActive) {
open = true
}
if (!active && wasActive) {
open = false open = false
} }
wasActive = active wasActive = active
@@ -24,9 +24,18 @@
const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length) const doneCount = $derived(tools.filter((t) => t.type === 'tool_result').length)
const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error)) const hasError = $derived(tools.some((t) => t.type === 'tool_result' && t.error))
const inProgress = $derived(active && doneCount < tools.length)
const names = $derived(tools.map((t) => t.name).join(', ')) const names = $derived(tools.map((t) => t.name).join(', '))
const runningTool = $derived(
active ? tools.find((t) => t.type === 'tool_use') : undefined
)
const ariaLabel = $derived(
doneCount === tools.length
? `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} completed`
: `${doneCount}/${tools.length} ${tools.length === 1 ? 'tool' : 'tools'} done`
)
function toolSummary(args: unknown): string { function toolSummary(args: unknown): string {
if (!args || typeof args !== 'object') return '' if (!args || typeof args !== 'object') return ''
return Object.entries(args as Record<string, unknown>) return Object.entries(args as Record<string, unknown>)
@@ -37,43 +46,63 @@
</script> </script>
{#if tools.length} {#if tools.length}
<details bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs"> <Collapsible.Root bind:open class="group w-fit max-w-full overflow-hidden rounded-lg border bg-card text-xs">
<summary class="flex cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden"> <Collapsible.Trigger class="flex w-full cursor-pointer select-none items-center gap-2 px-2.5 py-1.5 hover:bg-muted/50">
{#if inProgress} {#if active && doneCount < tools.length}
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" /> <LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{:else if hasError} {:else if hasError}
<XIcon class="size-3 shrink-0 text-destructive" /> <XIcon class="size-3 shrink-0 text-destructive" />
{:else} {:else}
<CheckIcon class="size-3 shrink-0 text-success" /> <CheckIcon class="size-3 shrink-0 text-success" />
{/if} {/if}
<span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<span class="max-w-64 truncate font-mono text-muted-foreground">{names}</span> {#if active && doneCount < tools.length}
<ChevronDownIcon class="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180" /> <span class="font-medium">{doneCount}/{tools.length}</span>
</summary> {#if runningTool}
<div class="flex flex-col divide-y border-t"> <span class="max-w-48 truncate font-mono text-muted-foreground">
{#each tools as tool (tool.id)} {runningTool.name}
<div class="p-2"> <span class="animate-pulse"></span>
<div class="flex items-center gap-2"> </span>
{#if tool.type === 'tool_result' && tool.error} {:else}
<XIcon class="size-3 shrink-0 text-destructive" /> <span class="animate-pulse text-muted-foreground">working…</span>
{:else if tool.type === 'tool_result'} {/if}
<CheckIcon class="size-3 shrink-0 text-success" /> {:else}
{:else} <span class="font-medium">{tools.length} tool{tools.length === 1 ? '' : 's'}</span>
<WrenchIcon class="size-3 shrink-0 animate-pulse text-primary" /> <span class="max-w-48 truncate font-mono text-muted-foreground">{names}</span>
{/if} {/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span> <ChevronDownIcon
class="size-3 shrink-0 text-muted-foreground transition-transform duration-200 {open ? 'rotate-180' : ''}"
aria-hidden="true"
/>
</Collapsible.Trigger>
<Collapsible.Content class="overflow-hidden transition-all duration-200 ease-out data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-top-2 data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-top-2">
<div class="flex flex-col divide-y border-t">
{#each tools as tool (tool.id)}
<div class="p-2">
<div class="flex items-center gap-2">
{#if tool.type === 'tool_result' && tool.error}
<XIcon class="size-3 shrink-0 text-destructive" />
{:else if tool.type === 'tool_result'}
<CheckIcon class="size-3 shrink-0 text-success" />
{:else}
<LoaderCircleIcon class="size-3 shrink-0 animate-spin text-primary" />
{/if}
<span class="font-mono font-medium">{tool.name}</span>
<span class="max-w-64 truncate text-muted-foreground">{toolSummary(tool.args)}</span>
</div>
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2">
{#if tool.args}
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre>
{/if}
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</div> </div>
<div class="mt-1 max-h-48 overflow-y-auto rounded bg-background/60 p-2"> {/each}
{#if tool.args} </div>
<pre class="whitespace-pre-wrap break-all font-mono text-[11px] text-muted-foreground">{JSON.stringify(tool.args, null, 2)}</pre> </Collapsible.Content>
{/if} </Collapsible.Root>
{#if tool.type === 'tool_result'}
<pre class="mt-1 whitespace-pre-wrap break-all font-mono text-[11px] {tool.error ? 'text-destructive' : ''}">{tool.error ?? JSON.stringify(tool.result, null, 2)}</pre>
{/if}
</div>
</div>
{/each}
</div>
</details>
{/if} {/if}

View File

@@ -1,12 +1,39 @@
import { writable, get } from 'svelte/store' import { writable, get } from 'svelte/store'
import { streamChat, fetchSessions, fetchMessages } from '$lib/api' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api'
export interface PendingApproval {
executionId: string
action: string
target: string
}
export interface ChatMessage { export interface ChatMessage {
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
text: string text: string
tools: ToolCallResult[] tools: ToolCallResult[]
pendingApprovals: PendingApproval[]
}
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
const out: PendingApproval[] = []
for (const t of tools) {
if (t.name !== 'request_execution' || t.type !== 'tool_result') continue
const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '')
if (!text.includes('requires approval')) continue
const m = text.match(APPROVAL_RE)
if (m) {
out.push({
executionId: m[1],
action: t.args?.action ?? 'unknown',
target: t.args?.target ?? 'unknown'
})
}
}
return out
} }
export interface ToolCallResult { export interface ToolCallResult {
@@ -57,12 +84,16 @@ export async function loadSessionMessages(sessionId: string) {
currentSession.set(sessionId) currentSession.set(sessionId)
const msgs = await fetchMessages(sessionId) const msgs = await fetchMessages(sessionId)
sessionMessages.set(msgs) sessionMessages.set(msgs)
const chatMsgs: ChatMessage[] = msgs.map((m) => ({ const chatMsgs: ChatMessage[] = msgs.map((m) => {
id: m.id, const tools = mergeToolCalls(m.content?.tool_calls)
role: m.role as 'user' | 'assistant', return {
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), id: m.id,
tools: mergeToolCalls(m.content?.tool_calls) role: m.role as 'user' | 'assistant',
})) text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
tools,
pendingApprovals: extractApprovals(tools)
}
})
messages.set(chatMsgs) messages.set(chatMsgs)
} }
@@ -74,7 +105,8 @@ export function sendMessage(text: string) {
id: mid(), id: mid(),
role: 'user', role: 'user',
text, text,
tools: [] tools: [],
pendingApprovals: []
} }
messages.update((ms) => [...ms, userMsg]) messages.update((ms) => [...ms, userMsg])
@@ -82,7 +114,8 @@ export function sendMessage(text: string) {
id: mid(), id: mid(),
role: 'assistant', role: 'assistant',
text: '', text: '',
tools: [] tools: [],
pendingApprovals: []
} }
messages.update((ms) => [...ms, assistantMsg]) messages.update((ms) => [...ms, assistantMsg])
@@ -147,6 +180,13 @@ export function sendMessage(text: string) {
return [...ms] return [...ms]
}) })
} else if (ev.type === 'done') { } else if (ev.type === 'done') {
messages.update((ms) => {
const last = ms[ms.length - 1]
if (last && last.role === 'assistant') {
last.pendingApprovals = extractApprovals(last.tools)
}
return [...ms]
})
currentSession.set(ev.data?.session_id ?? ev.session_id) currentSession.set(ev.data?.session_id ?? ev.session_id)
} else if (ev.type === 'error') { } else if (ev.type === 'error') {
error.set(ev.data) error.set(ev.data)
@@ -177,3 +217,12 @@ export function cancelStream() {
streaming.set(false) streaming.set(false)
} }
} }
export async function deleteSession(sessionId: string) {
const ok = await apiDeleteSession(sessionId)
if (!ok) return
if (get(currentSession) === sessionId) {
newChat()
}
loadSessions()
}

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat' import { messages, streaming, sendMessage, cancelStream, error, type PendingApproval } from '$lib/stores/chat'
import { decideApproval } from '$lib/api'
import SessionRail from '$lib/components/SessionRail.svelte' import SessionRail from '$lib/components/SessionRail.svelte'
import SessionGraph from '$lib/components/SessionGraph.svelte' import SessionGraph from '$lib/components/SessionGraph.svelte'
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte' import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
@@ -7,6 +8,10 @@
import { Textarea } from '$lib/components/ui/textarea' import { Textarea } from '$lib/components/ui/textarea'
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up' import ArrowUpIcon from '@lucide/svelte/icons/arrow-up'
import SquareIcon from '@lucide/svelte/icons/square' import SquareIcon from '@lucide/svelte/icons/square'
import CheckIcon from '@lucide/svelte/icons/check'
import XIcon from '@lucide/svelte/icons/x'
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
import { marked } from 'marked' import { marked } from 'marked'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
@@ -14,6 +19,43 @@
let input = $state('') let input = $state('')
let messagesEnd = $state<HTMLDivElement | null>(null) let messagesEnd = $state<HTMLDivElement | null>(null)
let approving = $state<string | null>(null)
let approvedIds = $state(new Set<string>())
const pendingApprovals = $derived.by(() => {
const msgs = $messages
const all: PendingApproval[] = []
for (const m of msgs) {
all.push(...m.pendingApprovals)
}
return all.filter(a => !approvedIds.has(a.executionId))
})
async function approveAll() {
for (const a of pendingApprovals) {
approving = a.executionId
await decideApproval(a.executionId, 'approve')
approvedIds.add(a.executionId)
approvedIds = approvedIds
}
approving = null
}
async function approveOne(a: PendingApproval) {
approving = a.executionId
await decideApproval(a.executionId, 'approve')
approvedIds.add(a.executionId)
approvedIds = approvedIds
approving = null
}
async function denyOne(a: PendingApproval) {
approving = a.executionId
await decideApproval(a.executionId, 'deny')
approvedIds.add(a.executionId)
approvedIds = approvedIds
approving = null
}
// Resizable right rail (session graph). Persisted so it survives reloads. // Resizable right rail (session graph). Persisted so it survives reloads.
const RAIL_MIN = 260 const RAIL_MIN = 260
@@ -141,6 +183,37 @@
</div> </div>
{/if} {/if}
{#if pendingApprovals.length > 0}
<div class="shrink-0 border-t border-warning/30 bg-warning/5 px-4 py-2">
{#each pendingApprovals as a (a.executionId)}
<div class="flex items-center gap-2">
<ShieldCheckIcon class="size-4 shrink-0 text-warning" />
<span class="flex-1 text-xs font-medium">
{a.action} on {a.target}
</span>
{#if approving === a.executionId}
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
{:else}
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => approveOne(a)}>
<CheckIcon class="size-3" />
<span class="ml-1">Approve</span>
</Button>
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={approving !== null} onclick={() => denyOne(a)}>
<XIcon class="size-3" />
<span class="ml-1">Deny</span>
</Button>
{/if}
</div>
{/each}
{#if pendingApprovals.length > 1}
<Button size="sm" variant="default" class="mt-1 h-6 px-2 text-xs" disabled={approving !== null} onclick={approveAll}>
<CheckIcon class="size-3" />
<span class="ml-1">Approve all</span>
</Button>
{/if}
</div>
{/if}
<div class="border-t bg-card/50 p-3"> <div class="border-t bg-card/50 p-3">
<form <form
class="mx-auto flex max-w-3xl items-end gap-2" class="mx-auto flex max-w-3xl items-end gap-2"

View File

@@ -1,29 +1,59 @@
<script lang="ts"> <script lang="ts">
import { sessions, loadSessions, loadSessionMessages, messages, currentSession } from '$lib/stores/chat' import { sessions, loadSessions, loadSessionMessages, deleteSession, currentSession } from '$lib/stores/chat'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import Trash2Icon from '@lucide/svelte/icons/trash-2'
onMount(() => { onMount(() => {
loadSessions() loadSessions()
}) })
let confirmDelete = $state<string | null>(null)
function handleDelete(e: MouseEvent, id: string) {
e.stopPropagation()
if (confirmDelete === id) {
deleteSession(id)
confirmDelete = null
} else {
confirmDelete = id
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
}
}
</script> </script>
<div class="sessions-page"> <div class="sessions-page">
<h2>Sessions</h2> <h2>Sessions</h2>
<div class="session-list"> <div class="session-list">
{#each $sessions as session (session.id)} {#each $sessions as session (session.id)}
<button <div
class="session-card" class="session-card group"
class:active={$currentSession === session.id} class:active={$currentSession === session.id}
onclick={() => {
loadSessionMessages(session.id)
location.hash = '#/chat'
}}
> >
<div class="session-title">{session.title || 'Untitled'}</div> <button
<div class="session-meta"> class="session-content"
{new Date(session.last_active_at).toLocaleString()} onclick={() => {
</div> loadSessionMessages(session.id)
</button> location.hash = '#/chat'
}}
>
<div class="session-title">{session.title || 'Untitled'}</div>
<div class="session-meta">
{new Date(session.last_active_at).toLocaleString()}
</div>
</button>
<button
class="session-delete"
onclick={(e) => handleDelete(e, session.id)}
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
aria-label="Delete session"
>
{#if confirmDelete === session.id}
<span class="confirm-text">Delete?</span>
{:else}
<Trash2Icon class="icon" />
{/if}
</button>
</div>
{:else} {:else}
<div class="empty">No sessions yet. Start chatting with Nomos.</div> <div class="empty">No sessions yet. Start chatting with Nomos.</div>
{/each} {/each}
@@ -54,15 +84,12 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 0.75rem 1rem;
background: var(--bg-surface); background: var(--bg-surface);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
cursor: pointer;
color: var(--text); color: var(--text);
font-family: inherit; font-family: inherit;
font-size: 0.875rem; font-size: 0.875rem;
text-align: left;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
@@ -75,6 +102,58 @@
background: var(--bg-hover); background: var(--bg-hover);
} }
.session-content {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
color: inherit;
font-family: inherit;
font-size: inherit;
text-align: left;
}
.session-delete {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
margin-right: 4px;
padding: 0;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.15s, background 0.15s;
}
.group:hover .session-delete {
opacity: 1;
}
.session-delete:hover {
background: var(--destructive-bg-subtle, rgba(239, 68, 68, 0.1));
color: var(--destructive, #ef4444);
}
.confirm-text {
font-size: 0.65rem;
font-weight: 700;
color: var(--destructive, #ef4444);
}
.icon {
width: 16px;
height: 16px;
}
.session-title { .session-title {
font-weight: 500; font-weight: 500;
} }