Compare commits
4 Commits
claude/cha
...
ea62d744ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 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"
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
67
web/src/lib/components/InlineApproval.svelte
Normal file
67
web/src/lib/components/InlineApproval.svelte
Normal 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}
|
||||||
@@ -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 = 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,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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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