feat: pct_create action, ToolCallGroup collapse+animation, inline chat approval
- 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.
This commit is contained in:
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
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ the actuator (a separate container with restricted SSH key) picks up.
|
|||||||
- `get_blast_radius` — understand impact before requesting action
|
- `get_blast_radius` — understand impact before requesting action
|
||||||
- `get_signal_history` — open alerts
|
- `get_signal_history` — open alerts
|
||||||
- `get_trend` — metric trends for a specific entity (single-entity only)
|
- `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
|
### Tool selection rules
|
||||||
@@ -51,6 +52,10 @@ the actuator (a separate container with restricted SSH key) picks up.
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
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,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}
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
Reference in New Issue
Block a user