Compare commits
14 Commits
claude/cha
...
4a96f46e76
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a96f46e76 | |||
| 5f888e6386 | |||
| f248508919 | |||
| a1f666f68a | |||
| ac86302f52 | |||
| 8ed2b88495 | |||
| b37f85ae08 | |||
| a567930466 | |||
| 9376dc7d89 | |||
| d9683cfe29 | |||
| ea62d744ed | |||
| 0d29b1db81 | |||
| e92a6ff7a5 | |||
| 49c37fe8b1 |
@@ -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))):
|
||||||
|
|||||||
82
.agents/skills/session-review/SKILL.md
Normal file
82
.agents/skills/session-review/SKILL.md
Normal 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
131
internal/httpapi/pct_create_test.go
Normal file
131
internal/httpapi/pct_create_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
56
internal/mcp/httpget_test.go
Normal file
56
internal/mcp/httpget_test.go
Normal 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 & 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1–4 core scope; not yet deployed to the
|
**Status:** In Progress — Phases 1–4 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
|
||||||
|
|||||||
156
plans/2026-07-09-session-execution-and-ux-fixes.md
Normal file
156
plans/2026-07-09-session-execution-and-ux-fixes.md
Normal 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.
|
||||||
189
plans/2026-07-10-general-gated-execution.md
Normal file
189
plans/2026-07-10-general-gated-execution.md
Normal 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?
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
96
web/src/lib/components/InlineApproval.svelte
Normal file
96
web/src/lib/components/InlineApproval.svelte
Normal 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}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user