From 4bd03d6f18a28e9052df83f4c463df9f436c18c8 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:46:53 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(bridge):=20AgenticTurnBrainClient=20?= =?UTF-8?q?=E2=80=94=20the=20autonomous=20agent=20tool-loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1 HeadlessTurnBrainClient does one engine dispatch per turn and declares clientCapabilities:'unsupported'. This adds the v2 brain the BrainClient contract always anticipated: a bounded ReAct loop that pulls client-lent tools mid-turn. - registerCapability/unregisterCapability store tools a client (the browser panel) lends the brain; runTurn runs the loop: dispatch engine → parse a __AGON_TOOL__ {name,input} sentinel → yield capability-request → await provideCapabilityResult → feed the result back into the transcript → repeat → final answer. - Destructive tools (CapabilitySpec.isDestructive) first yield approval-request and await provideApproval; approve-session/deny-session are remembered for the turn- brain so the same tool isn't gated twice. 'abort' ends the turn. - Engine-agnostic: parseAgentToolCall is forgiving (tolerates prose/fences, a garbled/absent sentinel reads as a final answer). Bounded by MAX_AGENT_STEPS; every capability/approval await is abort-aware and times out (no hung brain). - An agent screenshot (a 'data:' result) is decoded and shown to the engine as vision on the next dispatch, not dumped as text. - Declares clientCapabilities:'supported', approvalArbitration:'host-only'. 24 unit tests: the loop (read tool, approval gate, deny/abort, approve-session, unknown tool, step limit), the control surface (acks, detach cleanup, mid-turn cancel), and the pure helpers (forgiving sentinel parse, prompt/transcript build). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../generated/bridge/agentic-brain-client.ts | 458 +++++++++++++++++ .../src/kern/bridge/agentic-brain-client.kern | 485 ++++++++++++++++++ tests/unit/agentic-brain-client.test.ts | 263 ++++++++++ 3 files changed, 1206 insertions(+) create mode 100644 packages/cli/src/generated/bridge/agentic-brain-client.ts create mode 100644 packages/cli/src/kern/bridge/agentic-brain-client.kern create mode 100644 tests/unit/agentic-brain-client.test.ts diff --git a/packages/cli/src/generated/bridge/agentic-brain-client.ts b/packages/cli/src/generated/bridge/agentic-brain-client.ts new file mode 100644 index 00000000..05c21528 --- /dev/null +++ b/packages/cli/src/generated/bridge/agentic-brain-client.ts @@ -0,0 +1,458 @@ +// @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/bridge/agentic-brain-client.kern + +import type { BrainClient, BrainClientConfig, BrainEvent, BrainTurnRequest, BrainTurnResult, ControlAck, ControlCapabilities, ClientRef, ApprovalResponse, AnswerResponse, SteerRequest, CancelRequest, CapabilityRegistration, CapabilityUnregister, CapabilityResult, CapabilitySpec, BrainHealth, EngineAdapter, EngineRegistry } from '@kernlang/agon-core'; + +import { buildImageAttachment, decodeDataUrlToImageFile, MAX_DISPATCH_IMAGES } from '@kernlang/agon-core'; + +import { createCliAdapter } from '@kernlang/agon-adapter-cli'; + +import { join } from 'node:path'; + +import { tmpdir } from 'node:os'; + +import { rmSync } from 'node:fs'; + +import { randomUUID } from 'node:crypto'; + +// @kern-source: agentic-brain-client:36 +export const AGENT_TOOL_MARKER: string = '__AGON_TOOL__'; + +// @kern-source: agentic-brain-client:37 +export const MAX_AGENT_STEPS: number = 8; + +// @kern-source: agentic-brain-client:38 +export const CAPABILITY_RESULT_TIMEOUT_MS: number = 60_000; + +// @kern-source: agentic-brain-client:39 +export const APPROVAL_TIMEOUT_MS: number = 180_000; + +/** + * Extract an agent tool call from an engine's stdout: locate the AGENT_TOOL_MARKER and parse the first balanced {…} JSON object after it. Forgiving — surrounding prose or a ```code fence``` is tolerated, and a missing/garbled sentinel returns null (the caller treats null as a final prose answer). Returns null unless the object has a string `name`; a non-object `input` defaults to {}. + */ +// @kern-source: agentic-brain-client:43 +export function parseAgentToolCall(stdout: string): { name: string; input: Record } | null { + const idx = stdout.indexOf(AGENT_TOOL_MARKER); + if (idx === -1) return null; + const after = stdout.slice(idx + AGENT_TOOL_MARKER.length); + const start = after.indexOf('{'); + if (start === -1) return null; + let depth = 0; + let end = -1; + let inStr = false; + let esc = false; + for (let i = start; i < after.length; i++) { + const c = after[i]; + if (inStr) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === '{') depth++; + else if (c === '}') { depth--; if (depth === 0) { end = i; break; } } + } + if (end === -1) return null; + try { + const obj = JSON.parse(after.slice(start, end + 1)) as { name?: unknown; input?: unknown }; + if (obj && typeof obj.name === 'string') { + const input = (obj.input && typeof obj.input === 'object') ? obj.input as Record : {}; + return { name: obj.name, input }; + } + } catch { return null; } + return null; +} + +/** + * The agent's system prompt: the ReAct tool protocol plus the catalog of client-lent capabilities. Read-only vs destructive is surfaced so the engine knows which actions trigger the user's approval gate. + */ +// @kern-source: agentic-brain-client:78 +export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): string { + const lines: string[] = []; + if (base && base.trim().length > 0) { lines.push(base.trim(), ''); } + lines.push( + "You are Agon, an AI agent operating INSIDE the user's web browser via a side panel.", + 'You can inspect and act on the page the user is viewing, using ONLY the tools below.', + '', + 'TOOLS:', + ); + if (tools.length === 0) { + lines.push(' (none registered — answer from your own knowledge.)'); + } else { + for (const t of tools) { + const tag = t.isDestructive ? '(ACTS on the page — the user must approve)' : '(read-only)'; + lines.push(`- ${t.name} ${tag}: ${t.description}`); + lines.push(` input: ${JSON.stringify(t.inputSchema)}`); + } + } + lines.push( + '', + 'TO USE A TOOL, reply with EXACTLY one line and nothing else:', + `${AGENT_TOOL_MARKER} {"name":"","input":{ ... }}`, + 'Use ONE tool per step. You will then receive its result and may call another tool or answer.', + 'Read the page before acting on it. Never fabricate a tool result or claim an action you did not take.', + 'When you have the final answer for the user, reply normally in prose with NO tool line.', + ); + return lines.join('\n'); +} + +/** + * The growing ReAct transcript re-sent to the (stateless, exec-mode) engine each step: the original request plus every prior tool call and its result, ending with a nudge to act or answer. + */ +// @kern-source: agentic-brain-client:109 +export function renderAgentTranscript(userInput: string, steps: Array<{ name: string; input: Record; output: string }>): string { + const lines: string[] = [`User request: ${userInput}`, '']; + if (steps.length === 0) { + lines.push('No tools have run yet. Decide your first tool call, or answer directly.'); + return lines.join('\n'); + } + lines.push('Tool history so far (most recent last):'); + for (const s of steps) { + lines.push(`> ${s.name}(${JSON.stringify(s.input)})`); + lines.push(`< ${s.output}`); + } + lines.push('', 'Based on the results above, call the next tool or give your final answer.'); + return lines.join('\n'); +} + +/** + * A compact, human-readable one-liner for the approval popup's `command` field — what the agent is about to do. + */ +// @kern-source: agentic-brain-client:126 +export function describeAgentAction(name: string, input: Record): string { + let arg = ''; + try { arg = JSON.stringify(input); } catch { arg = '{…}'; } + if (arg.length > 160) arg = arg.slice(0, 157) + '…'; + return `${name}(${arg})`; +} + +/** + * v2 BrainClient: a bounded ReAct tool-loop over one engine, with client-lent capabilities (registerCapability) the brain pulls mid-turn via capability-request, and a per-action approval gate for destructive tools. Construct with the daemon's EngineRegistry; open() binds engine/cwd; runTurn() drives the loop; provideCapabilityResult/provideApproval answer the *-request events by requestId. + */ +// @kern-source: agentic-brain-client:137 +export class AgenticTurnBrainClient implements BrainClient { + private registry: EngineRegistry; + private adapter: EngineAdapter; + private engineId: string; + private cwd: string; + private systemPrompt: string|undefined; + private startedAtMs: number; + private activeTurnId: string|null; + private aborts: Map; + private caps: Map; + private pendingCaps: Map void>; + private pendingApprovals: Map void>; + private approvedSession: Set; + private deniedSession: Set; + controlCapabilities: ControlCapabilities; + + constructor(registry: EngineRegistry) { + this.registry = registry; + this.adapter = createCliAdapter(registry); + this.engineId = 'claude'; + this.cwd = process.cwd(); + this.systemPrompt = undefined; + this.startedAtMs = Date.now(); + this.activeTurnId = null; + this.aborts = new Map(); + this.caps = new Map(); + this.pendingCaps = new Map(); + this.pendingApprovals = new Map(); + this.approvedSession = new Set(); + this.deniedSession = new Set(); + this.controlCapabilities = { concurrentTurns: 'per-session-serialized', concurrentSteering: 'unsupported', approvalArbitration: 'host-only', questionArbitration: 'unsupported', clientCapabilities: 'supported', cancellation: 'per-turn' }; + this.open = this.open.bind(this); + this.runTurn = this.runTurn.bind(this); + this.steer = this.steer.bind(this); + this.provideApproval = this.provideApproval.bind(this); + this.provideAnswer = this.provideAnswer.bind(this); + this.registerCapability = this.registerCapability.bind(this); + this.unregisterCapability = this.unregisterCapability.bind(this); + this.provideCapabilityResult = this.provideCapabilityResult.bind(this); + this.cancel = this.cancel.bind(this); + this.notifyClientAttached = this.notifyClientAttached.bind(this); + this.notifyClientDetached = this.notifyClientDetached.bind(this); + this.health = this.health.bind(this); + this.close = this.close.bind(this); + } + + async open(config: BrainClientConfig): Promise { + this.engineId = config.engineId; + this.cwd = config.cwd; + this.systemPrompt = config.systemPrompt; + } + + private async waitForCapabilityResult(requestId: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error('aborted')); return; } + let timer: ReturnType; + const onAbort = () => { this.pendingCaps.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; + timer = setTimeout(() => { this.pendingCaps.delete(requestId); signal.removeEventListener('abort', onAbort); reject(new Error('timeout')); }, CAPABILITY_RESULT_TIMEOUT_MS); + signal.addEventListener('abort', onAbort, { once: true }); + this.pendingCaps.set(requestId, (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); }); + }); + } + + private async waitForApproval(requestId: string, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error('aborted')); return; } + let timer: ReturnType; + const onAbort = () => { this.pendingApprovals.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; + timer = setTimeout(() => { this.pendingApprovals.delete(requestId); signal.removeEventListener('abort', onAbort); resolve('deny'); }, APPROVAL_TIMEOUT_MS); + signal.addEventListener('abort', onAbort, { once: true }); + this.pendingApprovals.set(requestId, (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); }); + }); + } + + async *runTurn(req: BrainTurnRequest): AsyncGenerator { + // KERN-GAP: kern-review (undefined-reference) — in a raw `<<<` stream handler a + // `yield { ...objectLiteral }` is mis-parsed (its keys flagged undeclared). + // Workaround used throughout: assign each BrainEvent to a typed const, yield it. + const turnEngineId = (req.engineId && this.registry.listIds().includes(req.engineId)) ? req.engineId : this.engineId; + if (this.activeTurnId !== null) { + const reason = `brain busy with turn ${this.activeTurnId} (single-writer)`; + const busy: BrainEvent = { kind: 'notice', level: 'error', message: reason }; + yield busy; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + this.activeTurnId = req.turnId; + const ctrl = new AbortController(); + this.aborts.set(req.turnId, ctrl); + const safeTurn = req.turnId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 200) || 'turn'; + const turnDir = join(tmpdir(), 'agon-brain-turns', safeTurn); + try { + const engine = this.registry.get(turnEngineId); + // Step-0 vision: a screenshot the user attached to the turn (frontend-inspector). + // A screenshot the AGENT later captures (a 'data:' capability result) is decoded + // and shown on the NEXT dispatch so the agent can actually SEE what it grabbed. + type Att = NonNullable>; + let nextImages: Att[] = []; + const allImages = req.images ?? []; + const rawImages = allImages.slice(0, MAX_DISPATCH_IMAGES); + if (allImages.length > rawImages.length) { + const capped: BrainEvent = { kind: 'notice', level: 'warning', message: `only the first ${MAX_DISPATCH_IMAGES} of ${allImages.length} images are used` }; + yield capped; + } + for (let i = 0; i < rawImages.length; i++) { + const raw = rawImages[i]; + if (raw.startsWith('data:')) { + const decoded = decodeDataUrlToImageFile(raw, turnDir, i); + if (decoded.path) { const att = buildImageAttachment(decoded.path, this.cwd); if (att) nextImages.push(att); } + else if (decoded.reason) { const skip: BrainEvent = { kind: 'notice', level: 'warning', message: `image skipped: ${decoded.reason}` }; yield skip; } + } else { + const att = buildImageAttachment(raw, this.cwd); if (att) nextImages.push(att); + } + } + + const tools = [...this.caps.values()].map((c) => c.spec); + const sysPrompt = buildAgentSystemPrompt(tools, this.systemPrompt); + const steps: Array<{ name: string; input: Record; output: string }> = []; + let imgSeq = rawImages.length; + + for (let step = 0; step < MAX_AGENT_STEPS; step++) { + if (ctrl.signal.aborted) break; + const thinking: BrainEvent = { kind: 'notice', level: 'info', message: `${turnEngineId} thinking… (step ${step + 1}/${MAX_AGENT_STEPS})` }; + yield thinking; + const prompt = renderAgentTranscript(req.input, steps); + const dispatchImages = nextImages.length > 0 ? nextImages : undefined; + nextImages = []; // images are shown once, on the dispatch right after capture + const result = await this.adapter.dispatch({ + engine, prompt, cwd: this.cwd, mode: 'exec', timeout: 120, + outputDir: turnDir, signal: ctrl.signal, systemPrompt: sysPrompt, images: dispatchImages, + }); + const stdout = (result.stdout || '').trim(); + if (result.exitCode !== 0 || result.timedOut || stdout.length === 0) { + const reason = ctrl.signal.aborted ? 'cancelled by client' + : result.timedOut ? `${turnEngineId} timed out` + : `${turnEngineId} produced no answer (exit ${result.exitCode})`; + const note: BrainEvent = { kind: 'notice', level: ctrl.signal.aborted ? 'warning' : 'error', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + + const call = parseAgentToolCall(stdout); + if (!call) { + // No tool call → the engine's final answer. + const ans: BrainEvent = { kind: 'engine', engineId: turnEngineId, content: stdout }; + yield ans; + return { turnId: req.turnId, delegated: false, responded: true, engineId: turnEngineId }; + } + + const cap = this.caps.get(call.name); + if (!cap) { + const avail = [...this.caps.keys()].join(', ') || '(none)'; + steps.push({ name: call.name, input: call.input, output: `ERROR: no tool "${call.name}". Available tools: ${avail}.` }); + continue; + } + + const running: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'running', input: describeAgentAction(call.name, call.input) }; + yield running; + + // Approval gate for destructive tools (skipped if approved-for-session). + if (cap.spec.isDestructive && !this.approvedSession.has(call.name)) { + if (this.deniedSession.has(call.name)) { + const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied for this session' }; + yield denied; + steps.push({ name: call.name, input: call.input, output: 'DENIED by the user for this session — do not retry this tool; try another approach or tell the user.' }); + continue; + } + const apId = randomUUID(); + const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description }; + yield ask; + let decision: string; + try { decision = await this.waitForApproval(apId, ctrl.signal); } + catch { + const reason = 'cancelled by client'; + const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + if (decision === 'abort') { + const reason = 'the user aborted the turn at an approval prompt'; + const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + if (decision === 'approve-session') this.approvedSession.add(call.name); + if (decision === 'deny' || decision === 'deny-session') { + if (decision === 'deny-session') this.deniedSession.add(call.name); + const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied by the user' }; + yield denied; + steps.push({ name: call.name, input: call.input, output: 'DENIED by the user — do not retry; try another approach or explain what you would have done.' }); + continue; + } + // 'approve' / 'approve-session' fall through to execution. + } + + // Execute: pull the capability from the owning client. + const reqId = randomUUID(); + const capReq: BrainEvent = { kind: 'capability-request', requestId: reqId, capability: call.name, input: call.input, targetClientId: cap.clientId }; + yield capReq; + let capRes: CapabilityResult; + try { capRes = await this.waitForCapabilityResult(reqId, ctrl.signal); } + catch (err) { + const aborted = ctrl.signal.aborted; + const failNote: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: aborted ? 'cancelled' : 'the browser did not respond' }; + yield failNote; + const reason = aborted ? 'cancelled by client' : `tool "${call.name}" timed out (the browser client did not respond)`; + const note: BrainEvent = { kind: 'notice', level: aborted ? 'warning' : 'error', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + + const rawOut = capRes.ok ? (capRes.output ?? '(no output)') : `ERROR: ${capRes.error ?? 'the tool failed'}`; + // A 'data:' image result (e.g. an agent screenshot) is decoded and shown to + // the engine on the next dispatch as vision, not dumped into the text transcript. + let transcriptOut = rawOut; + if (capRes.ok && rawOut.startsWith('data:')) { + const decoded = decodeDataUrlToImageFile(rawOut, turnDir, imgSeq++); + if (decoded.path) { + const att = buildImageAttachment(decoded.path, this.cwd); + if (att) { nextImages = [att]; transcriptOut = '(screenshot captured — it is attached to your view this step)'; } + else transcriptOut = '(screenshot captured but could not be attached)'; + } else { + transcriptOut = `(screenshot could not be read${decoded.reason ? `: ${decoded.reason}` : ''})`; + } + } + const done: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: capRes.ok ? 'done' : 'error', output: transcriptOut.slice(0, 400) }; + yield done; + steps.push({ name: call.name, input: call.input, output: transcriptOut.slice(0, 4000) }); + } + + if (ctrl.signal.aborted) { + const reason = 'cancelled by client'; + const cancelled: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield cancelled; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + const reason = `reached the ${MAX_AGENT_STEPS}-step limit without a final answer`; + const limit: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield limit; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } catch (err) { + if (ctrl.signal.aborted) { + const reason = 'cancelled by client'; + const cancelled: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield cancelled; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + const reason = `turn failed: ${err instanceof Error ? err.message : String(err)}`; + const failed: BrainEvent = { kind: 'notice', level: 'error', message: reason }; + yield failed; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } finally { + this.aborts.delete(req.turnId); + if (this.activeTurnId === req.turnId) this.activeTurnId = null; + try { rmSync(turnDir, { recursive: true, force: true }); } catch { /* best-effort scratch cleanup */ } + } + } + + async cancel(req: CancelRequest): Promise { + const ctrl = this.aborts.get(req.turnId); + if (!ctrl) return { status: 'rejected', reason: `no active turn ${req.turnId}` }; + ctrl.abort(); + return { status: 'accepted' }; + } + + async registerCapability(reg: CapabilityRegistration): Promise { + this.caps.set(reg.spec.name, { clientId: reg.clientId, spec: reg.spec }); + return { status: 'accepted' }; + } + + async unregisterCapability(req: CapabilityUnregister): Promise { + this.caps.delete(req.name); + return { status: 'accepted' }; + } + + async provideCapabilityResult(res: CapabilityResult): Promise { + const resolve = this.pendingCaps.get(res.requestId); + if (!resolve) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + this.pendingCaps.delete(res.requestId); + resolve(res); + return { status: 'accepted' }; + } + + async provideApproval(res: ApprovalResponse): Promise { + const resolve = this.pendingApprovals.get(res.requestId); + if (!resolve) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + this.pendingApprovals.delete(res.requestId); + resolve(res.decision); + return { status: 'accepted' }; + } + + async steer(_req: SteerRequest): Promise { + return { status: 'unsupported', reason: 'agent v2 has no mid-turn steering yet (controlCapabilities.concurrentSteering)' }; + } + + async provideAnswer(_res: AnswerResponse): Promise { + return { status: 'unsupported', reason: 'agent v2 asks no free-text mid-turn questions yet (controlCapabilities.questionArbitration)' }; + } + + notifyClientAttached(_sessionId: string, _client: ClientRef): void { + /* capabilities are (re)registered explicitly by the client on attach; nothing to pre-track */ + } + + notifyClientDetached(_sessionId: string, clientId: string): void { + for (const [name, c] of [...this.caps]) { if (c.clientId === clientId) this.caps.delete(name); } + } + + async health(): Promise { + return { alive: true, engineId: this.engineId, pid: null, activeTurnId: this.activeTurnId, queuedTurns: 0, uptimeMs: Date.now() - this.startedAtMs }; + } + + async close(): Promise { + for (const ctrl of this.aborts.values()) ctrl.abort(); + this.aborts.clear(); + this.pendingCaps.clear(); + this.pendingApprovals.clear(); + this.activeTurnId = null; + } +} + +/** + * Factory mirroring createHeadlessTurnBrainClient: build the v2 agentic tool-loop BrainClient from the daemon's EngineRegistry. + */ +// @kern-source: agentic-brain-client:481 +export function createAgenticTurnBrainClient(registry: EngineRegistry): BrainClient { + return new AgenticTurnBrainClient(registry); +} diff --git a/packages/cli/src/kern/bridge/agentic-brain-client.kern b/packages/cli/src/kern/bridge/agentic-brain-client.kern new file mode 100644 index 00000000..5e101a08 --- /dev/null +++ b/packages/cli/src/kern/bridge/agentic-brain-client.kern @@ -0,0 +1,485 @@ +// ── AgenticTurnBrainClient — the v2 BrainClient: an autonomous tool-loop ────── +// +// Where HeadlessTurnBrainClient (the v1 "ask" brain) does ONE engine dispatch per +// turn and declares clientCapabilities:'unsupported', THIS brain implements the +// agent tool-loop the contract (brain-client.kern) always anticipated: a CLIENT +// (the browser extension) lends the brain tools via registerCapability, and the +// brain — mid-turn — autonomously pulls them. It runs a bounded ReAct loop over the +// SAME adapter.dispatch({mode:'exec'}) path: +// +// dispatch(engine, transcript) → parse a __AGON_TOOL__ {name,input} sentinel +// → yield a capability-request BrainEvent (the daemon/SSE routes it to the +// owning client) → await provideCapabilityResult → feed the result back into +// the transcript → dispatch again … until the engine answers with no tool +// call, or a step/abort/timeout limit trips. +// +// Destructive tools (CapabilitySpec.isDestructive) first yield an approval-request +// and await provideApproval — the load-bearing "ask before acting" gate (the panel +// toggle decides whether the client auto-approves). approve-session / deny-session +// are remembered per turn-brain so the agent isn't gated twice for the same tool. +// +// This is ENGINE-AGNOSTIC: any text engine that can emit the sentinel drives the +// loop (claude/codex are reliable; the parse is forgiving — fences/prose around the +// JSON are tolerated, a missing/garbled sentinel reads as a final answer). It is a +// NEW brain behind the UNCHANGED BrainClient interface — no protocol/client break, +// exactly the swap the contract promised. The fuller headless-Cesar brain (routing, +// confidence gate, steering) remains the deferred swap; this is the focused agent. + +import from="@kernlang/agon-core" names="BrainClient,BrainClientConfig,BrainEvent,BrainTurnRequest,BrainTurnResult,ControlAck,ControlCapabilities,ClientRef,ApprovalResponse,AnswerResponse,SteerRequest,CancelRequest,CapabilityRegistration,CapabilityUnregister,CapabilityResult,CapabilitySpec,BrainHealth,EngineAdapter,EngineRegistry" types=true +import from="@kernlang/agon-core" names="buildImageAttachment,decodeDataUrlToImageFile,MAX_DISPATCH_IMAGES" +import from="@kernlang/agon-adapter-cli" names="createCliAdapter" +import from="node:path" names="join" +import from="node:os" names="tmpdir" +import from="node:fs" names="rmSync" +import from="node:crypto" names="randomUUID" + +const name=AGENT_TOOL_MARKER type=string value={{ '__AGON_TOOL__' }} export=true +const name=MAX_AGENT_STEPS type=number value={{ 8 }} export=true +const name=CAPABILITY_RESULT_TIMEOUT_MS type=number value={{ 60_000 }} +const name=APPROVAL_TIMEOUT_MS type=number value={{ 180_000 }} + +// ── Pure helpers (exported for unit tests) ─────────────────────────────────── + +fn name=parseAgentToolCall params="stdout:string" returns="{ name: string; input: Record } | null" export=true + doc "Extract an agent tool call from an engine's stdout: locate the AGENT_TOOL_MARKER and parse the first balanced {…} JSON object after it. Forgiving — surrounding prose or a ```code fence``` is tolerated, and a missing/garbled sentinel returns null (the caller treats null as a final prose answer). Returns null unless the object has a string `name`; a non-object `input` defaults to {}." + handler <<< + const idx = stdout.indexOf(AGENT_TOOL_MARKER); + if (idx === -1) return null; + const after = stdout.slice(idx + AGENT_TOOL_MARKER.length); + const start = after.indexOf('{'); + if (start === -1) return null; + let depth = 0; + let end = -1; + let inStr = false; + let esc = false; + for (let i = start; i < after.length; i++) { + const c = after[i]; + if (inStr) { + if (esc) esc = false; + else if (c === '\\') esc = true; + else if (c === '"') inStr = false; + continue; + } + if (c === '"') inStr = true; + else if (c === '{') depth++; + else if (c === '}') { depth--; if (depth === 0) { end = i; break; } } + } + if (end === -1) return null; + try { + const obj = JSON.parse(after.slice(start, end + 1)) as { name?: unknown; input?: unknown }; + if (obj && typeof obj.name === 'string') { + const input = (obj.input && typeof obj.input === 'object') ? obj.input as Record : {}; + return { name: obj.name, input }; + } + } catch { return null; } + return null; + >>> + +fn name=buildAgentSystemPrompt params="tools:CapabilitySpec[], base?:string" returns="string" export=true + doc "The agent's system prompt: the ReAct tool protocol plus the catalog of client-lent capabilities. Read-only vs destructive is surfaced so the engine knows which actions trigger the user's approval gate." + handler <<< + const lines: string[] = []; + if (base && base.trim().length > 0) { lines.push(base.trim(), ''); } + lines.push( + "You are Agon, an AI agent operating INSIDE the user's web browser via a side panel.", + 'You can inspect and act on the page the user is viewing, using ONLY the tools below.', + '', + 'TOOLS:', + ); + if (tools.length === 0) { + lines.push(' (none registered — answer from your own knowledge.)'); + } else { + for (const t of tools) { + const tag = t.isDestructive ? '(ACTS on the page — the user must approve)' : '(read-only)'; + lines.push(`- ${t.name} ${tag}: ${t.description}`); + lines.push(` input: ${JSON.stringify(t.inputSchema)}`); + } + } + lines.push( + '', + 'TO USE A TOOL, reply with EXACTLY one line and nothing else:', + `${AGENT_TOOL_MARKER} {"name":"","input":{ ... }}`, + 'Use ONE tool per step. You will then receive its result and may call another tool or answer.', + 'Read the page before acting on it. Never fabricate a tool result or claim an action you did not take.', + 'When you have the final answer for the user, reply normally in prose with NO tool line.', + ); + return lines.join('\n'); + >>> + +fn name=renderAgentTranscript params="userInput:string, steps:Array<{ name: string; input: Record; output: string }>" returns="string" export=true + doc "The growing ReAct transcript re-sent to the (stateless, exec-mode) engine each step: the original request plus every prior tool call and its result, ending with a nudge to act or answer." + handler <<< + const lines: string[] = [`User request: ${userInput}`, '']; + if (steps.length === 0) { + lines.push('No tools have run yet. Decide your first tool call, or answer directly.'); + return lines.join('\n'); + } + lines.push('Tool history so far (most recent last):'); + for (const s of steps) { + lines.push(`> ${s.name}(${JSON.stringify(s.input)})`); + lines.push(`< ${s.output}`); + } + lines.push('', 'Based on the results above, call the next tool or give your final answer.'); + return lines.join('\n'); + >>> + +fn name=describeAgentAction params="name:string, input:Record" returns="string" export=true + doc "A compact, human-readable one-liner for the approval popup's `command` field — what the agent is about to do." + handler <<< + let arg = ''; + try { arg = JSON.stringify(input); } catch { arg = '{…}'; } + if (arg.length > 160) arg = arg.slice(0, 157) + '…'; + return `${name}(${arg})`; + >>> + +// ── The agentic brain ──────────────────────────────────────────────────────── + +service name=AgenticTurnBrainClient implements=BrainClient + doc "v2 BrainClient: a bounded ReAct tool-loop over one engine, with client-lent capabilities (registerCapability) the brain pulls mid-turn via capability-request, and a per-action approval gate for destructive tools. Construct with the daemon's EngineRegistry; open() binds engine/cwd; runTurn() drives the loop; provideCapabilityResult/provideApproval answer the *-request events by requestId." + field name=registry type=EngineRegistry private=true + field name=adapter type=EngineAdapter private=true + field name=engineId type=string private=true + field name=cwd type=string private=true + field name=systemPrompt type="string|undefined" private=true + field name=startedAtMs type=number private=true + field name=activeTurnId type="string|null" private=true + field name=aborts type="Map" private=true + field name=caps type="Map" private=true + field name=pendingCaps type="Map void>" private=true + field name=pendingApprovals type="Map void>" private=true + field name=approvedSession type="Set" private=true + field name=deniedSession type="Set" private=true + field name=controlCapabilities type=ControlCapabilities + + constructor params="registry:EngineRegistry" + handler lang="kern" + assign target="this.registry" value="registry" + assign target="this.adapter" value="createCliAdapter(registry)" + assign target="this.engineId" value="'claude'" + assign target="this.cwd" value="process.cwd()" + assign target="this.systemPrompt" value="undefined" + assign target="this.startedAtMs" value="Date.now()" + assign target="this.activeTurnId" value="null" + assign target="this.aborts" value="new Map()" + assign target="this.caps" value="new Map()" + assign target="this.pendingCaps" value="new Map()" + assign target="this.pendingApprovals" value="new Map()" + assign target="this.approvedSession" value="new Set()" + assign target="this.deniedSession" value="new Set()" + assign target="this.controlCapabilities" value="{ concurrentTurns: 'per-session-serialized', concurrentSteering: 'unsupported', approvalArbitration: 'host-only', questionArbitration: 'unsupported', clientCapabilities: 'supported', cancellation: 'per-turn' }" + assign target="this.open" value="this.open.bind(this)" + assign target="this.runTurn" value="this.runTurn.bind(this)" + assign target="this.steer" value="this.steer.bind(this)" + assign target="this.provideApproval" value="this.provideApproval.bind(this)" + assign target="this.provideAnswer" value="this.provideAnswer.bind(this)" + assign target="this.registerCapability" value="this.registerCapability.bind(this)" + assign target="this.unregisterCapability" value="this.unregisterCapability.bind(this)" + assign target="this.provideCapabilityResult" value="this.provideCapabilityResult.bind(this)" + assign target="this.cancel" value="this.cancel.bind(this)" + assign target="this.notifyClientAttached" value="this.notifyClientAttached.bind(this)" + assign target="this.notifyClientDetached" value="this.notifyClientDetached.bind(this)" + assign target="this.health" value="this.health.bind(this)" + assign target="this.close" value="this.close.bind(this)" + + method name=open params="config:BrainClientConfig" returns="Promise" async=true + doc "Bind the session: which engine answers, the cwd dispatches run in, and an optional system prompt prepended to the agent protocol prompt." + handler <<< + this.engineId = config.engineId; + this.cwd = config.cwd; + this.systemPrompt = config.systemPrompt; + >>> + + method name=waitForCapabilityResult params="requestId:string, signal:AbortSignal" returns="Promise" async=true private=true + doc "Suspend the turn until the owning client POSTs its CapabilityResult (provideCapabilityResult resolves the pending entry by requestId). Rejects on the turn's abort or after CAPABILITY_RESULT_TIMEOUT_MS so a closed/unresponsive panel can never hang the brain." + handler <<< + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error('aborted')); return; } + let timer: ReturnType; + const onAbort = () => { this.pendingCaps.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; + timer = setTimeout(() => { this.pendingCaps.delete(requestId); signal.removeEventListener('abort', onAbort); reject(new Error('timeout')); }, CAPABILITY_RESULT_TIMEOUT_MS); + signal.addEventListener('abort', onAbort, { once: true }); + this.pendingCaps.set(requestId, (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); }); + }); + >>> + + method name=waitForApproval params="requestId:string, signal:AbortSignal" returns="Promise" async=true private=true + doc "Suspend the turn until the user answers the approval-request (provideApproval resolves by requestId with the decision). Rejects on abort; on APPROVAL_TIMEOUT_MS resolves to 'deny' (a no-answer is a safe no, not a hang)." + handler <<< + return new Promise((resolve, reject) => { + if (signal.aborted) { reject(new Error('aborted')); return; } + let timer: ReturnType; + const onAbort = () => { this.pendingApprovals.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; + timer = setTimeout(() => { this.pendingApprovals.delete(requestId); signal.removeEventListener('abort', onAbort); resolve('deny'); }, APPROVAL_TIMEOUT_MS); + signal.addEventListener('abort', onAbort, { once: true }); + this.pendingApprovals.set(requestId, (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); }); + }); + >>> + + method name=runTurn params="req:BrainTurnRequest" returns="BrainEvent, BrainTurnResult, void" async=true stream=true + doc "Run the bounded ReAct tool-loop for one turn, streaming BrainEvents (thinking notices, tool start/done, capability-request + approval-request, and the final engine answer); the BrainTurnResult is the generator RETURN value. Single-writer: rejects a concurrent turn. Cancellable per turn via the AbortController." + handler <<< + // KERN-GAP: kern-review (undefined-reference) — in a raw `<<<` stream handler a + // `yield { ...objectLiteral }` is mis-parsed (its keys flagged undeclared). + // Workaround used throughout: assign each BrainEvent to a typed const, yield it. + const turnEngineId = (req.engineId && this.registry.listIds().includes(req.engineId)) ? req.engineId : this.engineId; + if (this.activeTurnId !== null) { + const reason = `brain busy with turn ${this.activeTurnId} (single-writer)`; + const busy: BrainEvent = { kind: 'notice', level: 'error', message: reason }; + yield busy; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + this.activeTurnId = req.turnId; + const ctrl = new AbortController(); + this.aborts.set(req.turnId, ctrl); + const safeTurn = req.turnId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 200) || 'turn'; + const turnDir = join(tmpdir(), 'agon-brain-turns', safeTurn); + try { + const engine = this.registry.get(turnEngineId); + // Step-0 vision: a screenshot the user attached to the turn (frontend-inspector). + // A screenshot the AGENT later captures (a 'data:' capability result) is decoded + // and shown on the NEXT dispatch so the agent can actually SEE what it grabbed. + type Att = NonNullable>; + let nextImages: Att[] = []; + const allImages = req.images ?? []; + const rawImages = allImages.slice(0, MAX_DISPATCH_IMAGES); + if (allImages.length > rawImages.length) { + const capped: BrainEvent = { kind: 'notice', level: 'warning', message: `only the first ${MAX_DISPATCH_IMAGES} of ${allImages.length} images are used` }; + yield capped; + } + for (let i = 0; i < rawImages.length; i++) { + const raw = rawImages[i]; + if (raw.startsWith('data:')) { + const decoded = decodeDataUrlToImageFile(raw, turnDir, i); + if (decoded.path) { const att = buildImageAttachment(decoded.path, this.cwd); if (att) nextImages.push(att); } + else if (decoded.reason) { const skip: BrainEvent = { kind: 'notice', level: 'warning', message: `image skipped: ${decoded.reason}` }; yield skip; } + } else { + const att = buildImageAttachment(raw, this.cwd); if (att) nextImages.push(att); + } + } + + const tools = [...this.caps.values()].map((c) => c.spec); + const sysPrompt = buildAgentSystemPrompt(tools, this.systemPrompt); + const steps: Array<{ name: string; input: Record; output: string }> = []; + let imgSeq = rawImages.length; + + for (let step = 0; step < MAX_AGENT_STEPS; step++) { + if (ctrl.signal.aborted) break; + const thinking: BrainEvent = { kind: 'notice', level: 'info', message: `${turnEngineId} thinking… (step ${step + 1}/${MAX_AGENT_STEPS})` }; + yield thinking; + const prompt = renderAgentTranscript(req.input, steps); + const dispatchImages = nextImages.length > 0 ? nextImages : undefined; + nextImages = []; // images are shown once, on the dispatch right after capture + const result = await this.adapter.dispatch({ + engine, prompt, cwd: this.cwd, mode: 'exec', timeout: 120, + outputDir: turnDir, signal: ctrl.signal, systemPrompt: sysPrompt, images: dispatchImages, + }); + const stdout = (result.stdout || '').trim(); + if (result.exitCode !== 0 || result.timedOut || stdout.length === 0) { + const reason = ctrl.signal.aborted ? 'cancelled by client' + : result.timedOut ? `${turnEngineId} timed out` + : `${turnEngineId} produced no answer (exit ${result.exitCode})`; + const note: BrainEvent = { kind: 'notice', level: ctrl.signal.aborted ? 'warning' : 'error', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + + const call = parseAgentToolCall(stdout); + if (!call) { + // No tool call → the engine's final answer. + const ans: BrainEvent = { kind: 'engine', engineId: turnEngineId, content: stdout }; + yield ans; + return { turnId: req.turnId, delegated: false, responded: true, engineId: turnEngineId }; + } + + const cap = this.caps.get(call.name); + if (!cap) { + const avail = [...this.caps.keys()].join(', ') || '(none)'; + steps.push({ name: call.name, input: call.input, output: `ERROR: no tool "${call.name}". Available tools: ${avail}.` }); + continue; + } + + const running: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'running', input: describeAgentAction(call.name, call.input) }; + yield running; + + // Approval gate for destructive tools (skipped if approved-for-session). + if (cap.spec.isDestructive && !this.approvedSession.has(call.name)) { + if (this.deniedSession.has(call.name)) { + const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied for this session' }; + yield denied; + steps.push({ name: call.name, input: call.input, output: 'DENIED by the user for this session — do not retry this tool; try another approach or tell the user.' }); + continue; + } + const apId = randomUUID(); + const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description }; + yield ask; + let decision: string; + try { decision = await this.waitForApproval(apId, ctrl.signal); } + catch { + const reason = 'cancelled by client'; + const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + if (decision === 'abort') { + const reason = 'the user aborted the turn at an approval prompt'; + const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + if (decision === 'approve-session') this.approvedSession.add(call.name); + if (decision === 'deny' || decision === 'deny-session') { + if (decision === 'deny-session') this.deniedSession.add(call.name); + const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied by the user' }; + yield denied; + steps.push({ name: call.name, input: call.input, output: 'DENIED by the user — do not retry; try another approach or explain what you would have done.' }); + continue; + } + // 'approve' / 'approve-session' fall through to execution. + } + + // Execute: pull the capability from the owning client. + const reqId = randomUUID(); + const capReq: BrainEvent = { kind: 'capability-request', requestId: reqId, capability: call.name, input: call.input, targetClientId: cap.clientId }; + yield capReq; + let capRes: CapabilityResult; + try { capRes = await this.waitForCapabilityResult(reqId, ctrl.signal); } + catch (err) { + const aborted = ctrl.signal.aborted; + const failNote: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: aborted ? 'cancelled' : 'the browser did not respond' }; + yield failNote; + const reason = aborted ? 'cancelled by client' : `tool "${call.name}" timed out (the browser client did not respond)`; + const note: BrainEvent = { kind: 'notice', level: aborted ? 'warning' : 'error', message: reason }; + yield note; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + + const rawOut = capRes.ok ? (capRes.output ?? '(no output)') : `ERROR: ${capRes.error ?? 'the tool failed'}`; + // A 'data:' image result (e.g. an agent screenshot) is decoded and shown to + // the engine on the next dispatch as vision, not dumped into the text transcript. + let transcriptOut = rawOut; + if (capRes.ok && rawOut.startsWith('data:')) { + const decoded = decodeDataUrlToImageFile(rawOut, turnDir, imgSeq++); + if (decoded.path) { + const att = buildImageAttachment(decoded.path, this.cwd); + if (att) { nextImages = [att]; transcriptOut = '(screenshot captured — it is attached to your view this step)'; } + else transcriptOut = '(screenshot captured but could not be attached)'; + } else { + transcriptOut = `(screenshot could not be read${decoded.reason ? `: ${decoded.reason}` : ''})`; + } + } + const done: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: capRes.ok ? 'done' : 'error', output: transcriptOut.slice(0, 400) }; + yield done; + steps.push({ name: call.name, input: call.input, output: transcriptOut.slice(0, 4000) }); + } + + if (ctrl.signal.aborted) { + const reason = 'cancelled by client'; + const cancelled: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield cancelled; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + const reason = `reached the ${MAX_AGENT_STEPS}-step limit without a final answer`; + const limit: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield limit; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } catch (err) { + if (ctrl.signal.aborted) { + const reason = 'cancelled by client'; + const cancelled: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; + yield cancelled; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } + const reason = `turn failed: ${err instanceof Error ? err.message : String(err)}`; + const failed: BrainEvent = { kind: 'notice', level: 'error', message: reason }; + yield failed; + return { turnId: req.turnId, delegated: false, responded: false, engineId: turnEngineId, reason }; + } finally { + this.aborts.delete(req.turnId); + if (this.activeTurnId === req.turnId) this.activeTurnId = null; + try { rmSync(turnDir, { recursive: true, force: true }); } catch { /* best-effort scratch cleanup */ } + } + >>> + + method name=cancel params="req:CancelRequest" returns="Promise" async=true + doc "Abort an in-flight turn by turnId — also rejects any capability/approval the loop is awaiting, so a cancel breaks the loop immediately." + handler <<< + const ctrl = this.aborts.get(req.turnId); + if (!ctrl) return { status: 'rejected', reason: `no active turn ${req.turnId}` }; + ctrl.abort(); + return { status: 'accepted' }; + >>> + + method name=registerCapability params="reg:CapabilityRegistration" returns="Promise" async=true + doc "A client lends the brain a tool. Stored by name with its owning clientId so a capability-request can be routed back. Re-registering a name replaces it (the latest client wins)." + handler <<< + this.caps.set(reg.spec.name, { clientId: reg.clientId, spec: reg.spec }); + return { status: 'accepted' }; + >>> + + method name=unregisterCapability params="req:CapabilityUnregister" returns="Promise" async=true + handler <<< + this.caps.delete(req.name); + return { status: 'accepted' }; + >>> + + method name=provideCapabilityResult params="res:CapabilityResult" returns="Promise" async=true + doc "A client's reply to a capability-request — resolves the pending turn await by requestId. Rejected (not unsupported) if no such request is outstanding (stale/duplicate)." + handler <<< + const resolve = this.pendingCaps.get(res.requestId); + if (!resolve) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + this.pendingCaps.delete(res.requestId); + resolve(res); + return { status: 'accepted' }; + >>> + + method name=provideApproval params="res:ApprovalResponse" returns="Promise" async=true + doc "The user's decision on an approval-request — resolves the pending gate by requestId. Arbitration is host-only; a stale/duplicate requestId is rejected." + handler <<< + const resolve = this.pendingApprovals.get(res.requestId); + if (!resolve) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + this.pendingApprovals.delete(res.requestId); + resolve(res.decision); + return { status: 'accepted' }; + >>> + + method name=steer params="_req:SteerRequest" returns="Promise" async=true + handler <<< + return { status: 'unsupported', reason: 'agent v2 has no mid-turn steering yet (controlCapabilities.concurrentSteering)' }; + >>> + + method name=provideAnswer params="_res:AnswerResponse" returns="Promise" async=true + handler <<< + return { status: 'unsupported', reason: 'agent v2 asks no free-text mid-turn questions yet (controlCapabilities.questionArbitration)' }; + >>> + + method name=notifyClientAttached params="_sessionId:string, _client:ClientRef" returns="void" + handler <<< + /* capabilities are (re)registered explicitly by the client on attach; nothing to pre-track */ + >>> + + method name=notifyClientDetached params="_sessionId:string, clientId:string" returns="void" + doc "Drop the detached client's lent tools so a closed panel leaves no phantom capabilities the brain might try to pull." + handler <<< + for (const [name, c] of [...this.caps]) { if (c.clientId === clientId) this.caps.delete(name); } + >>> + + method name=health returns="Promise" async=true + handler <<< + return { alive: true, engineId: this.engineId, pid: null, activeTurnId: this.activeTurnId, queuedTurns: 0, uptimeMs: Date.now() - this.startedAtMs }; + >>> + + method name=close returns="Promise" async=true + doc "Best-effort teardown: abort all in-flight turns (which rejects their pending capability/approval awaits) and clear state." + handler <<< + for (const ctrl of this.aborts.values()) ctrl.abort(); + this.aborts.clear(); + this.pendingCaps.clear(); + this.pendingApprovals.clear(); + this.activeTurnId = null; + >>> + +fn name=createAgenticTurnBrainClient params="registry:EngineRegistry" returns="BrainClient" export=true + doc "Factory mirroring createHeadlessTurnBrainClient: build the v2 agentic tool-loop BrainClient from the daemon's EngineRegistry." + handler <<< + return new AgenticTurnBrainClient(registry); + >>> diff --git a/tests/unit/agentic-brain-client.test.ts b/tests/unit/agentic-brain-client.test.ts new file mode 100644 index 00000000..798a170f --- /dev/null +++ b/tests/unit/agentic-brain-client.test.ts @@ -0,0 +1,263 @@ +import { describe, it, expect } from 'vitest'; + +import { + AgenticTurnBrainClient, + createAgenticTurnBrainClient, + parseAgentToolCall, + buildAgentSystemPrompt, + renderAgentTranscript, + describeAgentAction, + AGENT_TOOL_MARKER as MARK, +} from '../../packages/cli/src/generated/bridge/agentic-brain-client.js'; +import type { BrainEvent, BrainTurnResult, EngineAdapter, EngineRegistry, CapabilitySpec } from '@kernlang/agon-core'; + +// The agent brain runs a ReAct loop over adapter.dispatch. We inject a fake adapter +// that returns SCRIPTED stdout per step (a queue; past the end it repeats the last, +// so a mis-scripted test ends rather than loops). No engine is spawned. +function makeAgent(responses: string[]): AgenticTurnBrainClient { + let i = 0; + const registry = { get: (id: string) => ({ id }), listIds: () => ['claude', 'codex'] } as unknown as EngineRegistry; + const client = new AgenticTurnBrainClient(registry); + (client as unknown as { adapter: EngineAdapter }).adapter = { + dispatch: async () => ({ exitCode: 0, stdout: responses[Math.min(i++, responses.length - 1)], stderr: '', durationMs: 1, timedOut: false }), + isAvailable: async () => true, + } as unknown as EngineAdapter; + return client; +} + +const readSpec = (name = 'readPage'): CapabilitySpec => ({ name, description: 'read the page', inputSchema: {}, isReadOnly: true }); +const actSpec = (name = 'click'): CapabilitySpec => ({ name, description: 'click an element', inputSchema: { selector: 'string' }, isReadOnly: false, isDestructive: true }); +const req = (turnId: string, input = 'do something') => ({ sessionId: 's', turnId, clientId: 'c', input }); + +// Drive the turn to completion, auto-answering each capability/approval request. +// The response is scheduled on a microtask so it lands while the generator's +// `await waitForX` (set up synchronously inside the next gen.next()) is pending. +async function driveAgent( + client: AgenticTurnBrainClient, + gen: AsyncGenerator, + responders: { capability: (ev: BrainEvent) => { ok: boolean; output?: string; error?: string }; approval: (ev: BrainEvent) => string }, +): Promise<{ events: BrainEvent[]; result: BrainTurnResult }> { + const events: BrainEvent[] = []; + let r = await gen.next(); + while (!r.done) { + const ev = r.value; + events.push(ev); + if (ev.kind === 'capability-request') { + const reqId = (ev as { requestId: string }).requestId; + const resp = responders.capability(ev); + queueMicrotask(() => { void client.provideCapabilityResult({ sessionId: 's', requestId: reqId, clientId: 'c', ...resp }); }); + } else if (ev.kind === 'approval-request') { + const reqId = (ev as { requestId: string }).requestId; + const decision = responders.approval(ev) as 'approve' | 'approve-session' | 'deny' | 'deny-session' | 'abort'; + queueMicrotask(() => { void client.provideApproval({ sessionId: 's', requestId: reqId, clientId: 'c', decision }); }); + } + r = await gen.next(); + } + return { events, result: r.value }; +} + +describe('parseAgentToolCall — forgiving sentinel extraction', () => { + it('extracts a clean tool call', () => { + expect(parseAgentToolCall(`${MARK} {"name":"click","input":{"selector":"#buy"}}`)).toEqual({ name: 'click', input: { selector: '#buy' } }); + }); + it('tolerates surrounding prose and a code fence', () => { + expect(parseAgentToolCall(`Let me look.\n\`\`\`\n${MARK} {"name":"readPage","input":{}}\n\`\`\``)).toEqual({ name: 'readPage', input: {} }); + }); + it('handles nested braces in input', () => { + expect(parseAgentToolCall(`${MARK} {"name":"type","input":{"opts":{"a":1}}}`)).toEqual({ name: 'type', input: { opts: { a: 1 } } }); + }); + it('defaults input to {} when omitted', () => { + expect(parseAgentToolCall(`${MARK} {"name":"readPage"}`)).toEqual({ name: 'readPage', input: {} }); + }); + it('returns null when there is no sentinel (a final prose answer)', () => { + expect(parseAgentToolCall('Here is your final answer.')).toBeNull(); + }); + it('returns null on garbled JSON', () => { + expect(parseAgentToolCall(`${MARK} {name: click}`)).toBeNull(); + }); + it('returns null when the object has no string name', () => { + expect(parseAgentToolCall(`${MARK} {"input":{}}`)).toBeNull(); + }); +}); + +describe('agent prompt + transcript helpers', () => { + it('buildAgentSystemPrompt lists tools with read-only vs acts tags and the protocol', () => { + const p = buildAgentSystemPrompt([readSpec(), actSpec()]); + expect(p).toContain('readPage'); + expect(p).toContain('(read-only)'); + expect(p).toContain('click'); + expect(p).toContain('ACTS on the page'); + expect(p).toContain(MARK); + }); + it('buildAgentSystemPrompt prepends a base system prompt and notes when no tools', () => { + const p = buildAgentSystemPrompt([], 'BASE-PROMPT'); + expect(p.startsWith('BASE-PROMPT')).toBe(true); + expect(p).toContain('none registered'); + }); + it('renderAgentTranscript shows the request and the running tool history', () => { + expect(renderAgentTranscript('hello', [])).toContain('No tools have run yet'); + const t = renderAgentTranscript('hello', [{ name: 'readPage', input: {}, output: 'PAGE' }]); + expect(t).toContain('User request: hello'); + expect(t).toContain('> readPage({})'); + expect(t).toContain('< PAGE'); + }); + it('describeAgentAction renders a compact one-liner and truncates huge inputs', () => { + expect(describeAgentAction('click', { selector: '#buy' })).toBe('click({"selector":"#buy"})'); + expect(describeAgentAction('type', { v: 'x'.repeat(500) }).length).toBeLessThanOrEqual(170 + 'type()'.length); + }); +}); + +describe('AgenticTurnBrainClient — the ReAct loop', () => { + it('no tool call → streams the engine answer and responds', async () => { + const client = makeAgent(['Just answering directly.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'approve' }); + expect(events.some((e) => e.kind === 'capability-request')).toBe(false); + expect(events.find((e) => e.kind === 'engine')).toMatchObject({ engineId: 'claude', content: 'Just answering directly.' }); + expect(result).toMatchObject({ responded: true, engineId: 'claude' }); + }); + + it('a read-only tool runs WITHOUT approval: capability-request → result → final answer', async () => { + const client = makeAgent([`${MARK} {"name":"readPage","input":{}}`, 'The page is the Agon docs.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() }); + + let capInput: unknown; + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { + capability: (ev) => { capInput = (ev as { capability: string }).capability; return { ok: true, output: '' }; }, + approval: () => 'approve', + }); + expect(events.some((e) => e.kind === 'approval-request')).toBe(false); // read-only is never gated + expect(events.find((e) => e.kind === 'capability-request')).toMatchObject({ capability: 'readPage', targetClientId: 'c' }); + expect(capInput).toBe('readPage'); + expect(events.filter((e) => e.kind === 'tool').length).toBeGreaterThanOrEqual(2); // running + done + expect(events.find((e) => e.kind === 'engine')).toMatchObject({ content: 'The page is the Agon docs.' }); + expect(result.responded).toBe(true); + }); + + it('a destructive tool GATES on approval before the capability-request', async () => { + const client = makeAgent([`${MARK} {"name":"click","input":{"selector":"#buy"}}`, 'Clicked it.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: actSpec() }); + + let approvalCmd: string | undefined; + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { + capability: () => ({ ok: true, output: 'done' }), + approval: (ev) => { approvalCmd = (ev as { command: string }).command; return 'approve'; }, + }); + const ai = events.findIndex((e) => e.kind === 'approval-request'); + const ci = events.findIndex((e) => e.kind === 'capability-request'); + expect(ai).toBeGreaterThanOrEqual(0); + expect(ci).toBeGreaterThan(ai); // approval precedes execution + expect(approvalCmd).toContain('click'); + expect(result.responded).toBe(true); + }); + + it('a DENIED destructive tool never executes; the engine is told and can still answer', async () => { + const client = makeAgent([`${MARK} {"name":"click","input":{"selector":"#buy"}}`, 'OK, I will not click it.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: actSpec() }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'deny' }); + expect(events.some((e) => e.kind === 'capability-request')).toBe(false); // never ran + expect(result.responded).toBe(true); + }); + + it("'abort' at an approval prompt ends the turn", async () => { + const client = makeAgent([`${MARK} {"name":"click","input":{}}`, 'unused']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: actSpec() }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'abort' }); + expect(events.some((e) => e.kind === 'capability-request')).toBe(false); + expect(result.responded).toBe(false); + expect(result.reason).toMatch(/abort/i); + }); + + it("'approve-session' suppresses the approval prompt on the next use of the same tool", async () => { + const client = makeAgent([`${MARK} {"name":"click","input":{"selector":"#a"}}`, `${MARK} {"name":"click","input":{"selector":"#b"}}`, 'Both done.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: actSpec() }); + let approvals = 0; + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { + capability: () => ({ ok: true, output: 'ok' }), + approval: () => { approvals++; return 'approve-session'; }, + }); + expect(approvals).toBe(1); // the second click is NOT gated again + expect(events.filter((e) => e.kind === 'capability-request').length).toBe(2); + expect(result.responded).toBe(true); + }); + + it('an unknown tool is reported back and the loop recovers to an answer', async () => { + const client = makeAgent([`${MARK} {"name":"teleport","input":{}}`, 'I cannot do that here.']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + // no capability registered + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'approve' }); + expect(events.some((e) => e.kind === 'capability-request')).toBe(false); + expect(result.responded).toBe(true); + }); + + it('stops at the step limit when the engine never stops calling tools', async () => { + const client = makeAgent([`${MARK} {"name":"readPage","input":{}}`]); // always a tool call + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true, output: 'p' }), approval: () => 'approve' }); + expect(result.responded).toBe(false); + expect(result.reason).toMatch(/step limit/i); + expect(events.filter((e) => e.kind === 'capability-request').length).toBeGreaterThan(1); + }); +}); + +describe('AgenticTurnBrainClient — control surface', () => { + it('declares clientCapabilities supported + host-only approvals + per-turn cancel', () => { + const client = makeAgent(['x']); + expect(client.controlCapabilities).toEqual({ + concurrentTurns: 'per-session-serialized', + concurrentSteering: 'unsupported', + approvalArbitration: 'host-only', + questionArbitration: 'unsupported', + clientCapabilities: 'supported', + cancellation: 'per-turn', + }); + }); + + it('register + provide acks: a stale requestId is rejected, not unsupported', async () => { + const client = makeAgent(['x']); + expect(await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() })).toEqual({ status: 'accepted' }); + expect((await client.provideCapabilityResult({ sessionId: 's', requestId: 'nope', clientId: 'c', ok: true })).status).toBe('rejected'); + expect((await client.provideApproval({ sessionId: 's', requestId: 'nope', clientId: 'c', decision: 'approve' })).status).toBe('rejected'); + }); + + it('notifyClientDetached drops that client’s capabilities (no phantom tools)', async () => { + const client = makeAgent([`${MARK} {"name":"readPage","input":{}}`, 'answered without the tool']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c1', spec: readSpec() }); + client.notifyClientDetached('s', 'c1'); // panel closed + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'approve' }); + expect(events.some((e) => e.kind === 'capability-request')).toBe(false); // tool is gone → treated as unknown + expect(result.responded).toBe(true); + }); + + it('cancel mid-turn (while awaiting a tool result) ends the turn as cancelled', async () => { + const client = makeAgent([`${MARK} {"name":"readPage","input":{}}`, 'unused']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() }); + const gen = client.runTurn(req('t1')); + // Drain until the capability-request is yielded (the loop then awaits the result). + const events: BrainEvent[] = []; + let r = await gen.next(); + while (!r.done && r.value.kind !== 'capability-request') { events.push(r.value); r = await gen.next(); } + expect(r.done).toBe(false); // we paused on the capability-request + // Don't answer it — cancel instead. + expect(await client.cancel({ sessionId: 's', turnId: 't1', clientId: 'c' })).toEqual({ status: 'accepted' }); + let fin = await gen.next(); + while (!fin.done) fin = await gen.next(); + expect((fin.value as BrainTurnResult).responded).toBe(false); + expect((fin.value as BrainTurnResult).reason).toBe('cancelled by client'); + }); + + it('factory builds an interface-conformant agent reporting liveness', async () => { + const client = createAgenticTurnBrainClient({ get: (id: string) => ({ id }), listIds: () => ['claude'] } as unknown as EngineRegistry); + const h = await client.health(); + expect(h.alive).toBe(true); + expect(h.activeTurnId).toBeNull(); + }); +}); From 47fa9291606073cdc0adb4bdc7513aecedc17da3 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 17:47:04 +0200 Subject: [PATCH 2/7] =?UTF-8?q?feat(serve):=20wire=20the=20agent=20tool-lo?= =?UTF-8?q?op=20=E2=80=94=20capability=20round-trip=20endpoints=20+=20bind?= =?UTF-8?q?=20the=20agentic=20brain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agon serve now binds AgenticTurnBrainClient, and AgonServe routes the agent control plane so the browser panel can lend tools and answer the brain mid-turn. - AgonServe: POST /register-capability, /unregister-capability, /capability-result, /approval. These call the brain DIRECTLY (never chained on turnTail), so a capability-request the live turn is awaiting can be answered while handleSend still holds the per-session write lock — the no-deadlock property the design depends on. - The turn drain flushes the ledger immediately after a capability-request/ approval-request so it reaches the panel without the ~50ms coalesce wait. - Client-supplied capability specs are validated (string name+description) before reaching the brain; the approval decision enum is validated too. - serve.kern binds the agentic brain (degrades to single-dispatch with no tools) and trims the base system prompt — the agent role + tool protocol now come from buildAgentSystemPrompt; the old "you cannot click or type" guidance is gone. serve-command tests: control-endpoint validation (a valid register returns 'accepted', proving the agentic brain is bound, not the 'unsupported' v1), and an end-to-end wire round-trip proving /send blocks on a capability-request (over SSE) until /capability-result lands — no deadlock. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../cli/src/generated/bridge/agon-serve.ts | 72 +++++++++++++- packages/cli/src/generated/commands/serve.ts | 30 +++--- packages/cli/src/kern/bridge/agon-serve.kern | 77 ++++++++++++++- packages/cli/src/kern/commands/serve.kern | 22 ++--- tests/unit/serve-command.test.ts | 97 +++++++++++++++++++ 5 files changed, 265 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/generated/bridge/agon-serve.ts b/packages/cli/src/generated/bridge/agon-serve.ts index bb616cb9..51a50462 100644 --- a/packages/cli/src/generated/bridge/agon-serve.ts +++ b/packages/cli/src/generated/bridge/agon-serve.ts @@ -1,6 +1,6 @@ // @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/bridge/agon-serve.kern -import type { BrainClient } from '@kernlang/agon-core'; +import type { BrainClient, CapabilitySpec } from '@kernlang/agon-core'; import { getSessionHost, eventLogAppend, eventLogFlush, MAX_DISPATCH_IMAGES, MAX_DISPATCH_IMAGE_BYTES } from '@kernlang/agon-core'; @@ -124,6 +124,13 @@ export class AgonServe { } if (method === 'POST' && path === '/send') { await this.handleSend(req, res, origin); return; } if (method === 'POST' && path === '/cancel') { await this.handleCancel(req, res, origin); return; } + // Agent tool-loop control plane — these call the brain DIRECTLY (never chained + // on turnTail), so a capability-request the live turn is awaiting can be answered + // while handleSend still holds the per-session write lock (no deadlock). + if (method === 'POST' && path === '/register-capability') { await this.handleRegisterCapability(req, res, origin); return; } + if (method === 'POST' && path === '/unregister-capability') { await this.handleUnregisterCapability(req, res, origin); return; } + if (method === 'POST' && path === '/capability-result') { await this.handleCapabilityResult(req, res, origin); return; } + if (method === 'POST' && path === '/approval') { await this.handleApproval(req, res, origin); return; } this.sendJson(res, 404, { error: 'not found' }, origin); } catch (err) { // Never double-write: if the response already started (e.g. an SSE replay @@ -184,7 +191,15 @@ export class AgonServe { eventLogAppend(this.sessionId, { kind: 'provenance', clientId, origin, turnId }, { kind: 'bridge' }); const gen = this.brain.runTurn({ sessionId: this.sessionId, turnId, clientId, input, images, engineId }); let r = await gen.next(); - while (!r.done) { eventLogAppend(this.sessionId, r.value, { kind: 'bridge' }); r = await gen.next(); } + while (!r.done) { + eventLogAppend(this.sessionId, r.value, { kind: 'bridge' }); + // A capability/approval request must reach the client NOW — the turn is about + // to block awaiting the reply, so flush past the ~50ms coalesce window instead + // of letting the round-trip stall on the timer. + const k = (r.value as { kind?: string }).kind; + if (k === 'capability-request' || k === 'approval-request') eventLogFlush(this.sessionId); + r = await gen.next(); + } eventLogFlush(this.sessionId); this.sendJson(res, 200, { turnId, result: r.value }, origin); } finally { @@ -201,6 +216,57 @@ export class AgonServe { this.sendJson(res, 200, ack, origin); } + private async handleRegisterCapability(req: IncomingMessage, res: ServerResponse, origin: string): Promise { + const body = await this.readJson(req); + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const spec = body.spec as { name?: unknown; description?: unknown; inputSchema?: unknown; isReadOnly?: unknown; isDestructive?: unknown } | undefined; + if (!spec || typeof spec !== 'object' || typeof spec.name !== 'string' || typeof spec.description !== 'string') { + this.sendJson(res, 400, { error: 'invalid capability spec (need string name + description)' }, origin); + return; + } + const clean: CapabilitySpec = { + name: spec.name, + description: spec.description, + inputSchema: (spec.inputSchema && typeof spec.inputSchema === 'object') ? spec.inputSchema as Record : {}, + isReadOnly: spec.isReadOnly === true, + isDestructive: spec.isDestructive === true, + }; + const ack = await this.brain.registerCapability({ sessionId: this.sessionId, clientId, spec: clean }); + this.sendJson(res, 200, ack, origin); + } + + private async handleUnregisterCapability(req: IncomingMessage, res: ServerResponse, origin: string): Promise { + const body = await this.readJson(req); + const name = typeof body.name === 'string' ? body.name : ''; + if (!name) { this.sendJson(res, 400, { error: 'missing name' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ack = await this.brain.unregisterCapability({ sessionId: this.sessionId, clientId, name }); + this.sendJson(res, 200, ack, origin); + } + + private async handleCapabilityResult(req: IncomingMessage, res: ServerResponse, origin: string): Promise { + const body = await this.readJson(req); + const requestId = typeof body.requestId === 'string' ? body.requestId : ''; + if (!requestId) { this.sendJson(res, 400, { error: 'missing requestId' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ok = body.ok === true; + const output = typeof body.output === 'string' ? body.output : undefined; + const error = typeof body.error === 'string' ? body.error : undefined; + const ack = await this.brain.provideCapabilityResult({ sessionId: this.sessionId, requestId, clientId, ok, output, error }); + this.sendJson(res, 200, ack, origin); + } + + private async handleApproval(req: IncomingMessage, res: ServerResponse, origin: string): Promise { + const body = await this.readJson(req); + const requestId = typeof body.requestId === 'string' ? body.requestId : ''; + const decision = typeof body.decision === 'string' ? body.decision : ''; + const allowed = ['approve', 'approve-session', 'deny', 'deny-session', 'abort']; + if (!requestId || !allowed.includes(decision)) { this.sendJson(res, 400, { error: 'missing requestId or invalid decision' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ack = await this.brain.provideApproval({ sessionId: this.sessionId, requestId, clientId, decision: decision as 'approve' | 'approve-session' | 'deny' | 'deny-session' | 'abort' }); + this.sendJson(res, 200, ack, origin); + } + private async readJson(req: IncomingMessage): Promise> { const chunks: Buffer[] = []; let size = 0; @@ -229,7 +295,7 @@ export class AgonServe { /** * Factory: build the loopback bridge for a session given an opened BrainClient and the session id. */ -// @kern-source: agon-serve:266 +// @kern-source: agon-serve:339 export function createAgonServe(opts: { brain: BrainClient, sessionId: string, allowedOrigins?: string[], engines?: string[], engineId?: string }): AgonServe { return new AgonServe(opts); } diff --git a/packages/cli/src/generated/commands/serve.ts b/packages/cli/src/generated/commands/serve.ts index 5b6f0a4f..cfc8eb05 100644 --- a/packages/cli/src/generated/commands/serve.ts +++ b/packages/cli/src/generated/commands/serve.ts @@ -12,7 +12,7 @@ import { join } from 'node:path'; import { resolveBuiltinEnginesDir } from '../lib/engines-dir.js'; -import { createHeadlessTurnBrainClient } from '../bridge/headless-brain-client.js'; +import { createAgenticTurnBrainClient } from '../bridge/agentic-brain-client.js'; import { createAgonServe } from '../bridge/agon-serve.js'; @@ -158,18 +158,16 @@ export async function buildServeRuntime(opts: ServeOptions): Promise { ensureAgonHome(); const cwd = process.cwd(); @@ -286,7 +284,7 @@ export async function runServe(port: number, engine: string|undefined, allowedOr }); } -// @kern-source: serve:281 +// @kern-source: serve:279 export const serveCommand: any = defineCommand({ meta: { name: 'serve', diff --git a/packages/cli/src/kern/bridge/agon-serve.kern b/packages/cli/src/kern/bridge/agon-serve.kern index 149f581a..467ba2aa 100644 --- a/packages/cli/src/kern/bridge/agon-serve.kern +++ b/packages/cli/src/kern/bridge/agon-serve.kern @@ -25,7 +25,7 @@ // WS upgrade are later, behind the same wire. The brain is injected (the daemon // hands in a HeadlessTurnBrainClient). -import from="@kernlang/agon-core" names="BrainClient" types=true +import from="@kernlang/agon-core" names="BrainClient,CapabilitySpec" types=true import from="@kernlang/agon-core" names="getSessionHost,eventLogAppend,eventLogFlush,MAX_DISPATCH_IMAGES,MAX_DISPATCH_IMAGE_BYTES" import from="node:http" names="createServer" import from="node:http" names="IncomingMessage,ServerResponse,Server" types=true @@ -154,6 +154,13 @@ service name=AgonServe } if (method === 'POST' && path === '/send') { await this.handleSend(req, res, origin); return; } if (method === 'POST' && path === '/cancel') { await this.handleCancel(req, res, origin); return; } + // Agent tool-loop control plane — these call the brain DIRECTLY (never chained + // on turnTail), so a capability-request the live turn is awaiting can be answered + // while handleSend still holds the per-session write lock (no deadlock). + if (method === 'POST' && path === '/register-capability') { await this.handleRegisterCapability(req, res, origin); return; } + if (method === 'POST' && path === '/unregister-capability') { await this.handleUnregisterCapability(req, res, origin); return; } + if (method === 'POST' && path === '/capability-result') { await this.handleCapabilityResult(req, res, origin); return; } + if (method === 'POST' && path === '/approval') { await this.handleApproval(req, res, origin); return; } this.sendJson(res, 404, { error: 'not found' }, origin); } catch (err) { // Never double-write: if the response already started (e.g. an SSE replay @@ -218,7 +225,15 @@ service name=AgonServe eventLogAppend(this.sessionId, { kind: 'provenance', clientId, origin, turnId }, { kind: 'bridge' }); const gen = this.brain.runTurn({ sessionId: this.sessionId, turnId, clientId, input, images, engineId }); let r = await gen.next(); - while (!r.done) { eventLogAppend(this.sessionId, r.value, { kind: 'bridge' }); r = await gen.next(); } + while (!r.done) { + eventLogAppend(this.sessionId, r.value, { kind: 'bridge' }); + // A capability/approval request must reach the client NOW — the turn is about + // to block awaiting the reply, so flush past the ~50ms coalesce window instead + // of letting the round-trip stall on the timer. + const k = (r.value as { kind?: string }).kind; + if (k === 'capability-request' || k === 'approval-request') eventLogFlush(this.sessionId); + r = await gen.next(); + } eventLogFlush(this.sessionId); this.sendJson(res, 200, { turnId, result: r.value }, origin); } finally { @@ -236,6 +251,64 @@ service name=AgonServe this.sendJson(res, 200, ack, origin); >>> + method name=handleRegisterCapability params="req:IncomingMessage, res:ServerResponse, origin:string" returns="Promise" async=true private=true + doc "A client lends the brain a tool: { clientId, spec }. The spec is client-supplied (untrusted) — validated for a string name + boolean isReadOnly before it reaches the brain; the brain stores it for capability routing." + handler <<< + const body = await this.readJson(req); + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const spec = body.spec as { name?: unknown; description?: unknown; inputSchema?: unknown; isReadOnly?: unknown; isDestructive?: unknown } | undefined; + if (!spec || typeof spec !== 'object' || typeof spec.name !== 'string' || typeof spec.description !== 'string') { + this.sendJson(res, 400, { error: 'invalid capability spec (need string name + description)' }, origin); + return; + } + const clean: CapabilitySpec = { + name: spec.name, + description: spec.description, + inputSchema: (spec.inputSchema && typeof spec.inputSchema === 'object') ? spec.inputSchema as Record : {}, + isReadOnly: spec.isReadOnly === true, + isDestructive: spec.isDestructive === true, + }; + const ack = await this.brain.registerCapability({ sessionId: this.sessionId, clientId, spec: clean }); + this.sendJson(res, 200, ack, origin); + >>> + + method name=handleUnregisterCapability params="req:IncomingMessage, res:ServerResponse, origin:string" returns="Promise" async=true private=true + handler <<< + const body = await this.readJson(req); + const name = typeof body.name === 'string' ? body.name : ''; + if (!name) { this.sendJson(res, 400, { error: 'missing name' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ack = await this.brain.unregisterCapability({ sessionId: this.sessionId, clientId, name }); + this.sendJson(res, 200, ack, origin); + >>> + + method name=handleCapabilityResult params="req:IncomingMessage, res:ServerResponse, origin:string" returns="Promise" async=true private=true + doc "The client's reply to a capability-request — resolves the turn the brain has suspended on this requestId. NOT chained on turnTail, so it lands while handleSend still owns the write lock." + handler <<< + const body = await this.readJson(req); + const requestId = typeof body.requestId === 'string' ? body.requestId : ''; + if (!requestId) { this.sendJson(res, 400, { error: 'missing requestId' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ok = body.ok === true; + const output = typeof body.output === 'string' ? body.output : undefined; + const error = typeof body.error === 'string' ? body.error : undefined; + const ack = await this.brain.provideCapabilityResult({ sessionId: this.sessionId, requestId, clientId, ok, output, error }); + this.sendJson(res, 200, ack, origin); + >>> + + method name=handleApproval params="req:IncomingMessage, res:ServerResponse, origin:string" returns="Promise" async=true private=true + doc "The user's decision on an approval-request: { requestId, decision } where decision ∈ approve|approve-session|deny|deny-session|abort. Validated before reaching the brain; resolves the gated turn by requestId." + handler <<< + const body = await this.readJson(req); + const requestId = typeof body.requestId === 'string' ? body.requestId : ''; + const decision = typeof body.decision === 'string' ? body.decision : ''; + const allowed = ['approve', 'approve-session', 'deny', 'deny-session', 'abort']; + if (!requestId || !allowed.includes(decision)) { this.sendJson(res, 400, { error: 'missing requestId or invalid decision' }, origin); return; } + const clientId = typeof body.clientId === 'string' ? body.clientId : 'http'; + const ack = await this.brain.provideApproval({ sessionId: this.sessionId, requestId, clientId, decision: decision as 'approve' | 'approve-session' | 'deny' | 'deny-session' | 'abort' }); + this.sendJson(res, 200, ack, origin); + >>> + method name=readJson params="req:IncomingMessage" returns="Promise>" async=true private=true doc "Read + parse a JSON request body, capped at MAX_SEND_BODY_BYTES. Text turns are tiny, but /send may carry inline base64 images (browser screenshots) for the frontend-inspector path — the cap is sized to the brain's image contract (MAX_DISPATCH_IMAGES × MAX_DISPATCH_IMAGE_BYTES, base64-expanded, + slack) so a legitimate multi-image turn isn't 413'd before the per-image caps in decodeDataUrlToImageFile run, while still bounding a hostile/oversize body. Throws 'request body too large' (→413) or 'invalid JSON body' (→400), both mapped by handle()." handler <<< diff --git a/packages/cli/src/kern/commands/serve.kern b/packages/cli/src/kern/commands/serve.kern index 6c151e44..d9bea107 100644 --- a/packages/cli/src/kern/commands/serve.kern +++ b/packages/cli/src/kern/commands/serve.kern @@ -39,7 +39,7 @@ import from="@kernlang/agon-core" names="BrainClient" types=true import from="node:fs" names="mkdirSync,writeFileSync,rmSync,chmodSync" import from="node:path" names="join" import from="../lib/engines-dir.js" names="resolveBuiltinEnginesDir" -import from="../bridge/headless-brain-client.js" names="createHeadlessTurnBrainClient" +import from="../bridge/agentic-brain-client.js" names="createAgenticTurnBrainClient" import from="../bridge/agon-serve.js" names="createAgonServe" import from="../bridge/agon-serve.js" names="AgonServe" types=true import from="../blocks/output-format.js" names="header,info,success,warn,bold,dim,green,cyan,yellow,red" @@ -152,18 +152,16 @@ fn name=buildServeRuntime params="opts:ServeOptions" returns="Promise { it('parseOrigins: comma-separated string → trimmed, deduped list', () => { @@ -179,6 +181,101 @@ describe('agon serve — runtime wiring (integration)', () => { ).rejects.toThrow(/Unknown engine/); }); + it('register-capability validates the spec + accepts a valid one (the AGENTIC brain is wired, not the unsupported v1)', async () => { + const runtime = await buildServeRuntime({ engineId: 'claude', cwd: process.cwd(), allowedOrigins: ['https://ext.example'] }); + const { url, token } = await runtime.serve.start(0); + const H = { Authorization: `Bearer ${token}`, Origin: 'https://ext.example', 'Content-Type': 'application/json' }; + try { + // A spec missing name/description → 400 (the spec is client-supplied/untrusted). + const bad = await fetch(`${url}/register-capability`, { method: 'POST', headers: H, body: JSON.stringify({ clientId: 'c', spec: { description: 'x' } }) }); + expect(bad.status).toBe(400); + // A valid spec → 200 accepted. The headless v1 returned { unsupported } here; an + // 'accepted' proves serve now binds the agentic tool-loop brain. + const good = await fetch(`${url}/register-capability`, { method: 'POST', headers: H, body: JSON.stringify({ clientId: 'c', spec: { name: 'readPage', description: 'read the page', inputSchema: {}, isReadOnly: true } }) }); + expect(good.status).toBe(200); + expect(await good.json()).toEqual({ status: 'accepted' }); + // /approval validates the decision enum. + const badDec = await fetch(`${url}/approval`, { method: 'POST', headers: H, body: JSON.stringify({ requestId: 'r', decision: 'maybe' }) }); + expect(badDec.status).toBe(400); + // A stale capability-result is a 200 carrying a rejected ack (no pending request). + const stale = await fetch(`${url}/capability-result`, { method: 'POST', headers: H, body: JSON.stringify({ requestId: 'nope', ok: true }) }); + expect(stale.status).toBe(200); + expect((await stale.json()).status).toBe('rejected'); + } finally { + await runtime.serve.close(); + await runtime.brain.close(); + } + }); + + it('capability round-trip over the wire: send blocks on a capability-request (SSE) until /capability-result lands — no deadlock', async () => { + // A fake agentic brain: runTurn yields a capability-request, AWAITS its result via + // provideCapabilityResult, then yields the answer. Proves AgonServe ships the request + // over SSE while /send still holds the turn lock, and the separate /capability-result + // endpoint resolves that suspended turn (the whole reason control endpoints bypass turnTail). + const pending = new Map void>(); + const fakeBrain = { + runTurn: async function* (req: { turnId: string }) { + yield { kind: 'capability-request', requestId: 'cap-req-1', capability: 'readPage', input: {}, targetClientId: 'c-ext' }; + const r = await new Promise<{ ok: boolean; output?: string }>((resolve) => pending.set('cap-req-1', resolve)); + yield { kind: 'engine', engineId: 'x', content: `the page says: ${r.output ?? ''}` }; + return { turnId: req.turnId, delegated: false, responded: true, engineId: 'x' }; + }, + provideCapabilityResult: async (res: { requestId: string; ok: boolean; output?: string }) => { + const resolve = pending.get(res.requestId); + if (!resolve) return { status: 'rejected', reason: 'none' }; + pending.delete(res.requestId); + resolve({ ok: res.ok, output: res.output }); + return { status: 'accepted' }; + }, + } as unknown as BrainClient; + + const sessionId = newServeSessionId(1700000009999); + seedServeSession(sessionId, 'x'); // give the SSE replay a floor + const serve = createAgonServe({ brain: fakeBrain, sessionId, allowedOrigins: ['https://ext.example'], engines: ['x'], engineId: 'x' }); + const { url, token } = await serve.start(0); + const H = { Authorization: `Bearer ${token}`, Origin: 'https://ext.example' }; + const ctrl = new AbortController(); + try { + const ev = await fetch(`${url}/events?from=0`, { headers: { ...H }, signal: ctrl.signal }); + const reader = ev.body!.getReader(); + const dec = new TextDecoder(); + + // Fire /send but DON'T await — it cannot resolve until we answer the capability. + const sendP = fetch(`${url}/send`, { method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify({ input: 'what is on the page', clientId: 'c-ext' }) }); + + // Read SSE frames until the capability-request arrives. + let buf = ''; + let reqId = ''; + for (let i = 0; i < 60 && !reqId; i++) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value); + for (const line of buf.split('\n')) { + if (!line.startsWith('data: ')) continue; + try { + const entry = JSON.parse(line.slice(6)) as { event?: { kind?: string; requestId?: string } }; + if (entry.event?.kind === 'capability-request') reqId = entry.event.requestId ?? ''; + } catch { /* partial frame — next read completes it */ } + } + } + expect(reqId).toBe('cap-req-1'); + + // Answer the capability via its own endpoint while /send is still pending. + const capRes = await fetch(`${url}/capability-result`, { method: 'POST', headers: { ...H, 'Content-Type': 'application/json' }, body: JSON.stringify({ requestId: reqId, ok: true, output: 'HELLO WORLD', clientId: 'c-ext' }) }); + expect(capRes.status).toBe(200); + expect((await capRes.json()).status).toBe('accepted'); + + // NOW /send completes, carrying the brain's terminal result. + const sendRes = await sendP; + expect(sendRes.status).toBe(200); + const body = await sendRes.json(); + expect(body.result).toMatchObject({ responded: true, engineId: 'x' }); + } finally { + ctrl.abort(); + await serve.close(); + } + }, 15000); + it('runServe fails CLOSED (exit 2) when the port is in use — no crash, no hang, brain torn down', async () => { // Occupy a loopback port so AgonServe.start(port) rejects (EADDRINUSE). const blocker = createServer(() => {}); From 18bc096b72beaa0506f80b4a42561eb14d57a70d Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:08:10 +0200 Subject: [PATCH 3/7] =?UTF-8?q?fix(bridge):=20close=20the=20agent-review?= =?UTF-8?q?=20findings=20=E2=80=94=20fail-safe=20gate=20+=20per-client=20o?= =?UTF-8?q?wnership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 6-engine agon review of the agent brain + wire. Real findings fixed; false positives dismissed with reasoning. - FAIL-SAFE approval gate (claude, important): the gate now keys on !isReadOnly, not the client's isDestructive flag. A tool must be EXPLICITLY read-only to skip approval — a mis-/under-declared mutating tool is gated, never silently run. The engine-facing label is aligned to isReadOnly so a mutating tool is never shown as "(read-only)". Since the engine picks tools partly from untrusted page text, this closes a prompt-injection bypass of the "ask before acting" gate. - Per-client OWNERSHIP enforcement (codex 0.97/0.98, kimi 0.85): provideCapabilityResult and provideApproval now accept a reply ONLY from the client the request was routed to (capability owner / turn submitter) — enforcing the declared host-only arbitration instead of merely advertising it. A second token-holding client can no longer spoof another's tool result or approve its destructive action; a mismatch is rejected WITHOUT consuming the pending entry. unregisterCapability is ownership-checked too. - Spec size cap (minimax): a registered capability spec is bounded at 4 KiB so it can't bloat every subsequent dispatch's system prompt; inputSchema render is sliced. Dismissed (verified): close() timer "leak" (close aborts controllers first, firing onAbort→clearTimeout), description-mandatory mismatch (it's required by the type), timeout-ends-turn (deliberate fail-fast: a client timeout means the browser is gone, not engine-recoverable like deny/unknown). Two new tests: the fail-safe gate (a tool with neither flag is still gated) and ownership (a non-owner client's result is rejected; the owner's is accepted). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../generated/bridge/agentic-brain-client.ts | 52 ++++++++++-------- .../cli/src/generated/bridge/agon-serve.ts | 9 +++- .../src/kern/bridge/agentic-brain-client.kern | 53 +++++++++++-------- packages/cli/src/kern/bridge/agon-serve.kern | 7 +++ tests/unit/agentic-brain-client.test.ts | 40 ++++++++++++++ 5 files changed, 116 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/generated/bridge/agentic-brain-client.ts b/packages/cli/src/generated/bridge/agentic-brain-client.ts index 05c21528..34b83250 100644 --- a/packages/cli/src/generated/bridge/agentic-brain-client.ts +++ b/packages/cli/src/generated/bridge/agentic-brain-client.ts @@ -80,9 +80,11 @@ export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): lines.push(' (none registered — answer from your own knowledge.)'); } else { for (const t of tools) { - const tag = t.isDestructive ? '(ACTS on the page — the user must approve)' : '(read-only)'; + // Label by isReadOnly (fail-safe): anything NOT explicitly read-only ACTS and is + // gated — matching the runTurn gate, so the engine never sees a mutating tool as "read-only". + const tag = t.isReadOnly ? '(read-only)' : '(ACTS on the page — the user must approve)'; lines.push(`- ${t.name} ${tag}: ${t.description}`); - lines.push(` input: ${JSON.stringify(t.inputSchema)}`); + lines.push(` input: ${JSON.stringify(t.inputSchema).slice(0, 800)}`); } } lines.push( @@ -99,7 +101,7 @@ export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): /** * The growing ReAct transcript re-sent to the (stateless, exec-mode) engine each step: the original request plus every prior tool call and its result, ending with a nudge to act or answer. */ -// @kern-source: agentic-brain-client:109 +// @kern-source: agentic-brain-client:111 export function renderAgentTranscript(userInput: string, steps: Array<{ name: string; input: Record; output: string }>): string { const lines: string[] = [`User request: ${userInput}`, '']; if (steps.length === 0) { @@ -118,7 +120,7 @@ export function renderAgentTranscript(userInput: string, steps: Array<{ name: st /** * A compact, human-readable one-liner for the approval popup's `command` field — what the agent is about to do. */ -// @kern-source: agentic-brain-client:126 +// @kern-source: agentic-brain-client:128 export function describeAgentAction(name: string, input: Record): string { let arg = ''; try { arg = JSON.stringify(input); } catch { arg = '{…}'; } @@ -129,7 +131,7 @@ export function describeAgentAction(name: string, input: Record /** * v2 BrainClient: a bounded ReAct tool-loop over one engine, with client-lent capabilities (registerCapability) the brain pulls mid-turn via capability-request, and a per-action approval gate for destructive tools. Construct with the daemon's EngineRegistry; open() binds engine/cwd; runTurn() drives the loop; provideCapabilityResult/provideApproval answer the *-request events by requestId. */ -// @kern-source: agentic-brain-client:137 +// @kern-source: agentic-brain-client:139 export class AgenticTurnBrainClient implements BrainClient { private registry: EngineRegistry; private adapter: EngineAdapter; @@ -140,8 +142,8 @@ export class AgenticTurnBrainClient implements BrainClient { private activeTurnId: string|null; private aborts: Map; private caps: Map; - private pendingCaps: Map void>; - private pendingApprovals: Map void>; + private pendingCaps: Map void }>; + private pendingApprovals: Map void }>; private approvedSession: Set; private deniedSession: Set; controlCapabilities: ControlCapabilities; @@ -182,25 +184,25 @@ export class AgenticTurnBrainClient implements BrainClient { this.systemPrompt = config.systemPrompt; } - private async waitForCapabilityResult(requestId: string, signal: AbortSignal): Promise { + private async waitForCapabilityResult(requestId: string, ownerClientId: string, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error('aborted')); return; } let timer: ReturnType; const onAbort = () => { this.pendingCaps.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; timer = setTimeout(() => { this.pendingCaps.delete(requestId); signal.removeEventListener('abort', onAbort); reject(new Error('timeout')); }, CAPABILITY_RESULT_TIMEOUT_MS); signal.addEventListener('abort', onAbort, { once: true }); - this.pendingCaps.set(requestId, (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); }); + this.pendingCaps.set(requestId, { clientId: ownerClientId, resolve: (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); } }); }); } - private async waitForApproval(requestId: string, signal: AbortSignal): Promise { + private async waitForApproval(requestId: string, ownerClientId: string, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error('aborted')); return; } let timer: ReturnType; const onAbort = () => { this.pendingApprovals.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; timer = setTimeout(() => { this.pendingApprovals.delete(requestId); signal.removeEventListener('abort', onAbort); resolve('deny'); }, APPROVAL_TIMEOUT_MS); signal.addEventListener('abort', onAbort, { once: true }); - this.pendingApprovals.set(requestId, (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); }); + this.pendingApprovals.set(requestId, { clientId: ownerClientId, resolve: (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); } }); }); } @@ -288,8 +290,10 @@ export class AgenticTurnBrainClient implements BrainClient { const running: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'running', input: describeAgentAction(call.name, call.input) }; yield running; - // Approval gate for destructive tools (skipped if approved-for-session). - if (cap.spec.isDestructive && !this.approvedSession.has(call.name)) { + // Approval gate — FAIL-SAFE: gate anything that is not EXPLICITLY read-only + // (a tool the client mis-/under-declares still asks, never silently acts). + // Skipped only once the user approved this tool for the session. + if (!cap.spec.isReadOnly && !this.approvedSession.has(call.name)) { if (this.deniedSession.has(call.name)) { const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied for this session' }; yield denied; @@ -300,7 +304,7 @@ export class AgenticTurnBrainClient implements BrainClient { const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description }; yield ask; let decision: string; - try { decision = await this.waitForApproval(apId, ctrl.signal); } + try { decision = await this.waitForApproval(apId, req.clientId, ctrl.signal); } catch { const reason = 'cancelled by client'; const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; @@ -329,7 +333,7 @@ export class AgenticTurnBrainClient implements BrainClient { const capReq: BrainEvent = { kind: 'capability-request', requestId: reqId, capability: call.name, input: call.input, targetClientId: cap.clientId }; yield capReq; let capRes: CapabilityResult; - try { capRes = await this.waitForCapabilityResult(reqId, ctrl.signal); } + try { capRes = await this.waitForCapabilityResult(reqId, cap.clientId, ctrl.signal); } catch (err) { const aborted = ctrl.signal.aborted; const failNote: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: aborted ? 'cancelled' : 'the browser did not respond' }; @@ -400,23 +404,27 @@ export class AgenticTurnBrainClient implements BrainClient { } async unregisterCapability(req: CapabilityUnregister): Promise { + const existing = this.caps.get(req.name); + if (existing && existing.clientId !== req.clientId) return { status: 'rejected', reason: `capability "${req.name}" is owned by another client` }; this.caps.delete(req.name); return { status: 'accepted' }; } async provideCapabilityResult(res: CapabilityResult): Promise { - const resolve = this.pendingCaps.get(res.requestId); - if (!resolve) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + const pending = this.pendingCaps.get(res.requestId); + if (!pending) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + if (pending.clientId && res.clientId !== pending.clientId) return { status: 'rejected', reason: `capability ${res.requestId} is owned by another client` }; this.pendingCaps.delete(res.requestId); - resolve(res); + pending.resolve(res); return { status: 'accepted' }; } async provideApproval(res: ApprovalResponse): Promise { - const resolve = this.pendingApprovals.get(res.requestId); - if (!resolve) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + const pending = this.pendingApprovals.get(res.requestId); + if (!pending) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + if (pending.clientId && res.clientId !== pending.clientId) return { status: 'rejected', reason: `approval ${res.requestId} belongs to another client` }; this.pendingApprovals.delete(res.requestId); - resolve(res.decision); + pending.resolve(res.decision); return { status: 'accepted' }; } @@ -452,7 +460,7 @@ export class AgenticTurnBrainClient implements BrainClient { /** * Factory mirroring createHeadlessTurnBrainClient: build the v2 agentic tool-loop BrainClient from the daemon's EngineRegistry. */ -// @kern-source: agentic-brain-client:481 +// @kern-source: agentic-brain-client:490 export function createAgenticTurnBrainClient(registry: EngineRegistry): BrainClient { return new AgenticTurnBrainClient(registry); } diff --git a/packages/cli/src/generated/bridge/agon-serve.ts b/packages/cli/src/generated/bridge/agon-serve.ts index 51a50462..0d27ea50 100644 --- a/packages/cli/src/generated/bridge/agon-serve.ts +++ b/packages/cli/src/generated/bridge/agon-serve.ts @@ -231,6 +231,13 @@ export class AgonServe { isReadOnly: spec.isReadOnly === true, isDestructive: spec.isDestructive === true, }; + // Bound a single tool spec: its name/description/inputSchema are serialized into + // EVERY subsequent dispatch's system prompt, so an unbounded spec would inflate the + // token cost of the whole turn. 4 KiB is generous for a real tool, tiny for an attack. + if (JSON.stringify(clean).length > 4096) { + this.sendJson(res, 400, { error: 'capability spec too large (max 4096 bytes)' }, origin); + return; + } const ack = await this.brain.registerCapability({ sessionId: this.sessionId, clientId, spec: clean }); this.sendJson(res, 200, ack, origin); } @@ -295,7 +302,7 @@ export class AgonServe { /** * Factory: build the loopback bridge for a session given an opened BrainClient and the session id. */ -// @kern-source: agon-serve:339 +// @kern-source: agon-serve:346 export function createAgonServe(opts: { brain: BrainClient, sessionId: string, allowedOrigins?: string[], engines?: string[], engineId?: string }): AgonServe { return new AgonServe(opts); } diff --git a/packages/cli/src/kern/bridge/agentic-brain-client.kern b/packages/cli/src/kern/bridge/agentic-brain-client.kern index 5e101a08..2895f320 100644 --- a/packages/cli/src/kern/bridge/agentic-brain-client.kern +++ b/packages/cli/src/kern/bridge/agentic-brain-client.kern @@ -90,9 +90,11 @@ fn name=buildAgentSystemPrompt params="tools:CapabilitySpec[], base?:string" ret lines.push(' (none registered — answer from your own knowledge.)'); } else { for (const t of tools) { - const tag = t.isDestructive ? '(ACTS on the page — the user must approve)' : '(read-only)'; + // Label by isReadOnly (fail-safe): anything NOT explicitly read-only ACTS and is + // gated — matching the runTurn gate, so the engine never sees a mutating tool as "read-only". + const tag = t.isReadOnly ? '(read-only)' : '(ACTS on the page — the user must approve)'; lines.push(`- ${t.name} ${tag}: ${t.description}`); - lines.push(` input: ${JSON.stringify(t.inputSchema)}`); + lines.push(` input: ${JSON.stringify(t.inputSchema).slice(0, 800)}`); } } lines.push( @@ -145,8 +147,8 @@ service name=AgenticTurnBrainClient implements=BrainClient field name=activeTurnId type="string|null" private=true field name=aborts type="Map" private=true field name=caps type="Map" private=true - field name=pendingCaps type="Map void>" private=true - field name=pendingApprovals type="Map void>" private=true + field name=pendingCaps type="Map void }>" private=true + field name=pendingApprovals type="Map void }>" private=true field name=approvedSession type="Set" private=true field name=deniedSession type="Set" private=true field name=controlCapabilities type=ControlCapabilities @@ -189,8 +191,8 @@ service name=AgenticTurnBrainClient implements=BrainClient this.systemPrompt = config.systemPrompt; >>> - method name=waitForCapabilityResult params="requestId:string, signal:AbortSignal" returns="Promise" async=true private=true - doc "Suspend the turn until the owning client POSTs its CapabilityResult (provideCapabilityResult resolves the pending entry by requestId). Rejects on the turn's abort or after CAPABILITY_RESULT_TIMEOUT_MS so a closed/unresponsive panel can never hang the brain." + method name=waitForCapabilityResult params="requestId:string, ownerClientId:string, signal:AbortSignal" returns="Promise" async=true private=true + doc "Suspend the turn until the OWNING client (ownerClientId — the one that registered the tool) POSTs its CapabilityResult; provideCapabilityResult resolves the pending entry by requestId only when the reply's clientId matches. Rejects on the turn's abort or after CAPABILITY_RESULT_TIMEOUT_MS so a closed/unresponsive panel can never hang the brain." handler <<< return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error('aborted')); return; } @@ -198,12 +200,12 @@ service name=AgenticTurnBrainClient implements=BrainClient const onAbort = () => { this.pendingCaps.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; timer = setTimeout(() => { this.pendingCaps.delete(requestId); signal.removeEventListener('abort', onAbort); reject(new Error('timeout')); }, CAPABILITY_RESULT_TIMEOUT_MS); signal.addEventListener('abort', onAbort, { once: true }); - this.pendingCaps.set(requestId, (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); }); + this.pendingCaps.set(requestId, { clientId: ownerClientId, resolve: (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); } }); }); >>> - method name=waitForApproval params="requestId:string, signal:AbortSignal" returns="Promise" async=true private=true - doc "Suspend the turn until the user answers the approval-request (provideApproval resolves by requestId with the decision). Rejects on abort; on APPROVAL_TIMEOUT_MS resolves to 'deny' (a no-answer is a safe no, not a hang)." + method name=waitForApproval params="requestId:string, ownerClientId:string, signal:AbortSignal" returns="Promise" async=true private=true + doc "Suspend the turn until the OWNING client (ownerClientId — the turn's submitter, the host for v1) answers the approval-request; provideApproval resolves by requestId only when the reply's clientId matches (host-only arbitration). Rejects on abort; on APPROVAL_TIMEOUT_MS resolves to 'deny' (a no-answer is a safe no, not a hang)." handler <<< return new Promise((resolve, reject) => { if (signal.aborted) { reject(new Error('aborted')); return; } @@ -211,7 +213,7 @@ service name=AgenticTurnBrainClient implements=BrainClient const onAbort = () => { this.pendingApprovals.delete(requestId); clearTimeout(timer); reject(new Error('aborted')); }; timer = setTimeout(() => { this.pendingApprovals.delete(requestId); signal.removeEventListener('abort', onAbort); resolve('deny'); }, APPROVAL_TIMEOUT_MS); signal.addEventListener('abort', onAbort, { once: true }); - this.pendingApprovals.set(requestId, (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); }); + this.pendingApprovals.set(requestId, { clientId: ownerClientId, resolve: (decision: string) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(decision); } }); }); >>> @@ -301,8 +303,10 @@ service name=AgenticTurnBrainClient implements=BrainClient const running: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'running', input: describeAgentAction(call.name, call.input) }; yield running; - // Approval gate for destructive tools (skipped if approved-for-session). - if (cap.spec.isDestructive && !this.approvedSession.has(call.name)) { + // Approval gate — FAIL-SAFE: gate anything that is not EXPLICITLY read-only + // (a tool the client mis-/under-declares still asks, never silently acts). + // Skipped only once the user approved this tool for the session. + if (!cap.spec.isReadOnly && !this.approvedSession.has(call.name)) { if (this.deniedSession.has(call.name)) { const denied: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: 'denied for this session' }; yield denied; @@ -313,7 +317,7 @@ service name=AgenticTurnBrainClient implements=BrainClient const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description }; yield ask; let decision: string; - try { decision = await this.waitForApproval(apId, ctrl.signal); } + try { decision = await this.waitForApproval(apId, req.clientId, ctrl.signal); } catch { const reason = 'cancelled by client'; const note: BrainEvent = { kind: 'notice', level: 'warning', message: reason }; @@ -342,7 +346,7 @@ service name=AgenticTurnBrainClient implements=BrainClient const capReq: BrainEvent = { kind: 'capability-request', requestId: reqId, capability: call.name, input: call.input, targetClientId: cap.clientId }; yield capReq; let capRes: CapabilityResult; - try { capRes = await this.waitForCapabilityResult(reqId, ctrl.signal); } + try { capRes = await this.waitForCapabilityResult(reqId, cap.clientId, ctrl.signal); } catch (err) { const aborted = ctrl.signal.aborted; const failNote: BrainEvent = { kind: 'tool', engineId: turnEngineId, tool: call.name, status: 'error', output: aborted ? 'cancelled' : 'the browser did not respond' }; @@ -417,28 +421,33 @@ service name=AgenticTurnBrainClient implements=BrainClient >>> method name=unregisterCapability params="req:CapabilityUnregister" returns="Promise" async=true + doc "Ownership-checked: a client may only unregister a tool IT registered, so one attached client can't strip another's capabilities." handler <<< + const existing = this.caps.get(req.name); + if (existing && existing.clientId !== req.clientId) return { status: 'rejected', reason: `capability "${req.name}" is owned by another client` }; this.caps.delete(req.name); return { status: 'accepted' }; >>> method name=provideCapabilityResult params="res:CapabilityResult" returns="Promise" async=true - doc "A client's reply to a capability-request — resolves the pending turn await by requestId. Rejected (not unsupported) if no such request is outstanding (stale/duplicate)." + doc "A client's reply to a capability-request — resolves the pending turn await by requestId, but ONLY when the reply's clientId matches the client the request was routed to. A mismatched clientId is rejected WITHOUT consuming the pending entry, so a second token-holding client can't spoof another's tool result. Stale/duplicate requestId is also rejected." handler <<< - const resolve = this.pendingCaps.get(res.requestId); - if (!resolve) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + const pending = this.pendingCaps.get(res.requestId); + if (!pending) return { status: 'rejected', reason: `no pending capability request ${res.requestId}` }; + if (pending.clientId && res.clientId !== pending.clientId) return { status: 'rejected', reason: `capability ${res.requestId} is owned by another client` }; this.pendingCaps.delete(res.requestId); - resolve(res); + pending.resolve(res); return { status: 'accepted' }; >>> method name=provideApproval params="res:ApprovalResponse" returns="Promise" async=true - doc "The user's decision on an approval-request — resolves the pending gate by requestId. Arbitration is host-only; a stale/duplicate requestId is rejected." + doc "The user's decision on an approval-request — resolves the pending gate by requestId, but ONLY from the client that submitted the turn (host-only arbitration enforced, not just declared). A mismatched clientId is rejected without consuming the gate, so one client can't approve another's destructive action. Stale/duplicate requestId is rejected." handler <<< - const resolve = this.pendingApprovals.get(res.requestId); - if (!resolve) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + const pending = this.pendingApprovals.get(res.requestId); + if (!pending) return { status: 'rejected', reason: `no pending approval ${res.requestId}` }; + if (pending.clientId && res.clientId !== pending.clientId) return { status: 'rejected', reason: `approval ${res.requestId} belongs to another client` }; this.pendingApprovals.delete(res.requestId); - resolve(res.decision); + pending.resolve(res.decision); return { status: 'accepted' }; >>> diff --git a/packages/cli/src/kern/bridge/agon-serve.kern b/packages/cli/src/kern/bridge/agon-serve.kern index 467ba2aa..d21c038e 100644 --- a/packages/cli/src/kern/bridge/agon-serve.kern +++ b/packages/cli/src/kern/bridge/agon-serve.kern @@ -268,6 +268,13 @@ service name=AgonServe isReadOnly: spec.isReadOnly === true, isDestructive: spec.isDestructive === true, }; + // Bound a single tool spec: its name/description/inputSchema are serialized into + // EVERY subsequent dispatch's system prompt, so an unbounded spec would inflate the + // token cost of the whole turn. 4 KiB is generous for a real tool, tiny for an attack. + if (JSON.stringify(clean).length > 4096) { + this.sendJson(res, 400, { error: 'capability spec too large (max 4096 bytes)' }, origin); + return; + } const ack = await this.brain.registerCapability({ sessionId: this.sessionId, clientId, spec: clean }); this.sendJson(res, 200, ack, origin); >>> diff --git a/tests/unit/agentic-brain-client.test.ts b/tests/unit/agentic-brain-client.test.ts index 798a170f..cf0014c9 100644 --- a/tests/unit/agentic-brain-client.test.ts +++ b/tests/unit/agentic-brain-client.test.ts @@ -186,6 +186,21 @@ describe('AgenticTurnBrainClient — the ReAct loop', () => { expect(result.responded).toBe(true); }); + it('FAIL-SAFE gate: a tool that is not explicitly read-only is gated even without isDestructive', async () => { + const client = makeAgent([`${MARK} {"name":"mutate","input":{}}`, 'did it']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + // Neither isReadOnly:true nor isDestructive:true — a mis-/under-declared mutating tool. + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: { name: 'mutate', description: 'changes things', inputSchema: {}, isReadOnly: false } }); + let approvalAsked = false; + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { + capability: () => ({ ok: true, output: 'ok' }), + approval: () => { approvalAsked = true; return 'approve'; }, + }); + expect(approvalAsked).toBe(true); // gated despite isDestructive omitted (default-deny) + expect(events.some((e) => e.kind === 'approval-request')).toBe(true); + expect(result.responded).toBe(true); + }); + it('an unknown tool is reported back and the loop recovers to an answer', async () => { const client = makeAgent([`${MARK} {"name":"teleport","input":{}}`, 'I cannot do that here.']); await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); @@ -254,6 +269,31 @@ describe('AgenticTurnBrainClient — control surface', () => { expect((fin.value as BrainTurnResult).reason).toBe('cancelled by client'); }); + it('ownership: a capability result from a non-owner client is rejected; the owner is accepted', async () => { + const client = makeAgent([`${MARK} {"name":"readPage","input":{}}`, 'done reading']); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'owner', spec: readSpec() }); + const gen = client.runTurn({ sessionId: 's', turnId: 't1', clientId: 'owner', input: 'read it' }); + let attackerAck = ''; + let ownerAck = ''; + let r = await gen.next(); + while (!r.done) { + if (r.value.kind === 'capability-request') { + const reqId = (r.value as { requestId: string }).requestId; + queueMicrotask(async () => { + // A second token-holding client (different clientId) cannot answer the request… + attackerAck = (await client.provideCapabilityResult({ sessionId: 's', requestId: reqId, clientId: 'attacker', ok: true, output: 'X' })).status; + // …but the owner can, and the turn then completes. + ownerAck = (await client.provideCapabilityResult({ sessionId: 's', requestId: reqId, clientId: 'owner', ok: true, output: 'PAGE' })).status; + }); + } + r = await gen.next(); + } + expect(attackerAck).toBe('rejected'); + expect(ownerAck).toBe('accepted'); + expect((r.value as BrainTurnResult).responded).toBe(true); + }); + it('factory builds an interface-conformant agent reporting liveness', async () => { const client = createAgenticTurnBrainClient({ get: (id: string) => ({ id }), listIds: () => ['claude'] } as unknown as EngineRegistry); const h = await client.health(); From 7a3982b1e125b1282e753c477246641cd32125ea Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 18:32:04 +0200 Subject: [PATCH 4/7] fix(bridge): recover when an engine narrates an action but emits no tool call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field report: glm-5.2 replied "Let me navigate to your LinkedIn profile…" with NO __AGON_TOOL__ line, so the loop treated the prose as a final answer and ended — the agent SAID it would act and then did nothing. This is the weak-engine tool-call reliability risk; two mitigations: - Far more forceful agent protocol prompt: explicit "do NOT narrate then stop — narration does nothing, only a tool line acts", with concrete __AGON_TOOL__ examples. - Narration recovery in runTurn: when a reply has no tool call but looksLikeActionIntent (a short "Let me…/I'll…" + an action verb, head-only match so a real prose answer isn't caught), nudge the engine to actually emit the tool line instead of ending. Bounded by MAX_NARRATION_RETRIES (2) so a chatty engine can't loop; after the budget it surfaces the prose as the answer. 3 new tests: looksLikeActionIntent (preamble vs real answer), the nudge→act path, and the give-up-after-budget path (no infinite loop). Engines that DO emit tool calls (claude/codex) are unaffected — the nudge only fires on no-tool + action-intent. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- .../generated/bridge/agentic-brain-client.ts | 57 +++++++++++++++---- .../src/kern/bridge/agentic-brain-client.kern | 39 +++++++++++-- tests/unit/agentic-brain-client.test.ts | 36 ++++++++++++ 3 files changed, 115 insertions(+), 17 deletions(-) diff --git a/packages/cli/src/generated/bridge/agentic-brain-client.ts b/packages/cli/src/generated/bridge/agentic-brain-client.ts index 34b83250..7f80feb8 100644 --- a/packages/cli/src/generated/bridge/agentic-brain-client.ts +++ b/packages/cli/src/generated/bridge/agentic-brain-client.ts @@ -21,15 +21,18 @@ export const AGENT_TOOL_MARKER: string = '__AGON_TOOL__'; export const MAX_AGENT_STEPS: number = 8; // @kern-source: agentic-brain-client:38 -export const CAPABILITY_RESULT_TIMEOUT_MS: number = 60_000; +export const MAX_NARRATION_RETRIES: number = 2; // @kern-source: agentic-brain-client:39 +export const CAPABILITY_RESULT_TIMEOUT_MS: number = 60_000; + +// @kern-source: agentic-brain-client:40 export const APPROVAL_TIMEOUT_MS: number = 180_000; /** * Extract an agent tool call from an engine's stdout: locate the AGENT_TOOL_MARKER and parse the first balanced {…} JSON object after it. Forgiving — surrounding prose or a ```code fence``` is tolerated, and a missing/garbled sentinel returns null (the caller treats null as a final prose answer). Returns null unless the object has a string `name`; a non-object `input` defaults to {}. */ -// @kern-source: agentic-brain-client:43 +// @kern-source: agentic-brain-client:44 export function parseAgentToolCall(stdout: string): { name: string; input: Record } | null { const idx = stdout.indexOf(AGENT_TOOL_MARKER); if (idx === -1) return null; @@ -66,7 +69,7 @@ export function parseAgentToolCall(stdout: string): { name: string; input: Recor /** * The agent's system prompt: the ReAct tool protocol plus the catalog of client-lent capabilities. Read-only vs destructive is surfaced so the engine knows which actions trigger the user's approval gate. */ -// @kern-source: agentic-brain-client:78 +// @kern-source: agentic-brain-client:79 export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): string { const lines: string[] = []; if (base && base.trim().length > 0) { lines.push(base.trim(), ''); } @@ -89,11 +92,18 @@ export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): } lines.push( '', - 'TO USE A TOOL, reply with EXACTLY one line and nothing else:', + 'HOW TO ACT — this is strict. To use a tool, your reply MUST be EXACTLY one line,', + 'the tool call, and NOTHING ELSE (no preamble, no explanation):', `${AGENT_TOOL_MARKER} {"name":"","input":{ ... }}`, - 'Use ONE tool per step. You will then receive its result and may call another tool or answer.', - 'Read the page before acting on it. Never fabricate a tool result or claim an action you did not take.', - 'When you have the final answer for the user, reply normally in prose with NO tool line.', + '', + `Example — read the page: ${AGENT_TOOL_MARKER} {"name":"readPage","input":{}}`, + `Example — click a button: ${AGENT_TOOL_MARKER} {"name":"click","input":{"selector":"#submit"}}`, + '', + 'CRITICAL: Do NOT narrate ("Let me…", "I will…", "I\'m going to…") and then stop —', + 'narration alone does NOTHING; only a tool line acts. If you intend to act, EMIT THE', + 'TOOL LINE NOW. One tool per step — you then get its result and may call another tool.', + 'Read the page before acting on it. Never fabricate a result or claim an action you', + 'did not take. Reply in prose ONLY when giving the user your FINAL answer (no tool line).', ); return lines.join('\n'); } @@ -101,7 +111,7 @@ export function buildAgentSystemPrompt(tools: CapabilitySpec[], base?: string): /** * The growing ReAct transcript re-sent to the (stateless, exec-mode) engine each step: the original request plus every prior tool call and its result, ending with a nudge to act or answer. */ -// @kern-source: agentic-brain-client:111 +// @kern-source: agentic-brain-client:119 export function renderAgentTranscript(userInput: string, steps: Array<{ name: string; input: Record; output: string }>): string { const lines: string[] = [`User request: ${userInput}`, '']; if (steps.length === 0) { @@ -117,10 +127,22 @@ export function renderAgentTranscript(userInput: string, steps: Array<{ name: st return lines.join('\n'); } +/** + * True when an engine's reply DESCRIBES an action ('Let me navigate…', 'I'll click…') but emitted no tool call — a short intent preamble, not a final answer. Used to NUDGE the engine to actually emit the tool line instead of treating the narration as a final answer (the common weak-engine failure). Looks only at the head + caps length to avoid matching a real prose answer that happens to say 'review' or 'let me know'. + */ +// @kern-source: agentic-brain-client:136 +export function looksLikeActionIntent(text: string): boolean { + const head = text.trim().slice(0, 200).toLowerCase(); + if (/\blet me know\b/.test(head)) return false; // a sign-off, not an action + const intent = /\b(let me|i'?ll|i will|i'?m going to|i am going to|let's|first,? i|now i'?ll|going to)\b/.test(head); + const verb = /\b(navigat|click|type|go to|open|fill|select|read|review|look at|check|press|scroll|enter|search|find)\b/.test(head); + return intent && verb && text.trim().length < 500; +} + /** * A compact, human-readable one-liner for the approval popup's `command` field — what the agent is about to do. */ -// @kern-source: agentic-brain-client:128 +// @kern-source: agentic-brain-client:146 export function describeAgentAction(name: string, input: Record): string { let arg = ''; try { arg = JSON.stringify(input); } catch { arg = '{…}'; } @@ -131,7 +153,7 @@ export function describeAgentAction(name: string, input: Record /** * v2 BrainClient: a bounded ReAct tool-loop over one engine, with client-lent capabilities (registerCapability) the brain pulls mid-turn via capability-request, and a per-action approval gate for destructive tools. Construct with the daemon's EngineRegistry; open() binds engine/cwd; runTurn() drives the loop; provideCapabilityResult/provideApproval answer the *-request events by requestId. */ -// @kern-source: agentic-brain-client:139 +// @kern-source: agentic-brain-client:157 export class AgenticTurnBrainClient implements BrainClient { private registry: EngineRegistry; private adapter: EngineAdapter; @@ -250,6 +272,7 @@ export class AgenticTurnBrainClient implements BrainClient { const sysPrompt = buildAgentSystemPrompt(tools, this.systemPrompt); const steps: Array<{ name: string; input: Record; output: string }> = []; let imgSeq = rawImages.length; + let narrationRetries = 0; // budget for nudging an engine that narrates an action but emits no tool call for (let step = 0; step < MAX_AGENT_STEPS; step++) { if (ctrl.signal.aborted) break; @@ -274,7 +297,17 @@ export class AgenticTurnBrainClient implements BrainClient { const call = parseAgentToolCall(stdout); if (!call) { - // No tool call → the engine's final answer. + // No tool call. If the engine NARRATED an action ("Let me navigate…") but emitted + // no tool line, nudge it to actually act instead of ending — the common weak-engine + // failure where the agent says it will do something and then stops. Bounded retries. + if (tools.length > 0 && narrationRetries < MAX_NARRATION_RETRIES && looksLikeActionIntent(stdout)) { + narrationRetries++; + const nudge: BrainEvent = { kind: 'notice', level: 'warning', message: 'the engine described an action but sent no tool call — asking it to actually act' }; + yield nudge; + steps.push({ name: 'reminder', input: {}, output: `You said: "${stdout.trim().slice(0, 160)}" — but you emitted NO tool call, so nothing happened. To act, your NEXT reply must be EXACTLY one ${AGENT_TOOL_MARKER} {"name":...,"input":...} line and nothing else. If you are truly finished, give your final prose answer.` }); + continue; + } + // Otherwise it's the engine's final answer. const ans: BrainEvent = { kind: 'engine', engineId: turnEngineId, content: stdout }; yield ans; return { turnId: req.turnId, delegated: false, responded: true, engineId: turnEngineId }; @@ -460,7 +493,7 @@ export class AgenticTurnBrainClient implements BrainClient { /** * Factory mirroring createHeadlessTurnBrainClient: build the v2 agentic tool-loop BrainClient from the daemon's EngineRegistry. */ -// @kern-source: agentic-brain-client:490 +// @kern-source: agentic-brain-client:519 export function createAgenticTurnBrainClient(registry: EngineRegistry): BrainClient { return new AgenticTurnBrainClient(registry); } diff --git a/packages/cli/src/kern/bridge/agentic-brain-client.kern b/packages/cli/src/kern/bridge/agentic-brain-client.kern index 2895f320..36069ee6 100644 --- a/packages/cli/src/kern/bridge/agentic-brain-client.kern +++ b/packages/cli/src/kern/bridge/agentic-brain-client.kern @@ -35,6 +35,7 @@ import from="node:crypto" names="randomUUID" const name=AGENT_TOOL_MARKER type=string value={{ '__AGON_TOOL__' }} export=true const name=MAX_AGENT_STEPS type=number value={{ 8 }} export=true +const name=MAX_NARRATION_RETRIES type=number value={{ 2 }} export=true const name=CAPABILITY_RESULT_TIMEOUT_MS type=number value={{ 60_000 }} const name=APPROVAL_TIMEOUT_MS type=number value={{ 180_000 }} @@ -99,11 +100,18 @@ fn name=buildAgentSystemPrompt params="tools:CapabilitySpec[], base?:string" ret } lines.push( '', - 'TO USE A TOOL, reply with EXACTLY one line and nothing else:', + 'HOW TO ACT — this is strict. To use a tool, your reply MUST be EXACTLY one line,', + 'the tool call, and NOTHING ELSE (no preamble, no explanation):', `${AGENT_TOOL_MARKER} {"name":"","input":{ ... }}`, - 'Use ONE tool per step. You will then receive its result and may call another tool or answer.', - 'Read the page before acting on it. Never fabricate a tool result or claim an action you did not take.', - 'When you have the final answer for the user, reply normally in prose with NO tool line.', + '', + `Example — read the page: ${AGENT_TOOL_MARKER} {"name":"readPage","input":{}}`, + `Example — click a button: ${AGENT_TOOL_MARKER} {"name":"click","input":{"selector":"#submit"}}`, + '', + 'CRITICAL: Do NOT narrate ("Let me…", "I will…", "I\'m going to…") and then stop —', + 'narration alone does NOTHING; only a tool line acts. If you intend to act, EMIT THE', + 'TOOL LINE NOW. One tool per step — you then get its result and may call another tool.', + 'Read the page before acting on it. Never fabricate a result or claim an action you', + 'did not take. Reply in prose ONLY when giving the user your FINAL answer (no tool line).', ); return lines.join('\n'); >>> @@ -125,6 +133,16 @@ fn name=renderAgentTranscript params="userInput:string, steps:Array<{ name: stri return lines.join('\n'); >>> +fn name=looksLikeActionIntent params="text:string" returns="boolean" export=true + doc "True when an engine's reply DESCRIBES an action ('Let me navigate…', 'I'll click…') but emitted no tool call — a short intent preamble, not a final answer. Used to NUDGE the engine to actually emit the tool line instead of treating the narration as a final answer (the common weak-engine failure). Looks only at the head + caps length to avoid matching a real prose answer that happens to say 'review' or 'let me know'." + handler <<< + const head = text.trim().slice(0, 200).toLowerCase(); + if (/\blet me know\b/.test(head)) return false; // a sign-off, not an action + const intent = /\b(let me|i'?ll|i will|i'?m going to|i am going to|let's|first,? i|now i'?ll|going to)\b/.test(head); + const verb = /\b(navigat|click|type|go to|open|fill|select|read|review|look at|check|press|scroll|enter|search|find)\b/.test(head); + return intent && verb && text.trim().length < 500; + >>> + fn name=describeAgentAction params="name:string, input:Record" returns="string" export=true doc "A compact, human-readable one-liner for the approval popup's `command` field — what the agent is about to do." handler <<< @@ -263,6 +281,7 @@ service name=AgenticTurnBrainClient implements=BrainClient const sysPrompt = buildAgentSystemPrompt(tools, this.systemPrompt); const steps: Array<{ name: string; input: Record; output: string }> = []; let imgSeq = rawImages.length; + let narrationRetries = 0; // budget for nudging an engine that narrates an action but emits no tool call for (let step = 0; step < MAX_AGENT_STEPS; step++) { if (ctrl.signal.aborted) break; @@ -287,7 +306,17 @@ service name=AgenticTurnBrainClient implements=BrainClient const call = parseAgentToolCall(stdout); if (!call) { - // No tool call → the engine's final answer. + // No tool call. If the engine NARRATED an action ("Let me navigate…") but emitted + // no tool line, nudge it to actually act instead of ending — the common weak-engine + // failure where the agent says it will do something and then stops. Bounded retries. + if (tools.length > 0 && narrationRetries < MAX_NARRATION_RETRIES && looksLikeActionIntent(stdout)) { + narrationRetries++; + const nudge: BrainEvent = { kind: 'notice', level: 'warning', message: 'the engine described an action but sent no tool call — asking it to actually act' }; + yield nudge; + steps.push({ name: 'reminder', input: {}, output: `You said: "${stdout.trim().slice(0, 160)}" — but you emitted NO tool call, so nothing happened. To act, your NEXT reply must be EXACTLY one ${AGENT_TOOL_MARKER} {"name":...,"input":...} line and nothing else. If you are truly finished, give your final prose answer.` }); + continue; + } + // Otherwise it's the engine's final answer. const ans: BrainEvent = { kind: 'engine', engineId: turnEngineId, content: stdout }; yield ans; return { turnId: req.turnId, delegated: false, responded: true, engineId: turnEngineId }; diff --git a/tests/unit/agentic-brain-client.test.ts b/tests/unit/agentic-brain-client.test.ts index cf0014c9..8b2515fc 100644 --- a/tests/unit/agentic-brain-client.test.ts +++ b/tests/unit/agentic-brain-client.test.ts @@ -7,6 +7,7 @@ import { buildAgentSystemPrompt, renderAgentTranscript, describeAgentAction, + looksLikeActionIntent, AGENT_TOOL_MARKER as MARK, } from '../../packages/cli/src/generated/bridge/agentic-brain-client.js'; import type { BrainEvent, BrainTurnResult, EngineAdapter, EngineRegistry, CapabilitySpec } from '@kernlang/agon-core'; @@ -105,6 +106,13 @@ describe('agent prompt + transcript helpers', () => { expect(describeAgentAction('click', { selector: '#buy' })).toBe('click({"selector":"#buy"})'); expect(describeAgentAction('type', { v: 'x'.repeat(500) }).length).toBeLessThanOrEqual(170 + 'type()'.length); }); + it('looksLikeActionIntent flags a "Let me…" preamble but not a real final answer', () => { + expect(looksLikeActionIntent('Let me navigate to your LinkedIn profile to review it.')).toBe(true); + expect(looksLikeActionIntent("I'll click the submit button now.")).toBe(true); + expect(looksLikeActionIntent('Your profile looks strong — clear headline and a good photo.')).toBe(false); + expect(looksLikeActionIntent('Done. Let me know if you want me to review a specific section.')).toBe(false); + expect(looksLikeActionIntent('Here is my detailed review of the page. '.repeat(40))).toBe(false); // too long = a real answer + }); }); describe('AgenticTurnBrainClient — the ReAct loop', () => { @@ -201,6 +209,34 @@ describe('AgenticTurnBrainClient — the ReAct loop', () => { expect(result.responded).toBe(true); }); + it('nudges an engine that narrates an action without a tool call, then it acts', async () => { + const client = makeAgent([ + 'Let me navigate to your profile to review it.', // narration, no tool → nudge + `${MARK} {"name":"readPage","input":{}}`, // now it actually acts + 'Your profile looks good.', // final answer + ]); + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { + capability: () => ({ ok: true, output: 'PAGE' }), + approval: () => 'approve', + }); + expect(events.some((e) => e.kind === 'notice' && /tool call/.test((e as { message: string }).message))).toBe(true); + expect(events.some((e) => e.kind === 'capability-request')).toBe(true); + expect(result.responded).toBe(true); + }); + + it('gives up nudging after the retry budget and returns the prose (no infinite loop)', async () => { + const client = makeAgent(['Let me click the button.']); // always narrates, never emits a tool + await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); + await client.registerCapability({ sessionId: 's', clientId: 'c', spec: readSpec() }); + const { events, result } = await driveAgent(client, client.runTurn(req('t1')), { capability: () => ({ ok: true }), approval: () => 'approve' }); + const nudges = events.filter((e) => e.kind === 'notice' && /tool call/.test((e as { message: string }).message)).length; + expect(nudges).toBe(2); // MAX_NARRATION_RETRIES — bounded + expect(events.find((e) => e.kind === 'engine')).toMatchObject({ content: 'Let me click the button.' }); + expect(result.responded).toBe(true); + }); + it('an unknown tool is reported back and the loop recovers to an answer', async () => { const client = makeAgent([`${MARK} {"name":"teleport","input":{}}`, 'I cannot do that here.']); await client.open({ sessionId: 's', engineId: 'claude', cwd: '/tmp' }); From ea4a47f43964d6f164e3b282c38996b6aefa2476 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:09:45 +0200 Subject: [PATCH 5/7] feat(serve): engine picker shows only connected, non-hidden models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser engine selector was fed registry.listIds() — every engine definition on disk, including ones with no API key/binary and ones the user explicitly hid or removed. Switch to registry.activeIds(config): available (an API key env var is set OR the CLI binary is on PATH) AND not in hiddenEngines/removedEngines, honoring engineActivationMode. Canonicalize the bound default via resolveId so an alias-started serve (--engine kimi) never double-lists against the canonical id (kimi-for-coding-*), and force-include it on a FRESH array (no in-place mutation of the method's return) so it always shows as the current selection even if its availability check is borderline. 6-engine agon review closed all importants: availableIds ignored the activation/visibility rules; the unshift mutated the method's return; an alias could double-list the same engine. ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/generated/commands/serve.ts | 30 +++++++++++++++----- packages/cli/src/kern/commands/serve.kern | 22 ++++++++++++-- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/generated/commands/serve.ts b/packages/cli/src/generated/commands/serve.ts index cfc8eb05..1ba3c7da 100644 --- a/packages/cli/src/generated/commands/serve.ts +++ b/packages/cli/src/generated/commands/serve.ts @@ -173,16 +173,32 @@ export async function buildServeRuntime(opts: ServeOptions): Promise { ensureAgonHome(); const cwd = process.cwd(); @@ -284,7 +300,7 @@ export async function runServe(port: number, engine: string|undefined, allowedOr }); } -// @kern-source: serve:279 +// @kern-source: serve:295 export const serveCommand: any = defineCommand({ meta: { name: 'serve', diff --git a/packages/cli/src/kern/commands/serve.kern b/packages/cli/src/kern/commands/serve.kern index d9bea107..f979c823 100644 --- a/packages/cli/src/kern/commands/serve.kern +++ b/packages/cli/src/kern/commands/serve.kern @@ -167,9 +167,25 @@ fn name=buildServeRuntime params="opts:ServeOptions" returns="Promise>> From 7eae49bdf8b80937605a32c12dac3d997438afe2 Mon Sep 17 00:00:00 2001 From: "agon (KERN)" <292465531+KERN-Agon@users.noreply.github.com> Date: Tue, 23 Jun 2026 20:50:21 +0200 Subject: [PATCH 6/7] =?UTF-8?q?feat(drive):=20`agon=20drive`=20=E2=80=94?= =?UTF-8?q?=20drive=20your=20browser=20from=20the=20terminal=20via=20`agon?= =?UTF-8?q?=20serve`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second client of a running `agon serve`: a turn typed in the TERMINAL is answered by serve's agentic brain using your browser. Page-tool calls (navigate/readPage/screenshot/click/type) route to the side panel that registered them — independent of who submitted the turn — so they run in your Chrome. Approvals route to the submitter (us): a page-changing action prompts in the terminal (y/a/n/s), or --auto-approve; read-only tools need no approval. A blocking POST /send drives the turn while an SSE /events reader renders live events and answers our approval-requests. The connection is auto-discovered from the 0600 serve files (or --url/--token). Requires the side panel open + attached — that is where the page tools live; with none registered the brain just answers in text. BrainEvent 'approval-request' gains an optional targetClientId (mirrors capability-request), stamped to the turn submitter, so the prompt routes to ONE surface — the browser panel skips a terminal-driven turn's approval. Hardened: SSE failure surfaced (the turn no longer runs blind), /approval res.ok checked, non-TTY stdin denied rather than hung, the post-send drain waits for the answer (bounded), frame parsing stays O(n), and the real session id comes from /attach. 26 unit tests for the pure helpers (discovery, SSE framing, rendering, approval routing). ⚔️ Forged by [Agon](https://github.com/KERNlang/agon) Co-Authored-By: agon (KERN) <292465531+KERN-Agon@users.noreply.github.com> --- packages/cli/src/commands/drive.ts | 2 + .../generated/bridge/agentic-brain-client.ts | 7 +- packages/cli/src/generated/commands/drive.ts | 395 +++++++++++++++++ packages/cli/src/index.ts | 2 + .../src/kern/bridge/agentic-brain-client.kern | 5 +- packages/cli/src/kern/commands/drive.kern | 409 ++++++++++++++++++ .../src/generated/sessions/brain-client.ts | 30 +- .../core/src/kern/sessions/brain-client.kern | 3 +- tests/unit/drive.test.ts | 190 ++++++++ 9 files changed, 1024 insertions(+), 19 deletions(-) create mode 100644 packages/cli/src/commands/drive.ts create mode 100644 packages/cli/src/generated/commands/drive.ts create mode 100644 packages/cli/src/kern/commands/drive.kern create mode 100644 tests/unit/drive.test.ts diff --git a/packages/cli/src/commands/drive.ts b/packages/cli/src/commands/drive.ts new file mode 100644 index 00000000..94910dcf --- /dev/null +++ b/packages/cli/src/commands/drive.ts @@ -0,0 +1,2 @@ +// Re-export from KERN-generated drive command +export { driveCommand } from '../generated/commands/drive.js'; diff --git a/packages/cli/src/generated/bridge/agentic-brain-client.ts b/packages/cli/src/generated/bridge/agentic-brain-client.ts index 7f80feb8..bfe18df0 100644 --- a/packages/cli/src/generated/bridge/agentic-brain-client.ts +++ b/packages/cli/src/generated/bridge/agentic-brain-client.ts @@ -334,7 +334,10 @@ export class AgenticTurnBrainClient implements BrainClient { continue; } const apId = randomUUID(); - const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description }; + // targetClientId = the turn submitter: the brain awaits THIS client's approval + // (host-only arbitration), so naming it lets every other SSE-subscribed client + // (e.g. the browser panel for an `agon drive` turn) skip rendering the prompt. + const ask: BrainEvent = { kind: 'approval-request', requestId: apId, tool: call.name, command: describeAgentAction(call.name, call.input), reason: cap.spec.description, targetClientId: req.clientId }; yield ask; let decision: string; try { decision = await this.waitForApproval(apId, req.clientId, ctrl.signal); } @@ -493,7 +496,7 @@ export class AgenticTurnBrainClient implements BrainClient { /** * Factory mirroring createHeadlessTurnBrainClient: build the v2 agentic tool-loop BrainClient from the daemon's EngineRegistry. */ -// @kern-source: agentic-brain-client:519 +// @kern-source: agentic-brain-client:522 export function createAgenticTurnBrainClient(registry: EngineRegistry): BrainClient { return new AgenticTurnBrainClient(registry); } diff --git a/packages/cli/src/generated/commands/drive.ts b/packages/cli/src/generated/commands/drive.ts new file mode 100644 index 00000000..e33d2223 --- /dev/null +++ b/packages/cli/src/generated/commands/drive.ts @@ -0,0 +1,395 @@ +// @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/commands/drive.kern + +import { defineCommand } from 'citty'; + +import { ensureAgonHome, agonPath } from '@kernlang/agon-core'; + +import { readdirSync, readFileSync, existsSync } from 'node:fs'; + +import { join } from 'node:path'; + +import { randomUUID } from 'node:crypto'; + +import { createInterface } from 'node:readline/promises'; + +import { header, info, success, warn, bold, dim, green, cyan, yellow, red } from '../blocks/output-format.js'; + +/** + * One running serve bridge, read from $AGON_HOME/serve/.json: the loopback url + bearer token a client attaches with, plus its session id / bound engine / start time / source file. + */ +// @kern-source: drive:39 +export interface ServeConnection { + url: string; + token: string; + sessionId: string; + engineId: string; + startedAt: string; + file: string; +} + +/** + * Read every well-formed serve connection file in `dir` ($AGON_HOME/serve/). Skips partial/garbled files and any missing url/token/sessionId. Pure given the dir so the test drives it with a temp $AGON_HOME. + */ +// @kern-source: drive:48 +export function listServeConnections(dir: string): ServeConnection[] { + if (!existsSync(dir)) return []; + let files: string[]; + try { files = readdirSync(dir).filter((f) => f.endsWith('.json')); } + catch { return []; } + const out: ServeConnection[] = []; + for (const f of files) { + try { + const raw = JSON.parse(readFileSync(join(dir, f), 'utf-8')) as Record; + const url = typeof raw.url === 'string' ? raw.url : ''; + const token = typeof raw.token === 'string' ? raw.token : ''; + const sessionId = typeof raw.sessionId === 'string' ? raw.sessionId : ''; + if (!url || !token || !sessionId) continue; + out.push({ + url, token, sessionId, + engineId: typeof raw.engineId === 'string' ? raw.engineId : '', + startedAt: typeof raw.startedAt === 'string' ? raw.startedAt : '', + file: join(dir, f), + }); + } catch { /* unreadable/partial — skip */ } + } + return out; +} + +/** + * Resolve which serve session to drive. With a --session arg: exact sessionId wins, else a unique substring match; ambiguous or no match is an error listing the candidates. With no arg: the only one, or the most-recently-started (by startedAt) when several are running. Returns { error } (never throws) so the command maps it to a clean message + exit 2. + */ +// @kern-source: drive:74 +export function pickServeConnection(conns: ServeConnection[], sessionArg: string|undefined): { conn?: ServeConnection; error?: string } { + if (conns.length === 0) { + return { error: 'no running `agon serve` found. Start one (`agon serve --origin `) and open the side panel, or pass --url + --token.' }; + } + const needle = (sessionArg ?? '').trim(); + if (needle) { + const exact = conns.filter((c) => c.sessionId === needle); + const matches = exact.length > 0 ? exact : conns.filter((c) => c.sessionId.includes(needle)); + if (matches.length === 0) return { error: `no serve session matches "${needle}". Running: ${conns.map((c) => c.sessionId).join(', ')}` }; + if (matches.length > 1) return { error: `"${needle}" is ambiguous: ${matches.map((c) => c.sessionId).join(', ')}` }; + return { conn: matches[0] }; + } + if (conns.length === 1) return { conn: conns[0] }; + const sorted = [...conns].sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0)); + return { conn: sorted[0] }; +} + +/** + * Pull complete Server-Sent-Events frames (each terminated by a blank line) out of a streamed buffer. JSON.parses each `data:` payload (a serialized LoggedEvent); skips `:` comment pings and unparseable blocks. Returns the parsed frames + the unterminated remainder to carry into the next read. Pure + exported so the test feeds it split/partial chunks. + */ +// @kern-source: drive:95 +export function parseSseChunk(buffer: string): { frames: unknown[]; rest: string } { + const frames: unknown[] = []; + // Normalize CRLF → LF so the blank-line frame split works whether the source emits + // `\n\n` (our bridge) or `\r\n\r\n`. Only touch the buffer when a `\r` is actually + // present (our bridge never emits one) so the common path stays O(n), not O(n²) over + // a long carried-over buffer. + let rest = buffer.includes('\r') ? buffer.replace(/\r\n/g, '\n') : buffer; + let idx = rest.indexOf('\n\n'); + while (idx !== -1) { + const block = rest.slice(0, idx); + rest = rest.slice(idx + 2); + const dataLines = block + .split('\n') + .filter((l) => l.startsWith('data:')) + .map((l) => l.slice(5).trim()); + if (dataLines.length > 0) { + try { frames.push(JSON.parse(dataLines.join('\n'))); } + catch { /* partial/garbled data — drop this block */ } + } + idx = rest.indexOf('\n\n'); + } + return { frames, rest }; +} + +/** + * Human label for a page tool the browser is executing (rendered as 'browser: