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 new file mode 100644 index 00000000..bfe18df0 --- /dev/null +++ b/packages/cli/src/generated/bridge/agentic-brain-client.ts @@ -0,0 +1,502 @@ +// @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 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:44 +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:79 +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) { + // 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).slice(0, 800)}`); + } + } + lines.push( + '', + '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":{ ... }}`, + '', + `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'); +} + +/** + * 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: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) { + 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'); +} + +/** + * 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:146 +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:157 +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, 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, { clientId: ownerClientId, resolve: (r: CapabilityResult) => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); resolve(r); } }); + }); + } + + 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, { clientId: ownerClientId, resolve: (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; + 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; + 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. 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 }; + } + + 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 — 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; + 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(); + // 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); } + 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, 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' }; + 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 { + 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 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); + pending.resolve(res); + return { status: 'accepted' }; + } + + async provideApproval(res: ApprovalResponse): Promise { + 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); + pending.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:522 +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 bb616cb9..0d27ea50 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,64 @@ 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, + }; + // 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); + } + + 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 +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:266 +// @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/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: