4 Commits

Author SHA1 Message Date
ea62d744ed feat: pct_create action, ToolCallGroup collapse+animation, inline chat approval
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
- Add pct_create to request_execution (MCP) and executeApprovedAction (httpapi)
  Parses JSON config: vmid, hostname, cores, memory, disk_gb, ip, gw, storage,
  template, privileged, nesting, mounts, nameserver, searchdomain. Creates
  entity (state=provisioning), hosts relationship, entity_status on success.
  Fixes action string parsing to use Index instead of SplitN (colons in JSON).

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

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

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

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

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -15,6 +16,15 @@ import (
) )
const maxIterations = 15 const maxIterations = 15
const maxLLMRetries = 1
var refusalDenylist = []string{
"我没有相关信息",
"您可以尝试问我其它问题",
"我无法",
"抱歉,我无法",
"关于这个问题,我没有",
}
type agent struct { type agent struct {
client *mcpClient client *mcpClient
@@ -105,14 +115,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 +149,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 +414,33 @@ func (a *agent) fleetSnapshot() string {
return summary return summary
} }
// isRefusalOrEmpty returns true when the LLM response is blank or looks like a
// canned non-English refusal to an English-language conversation. Flash-tier
// models occasionally emit Chinese boilerplate deflection instead of a real
// answer; this catches it before it reaches the UI.
func isRefusalOrEmpty(text string) bool {
if strings.TrimSpace(text) == "" {
return true
}
ascii, nonASCII := 0, 0
for _, r := range text {
if r <= 127 {
ascii++
} else {
nonASCII++
}
}
if nonASCII > ascii {
return true
}
for _, pattern := range refusalDenylist {
if strings.Contains(text, pattern) {
return true
}
}
return false
}
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) { func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
defs, err := a.client.listToolsFull() defs, err := a.client.listToolsFull()
if err != nil { if err != nil {

View File

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

View File

@@ -10,6 +10,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
) )
const maxToolResultSize = 4096
type store struct { type store struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
@@ -71,10 +73,45 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
} }
_, err := s.pool.Exec(ctx, _, err := s.pool.Exec(ctx,
`INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`, `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3)`,
sessionID, role, content) sessionID, role, truncateToolResults(content))
return err return err
} }
func truncateToolResults(content json.RawMessage) json.RawMessage {
var m map[string]any
if err := json.Unmarshal(content, &m); err != nil {
return content
}
toolCalls, ok := m["tool_calls"].([]any)
if !ok || len(toolCalls) == 0 {
return content
}
changed := false
for i, raw := range toolCalls {
tc, ok := raw.(map[string]any)
if !ok {
continue
}
if result, ok := tc["result"]; ok {
resultJSON, _ := json.Marshal(result)
if len(resultJSON) > maxToolResultSize {
tc["result"] = string(resultJSON[:maxToolResultSize]) + fmt.Sprintf("...truncated (%d bytes total)", len(resultJSON))
toolCalls[i] = tc
changed = true
}
}
}
if !changed {
return content
}
m["tool_calls"] = toolCalls
out, err := json.Marshal(m)
if err != nil {
return content
}
return out
}
func (s *store) touchSession(ctx context.Context, id string) { func (s *store) touchSession(ctx context.Context, id string) {
if s != nil { if s != nil {
s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id) s.pool.Exec(ctx, `UPDATE agent_sessions SET last_active_at=now() WHERE id=$1`, id)
@@ -126,6 +163,26 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
return out, rows.Err() return out, rows.Err()
} }
func (s *store) deleteSession(ctx context.Context, id string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
return err
}
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
if s == nil {
return nil
}
_, err := s.pool.Exec(ctx, `UPDATE agent_sessions SET title = $1 WHERE id = $2`, title, id)
return err
}
// resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos"). // resolveAgentID looks up the UUID of the agent entity (e.g. "agent:nomos").
// Returns uuid.Nil if the store is absent or the slug is unknown. // Returns uuid.Nil if the store is absent or the slug is unknown.
func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID { func (s *store) resolveAgentID(ctx context.Context, slug string) uuid.UUID {

View File

@@ -144,15 +144,12 @@ func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID,
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,6 +174,140 @@ 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 bool `json:"privileged"`
Nesting bool `json:"nesting"`
Mounts []string `json:"mounts"`
Nameserver string `json:"nameserver"`
Searchdomain string `json:"searchdomain"`
}
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, fmt.Sprintf(`{"error":"invalid pct_create params: %v"}`, err))
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": err.Error()})
return
}
if cfg.VMID == 0 || cfg.Hostname == "" {
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`,
execID, `{"error":"pct_create: vmid and hostname are required"}`)
emitExecutionEvent(ctx, pool, execID, "failed", map[string]any{"target": targetSlug, "error": "missing vmid or 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"
}
if cfg.Template == "" {
// Try to find the latest debian template
cfg.Template = "debian-13-standard_13.0-1_amd64.tar.zst"
}
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, ","))
}
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 name=eth0,bridge=vmbr0,ip=%s,gw=%s%s --start 1",
cfg.VMID, templatePath, cfg.Hostname, cfg.Cores, cfg.Memory,
cfg.Storage, cfg.DiskGB, privFlag, cfg.IP, cfg.GW, 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)
// 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`,

View File

@@ -261,11 +261,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.",
InputSchema: objSchema( InputSchema: objSchema(
prop{"target", "string", "Target entity slug (e.g. lxc:caddy)"}, prop{"target", "string", "Target entity slug (e.g. lxc:caddy, host:strong)"},
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", "Extra params: for systemctl use 'enable|disable|reload', for pct_exec use the shell command, for apt_upgrade use 'audit|upgrade', for pct_create use JSON config (see docs)"},
), ),
}, 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)
@@ -370,8 +370,13 @@ 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
} }
}) })
@@ -698,6 +703,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

