From the last (successful) TypeType deploy session, two gaps the operator hit:
1. Knowledge write-back — the missing half of the loop.
The agent could read the knowledge base (search_knowledge/get_entity_knowledge)
but had no way to WRITE it, so everything it learned (the Dragonfly memlock
rlimit gotcha, the NAT-hairpin DNS issue, etc.) lived only in an ephemeral
chat message and was lost — the system could never actually "get better."
This is the `upsert_knowledge` MCP tool the 2026-07-08 gaps plan called for.
- internal/mcp/server.go: upsert_knowledge(title, content, about?, tags?,
kind?) writes a document/investigation/runbook entity + knowledge_entities
row (search column is generated), upserts by slug so re-titling updates in
place, and optionally links it to the entity it's about so
get_entity_knowledge surfaces it there.
- SOUL.md: capture non-obvious findings/deploys/gotchas as part of finishing
work, not only when asked "what did we learn".
2. "I had to ask for status multiple times."
The clearest cause: a long working turn (64 tool calls) that exhausted the
iteration cap ended with a bare "max iterations reached without final
answer" — a dead end that forced the operator to ask what happened.
- cmd/nomos/agent.go: on exhaustion, make one final no-tools LLM call
(finalSummary) asking for a status report — what was accomplished, current
state, what remains — so the turn always ends with a real outcome.
- maxIterations 25 -> 40 (the decomposed per-step pct_create flow legitimately
needs more steps).
- SOUL.md: always end a turn with a clear outcome; never end silently or on a
bare tool call — the operator can't see the tools working and reads silence
as "nothing happened".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
295 lines
9.5 KiB
TypeScript
295 lines
9.5 KiB
TypeScript
import { writable, get } from 'svelte/store'
|
|
import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api'
|
|
import type { ChatEvent, Session, Message } from '$lib/api'
|
|
|
|
export interface PendingApproval {
|
|
executionId: string
|
|
action: string
|
|
target: string
|
|
destructive: boolean
|
|
command?: string
|
|
purpose?: string
|
|
}
|
|
|
|
export interface ChatMessage {
|
|
id: string
|
|
role: 'user' | 'assistant'
|
|
text: string
|
|
tools: ToolCallResult[]
|
|
pendingApprovals: PendingApproval[]
|
|
}
|
|
|
|
const APPROVAL_RE = /execution\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
|
|
|
|
// Deliberately NOT filtered by tool name. There is no fixed set of gated
|
|
// tools — `run` can execute anything, and any future tool that queues an
|
|
// approval should surface a card the same way. A prior version hardcoded
|
|
// `t.name === 'request_execution'`, so approvals raised by the newer `run`
|
|
// tool were silently invisible in chat: no card, no feedback, nothing to
|
|
// self-heal, forcing the operator to the Ops page with zero acknowledgement
|
|
// back in the conversation. Matching on the response shape (not the tool
|
|
// name) is what makes this robust to new gated tools without another
|
|
// silent breakage.
|
|
function extractApprovals(tools: ToolCallResult[]): PendingApproval[] {
|
|
const out: PendingApproval[] = []
|
|
for (const t of tools) {
|
|
if (t.type !== 'tool_result') continue
|
|
const text = typeof t.result === 'string' ? t.result : JSON.stringify(t.result ?? '')
|
|
if (!text.includes('requires approval')) continue
|
|
const m = text.match(APPROVAL_RE)
|
|
if (m) {
|
|
out.push({
|
|
executionId: m[1],
|
|
action: t.args?.action ?? t.args?.purpose ?? t.name ?? 'unknown',
|
|
target: t.args?.target ?? 'unknown',
|
|
destructive: /\bDESTRUCTIVE\b/.test(text),
|
|
command: t.args?.command,
|
|
purpose: t.args?.purpose
|
|
})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
export interface ToolCallResult {
|
|
type: 'tool_use' | 'tool_result'
|
|
name: string
|
|
id?: string
|
|
args?: any
|
|
result?: any
|
|
error?: string
|
|
}
|
|
|
|
function mid(): string {
|
|
return crypto.randomUUID()
|
|
}
|
|
|
|
export const messages = writable<ChatMessage[]>([])
|
|
export const streaming = writable(false)
|
|
export const currentSession = writable<string | null>(null)
|
|
export const sessions = writable<Session[]>([])
|
|
export const sessionMessages = writable<Message[]>([])
|
|
export const error = writable<string | null>(null)
|
|
|
|
let activeController: AbortController | null = null
|
|
|
|
export async function loadSessions() {
|
|
const list = await fetchSessions()
|
|
sessions.set(list)
|
|
}
|
|
|
|
// mergeToolCalls collapses a persisted tool_calls array into one entry per
|
|
// call id. Nomos persists the tool_use and tool_result as two separate
|
|
// entries sharing the same id (matching the SSE event pair); Chat.svelte
|
|
// renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate
|
|
// keys and silently aborts the whole message list. Live-streamed messages
|
|
// never hit this because sendMessage() merges tool_result into the existing
|
|
// tool_use entry in place rather than appending a second one.
|
|
function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
|
|
const byId = new Map<string, ToolCallResult>()
|
|
for (const tc of raw ?? []) {
|
|
const key = tc.id ?? crypto.randomUUID()
|
|
const existing = byId.get(key)
|
|
byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key })
|
|
}
|
|
return Array.from(byId.values())
|
|
}
|
|
|
|
function toChatMessages(msgs: Message[]): ChatMessage[] {
|
|
return msgs.map((m) => {
|
|
const tools = mergeToolCalls(m.content?.tool_calls)
|
|
return {
|
|
id: m.id,
|
|
role: m.role as 'user' | 'assistant',
|
|
text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''),
|
|
tools,
|
|
pendingApprovals: extractApprovals(tools)
|
|
}
|
|
})
|
|
}
|
|
|
|
export async function loadSessionMessages(sessionId: string) {
|
|
currentSession.set(sessionId)
|
|
const msgs = await fetchMessages(sessionId)
|
|
sessionMessages.set(msgs)
|
|
messages.set(toChatMessages(msgs))
|
|
startPolling(sessionId)
|
|
}
|
|
|
|
// Live visibility for autonomous work: the auto-continuation worker (see
|
|
// cmd/nomos/continue.go) runs entirely server-side and has no live push —
|
|
// previously the only way to see its result was to manually reload the
|
|
// session, so approving a plan and then waiting felt like nothing was
|
|
// happening even while the agent was actively working. This polls the
|
|
// session's persisted messages every few seconds and merges in anything new
|
|
// (an auto-continuation's result, a fresh pending approval it queued, etc.)
|
|
// so the transcript updates on its own. Only runs between turns — never
|
|
// while a live streaming turn owns the message list, to avoid clobbering the
|
|
// in-progress optimistic UI.
|
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
let pollingSessionId: string | null = null
|
|
|
|
function startPolling(sessionId: string) {
|
|
stopPolling()
|
|
pollingSessionId = sessionId
|
|
pollTimer = setInterval(async () => {
|
|
if (get(streaming)) return
|
|
if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return
|
|
const msgs = await fetchMessages(sessionId)
|
|
if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time
|
|
// No cheap "anything new?" check: the auto-continuation worker updates a
|
|
// placeholder message IN PLACE as each tool call lands (see
|
|
// cmd/nomos/continue.go), so the message COUNT stays the same while the
|
|
// content changes — a length-only diff (the previous version of this
|
|
// code) never detected those updates and progress looked frozen even
|
|
// though the backend was actively working. Just re-set every tick;
|
|
// Svelte's own diffing keeps the actual re-render cheap.
|
|
sessionMessages.set(msgs)
|
|
messages.set(toChatMessages(msgs))
|
|
}, 3000)
|
|
}
|
|
|
|
export function stopPolling() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer)
|
|
pollTimer = null
|
|
}
|
|
pollingSessionId = null
|
|
}
|
|
|
|
export function sendMessage(text: string) {
|
|
error.set(null)
|
|
streaming.set(true)
|
|
|
|
const userMsg: ChatMessage = {
|
|
id: mid(),
|
|
role: 'user',
|
|
text,
|
|
tools: [],
|
|
pendingApprovals: []
|
|
}
|
|
messages.update((ms) => [...ms, userMsg])
|
|
|
|
const assistantMsg: ChatMessage = {
|
|
id: mid(),
|
|
role: 'assistant',
|
|
text: '',
|
|
tools: [],
|
|
pendingApprovals: []
|
|
}
|
|
messages.update((ms) => [...ms, assistantMsg])
|
|
|
|
let activeTools: Map<string, ToolCallResult> = new Map()
|
|
|
|
activeController = streamChat(
|
|
text,
|
|
get(currentSession), // continue the active session so the agent keeps context
|
|
(ev: ChatEvent) => {
|
|
if (ev.type === 'session') {
|
|
currentSession.set(ev.data)
|
|
} else if (ev.type === 'tool_use') {
|
|
const tr: ToolCallResult = {
|
|
type: 'tool_use',
|
|
name: ev.data.name,
|
|
id: ev.data.id,
|
|
args: ev.data.args
|
|
}
|
|
activeTools.set(ev.data.id, tr)
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.tools = [...last.tools, tr]
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'tool_result') {
|
|
const existing = activeTools.get(ev.data.id)
|
|
if (existing) {
|
|
const updated: ToolCallResult = {
|
|
...existing,
|
|
type: 'tool_result',
|
|
result: ev.data.result,
|
|
error: ev.data.error
|
|
}
|
|
activeTools.set(ev.data.id, updated)
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.tools = last.tools.map((t) =>
|
|
t.id === ev.data.id ? updated : t
|
|
)
|
|
}
|
|
return [...ms]
|
|
})
|
|
}
|
|
} else if (ev.type === 'text_delta') {
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.text += ev.data
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'text') {
|
|
// Final authoritative content for the turn; replaces accumulated deltas.
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.text = ev.data
|
|
}
|
|
return [...ms]
|
|
})
|
|
} else if (ev.type === 'done') {
|
|
messages.update((ms) => {
|
|
const last = ms[ms.length - 1]
|
|
if (last && last.role === 'assistant') {
|
|
last.pendingApprovals = extractApprovals(last.tools)
|
|
}
|
|
return [...ms]
|
|
})
|
|
const sid = ev.data?.session_id ?? ev.session_id
|
|
currentSession.set(sid)
|
|
// Start polling for auto-continuation results now that the live turn
|
|
// is over — this is what makes an approved plan's later steps show up
|
|
// on their own instead of requiring a manual reload.
|
|
if (sid) startPolling(sid)
|
|
} else if (ev.type === 'error') {
|
|
error.set(ev.data)
|
|
}
|
|
},
|
|
(err: string) => {
|
|
error.set(err)
|
|
},
|
|
() => {
|
|
streaming.set(false)
|
|
activeController = null
|
|
loadSessions()
|
|
}
|
|
)
|
|
}
|
|
|
|
export function newChat() {
|
|
cancelStream()
|
|
stopPolling()
|
|
currentSession.set(null)
|
|
messages.set([])
|
|
error.set(null)
|
|
}
|
|
|
|
export function cancelStream() {
|
|
if (activeController) {
|
|
activeController.abort()
|
|
activeController = null
|
|
streaming.set(false)
|
|
}
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string) {
|
|
const ok = await apiDeleteSession(sessionId)
|
|
if (!ok) return
|
|
if (get(currentSession) === sessionId) {
|
|
newChat()
|
|
}
|
|
loadSessions()
|
|
}
|