View File

@@ -23,18 +23,39 @@ 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).
- `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 new LXC containers. Requires operator approval.
Once approved, the new LXC entity is created in the DB with `hosts` relationships and
`state: provisioning`. Accepts JSON params with vmid, hostname, cores, memory, disk_gb,
ip, gw, storage, template, privileged, nesting, mounts, nameserver, searchdomain.
- 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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,9 @@ 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 |
## Done ## Done
@@ -33,6 +35,7 @@ See [`done/`](done/) for executed plans:
| 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](done/2026-07-07-client-lifecycle-in-go.md) | | 2026-07-07 | [Client lifecycle in Go — enrollment through deprecation](done/2026-07-07-client-lifecycle-in-go.md) |
| 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) | | 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) |
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) | | 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) |
| 2026-07-08 | [Signal triggers: host health checks](done/2026-07-08-signal-triggers.md) |
## Conventions ## Conventions

View File

@@ -31,6 +31,11 @@ export async function fetchMessages(sessionId: string): Promise<Message[]> {
return data.messages ?? [] return data.messages ?? []
} }
export async function deleteSession(sessionId: string): Promise<boolean> {
const res = await fetch(`${BASE}/sessions/${sessionId}`, { method: 'DELETE' })
return res.ok
}
export interface ChatEvent { export interface ChatEvent {
type: string type: string
data: any data: any

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { decideApproval } from '$lib/api'
import { Button } from '$lib/components/ui/button'
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 { text }: { text: string } = $props()
const RE = /\bexec[uecution]*\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i
const match = $derived(text.match(RE))
let pending = $state(false)
let done = $state<'approved' | 'denied' | null>(null)
async function approve() {
if (!match) return
pending = true
const result = await decideApproval(match[1], 'approve')
pending = false
done = result ? 'approved' : 'denied'
}
async function deny() {
if (!match) return
pending = true
const result = await decideApproval(match[1], 'deny')
pending = false
done = result ? 'denied' : 'denied'
}
$effect(() => {
done = null
pending = false
void text
})
</script>
{#if match && !done}
<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">This action requires approval</span>
{#if pending}
<LoaderCircleIcon class="size-4 animate-spin text-muted-foreground" />
{:else}
<Button size="sm" variant="default" class="h-7 px-2.5 text-xs" onclick={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={deny}>
<XIcon class="size-3" />
<span class="ml-1">Deny</span>
</Button>
{/if}
</div>
{:else if done}
<div class="my-2 flex items-center gap-2 rounded-lg border px-3 py-2 text-xs {done === 'approved' ? 'border-success/40 bg-success/5 text-success' : 'border-destructive/40 bg-destructive/5 text-destructive'}">
{#if done === 'approved'}
<CheckIcon class="size-4" />
<span>Approved. The action is running.</span>
{:else}
<XIcon class="size-4" />
<span>Denied.</span>
{/if}
</div>
{/if}

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
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 ChatMessage { export interface ChatMessage {
@@ -177,3 +177,12 @@ export function cancelStream() {
streaming.set(false) streaming.set(false)
} }
} }
export async function deleteSession(sessionId: string) {
const ok = await apiDeleteSession(sessionId)
if (!ok) return
if (get(currentSession) === sessionId) {
newChat()
}
loadSessions()
}

View File

@@ -3,6 +3,7 @@
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'
import InlineApproval from '$lib/components/InlineApproval.svelte'
import { Button } from '$lib/components/ui/button' import { Button } from '$lib/components/ui/button'
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'
@@ -113,6 +114,7 @@
{:else} {:else}
<div class="flex w-full flex-col gap-2"> <div class="flex w-full flex-col gap-2">
<ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} /> <ToolCallGroup tools={msg.tools} active={$streaming && i === $messages.length - 1} />
<InlineApproval text={msg.text} />
{#if msg.text} {#if msg.text}
<div class="prose-chat max-w-none text-sm leading-relaxed"> <div class="prose-chat max-w-none text-sm leading-relaxed">
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify --> <!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized via DOMPurify -->

View File

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