diff --git a/engines/claude.json b/engines/claude.json index 9aec23a4c..f5b1c013a 100644 --- a/engines/claude.json +++ b/engines/claude.json @@ -53,6 +53,8 @@ "companion": { "protocol": "stream-json", "serverCmd": ["--print", "--verbose", "--input-format", "stream-json", "--output-format", "stream-json", "--max-turns", "1"], + "systemPromptFlag": "--system-prompt", + "textOnlyArgs": ["--tools", ""], "features": { "threadResume": false, "nativeReview": false, diff --git a/packages/adapter-cli/src/generated/adapter-helpers.ts b/packages/adapter-cli/src/generated/adapter-helpers.ts index 07c4c0be0..503d4718d 100644 --- a/packages/adapter-cli/src/generated/adapter-helpers.ts +++ b/packages/adapter-cli/src/generated/adapter-helpers.ts @@ -1,6 +1,6 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/adapter-helpers.kern -// @kern-source: adapter-helpers:520 +// @kern-source: adapter-helpers:525 import type { EngineDefinition, EngineMode, EngineModeConfig, ImageAttachment, EngineIsolationPlan } from '@kernlang/agon-core'; @@ -324,12 +324,13 @@ export function resolveAgentArgs(engine: EngineDefinition, permissionLevel: 'ful } /** - * Extract text content from Claude stream-json NDJSON output. Returns plain text with system/hook messages removed. + * Extract text content from Claude stream-json NDJSON output. The final result event is the authoritative answer when present — concatenating it with the assistant text blocks duplicates the answer, and in multi-turn tool runs the early assistant texts are tool preambles ('I'll quickly verify...'), not the answer. Assistant texts remain the fallback for streams the result never closed (timeout kill, max-tokens is_error). */ // @kern-source: adapter-helpers:297 export function stripStreamJson(stdout: string): string { const lines = stdout.split('\n'); - const textParts: string[] = []; + const assistantParts: string[] = []; + let resultText = ''; for (const line of lines) { const trimmed = line.trim(); @@ -339,27 +340,31 @@ export function stripStreamJson(stdout: string): string { // assistant message → extract text blocks if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === 'text' && block.text) textParts.push(block.text); + if (block.type === 'text' && block.text) assistantParts.push(block.text); } } - // result → extract result text + // result → the final answer (last one wins if several appear). Only a + // string result is authoritative; non-string payloads (objects) keep + // the old stringify-into-parts behavior instead of masking the + // assistant text with '{}'. else if (msg.type === 'result' && msg.result && !msg.is_error) { - textParts.push(typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result)); + if (typeof msg.result === 'string') resultText = msg.result; + else assistantParts.push(JSON.stringify(msg.result)); } // system, hook, etc. → skip } catch { // Not JSON — raw text from non-Claude engines, keep as-is - textParts.push(trimmed); + assistantParts.push(trimmed); } } - return textParts.join('\n'); + return resultText.trim() ? resultText : assistantParts.join('\n'); } /** * Check if engine exec mode outputs stream-json NDJSON. */ -// @kern-source: adapter-helpers:328 +// @kern-source: adapter-helpers:333 export function usesStreamJson(engine: EngineDefinition): boolean { const args = engine.exec?.args ?? []; return args.includes('stream-json') || args.some((a: string) => a === '--output-format' && args[args.indexOf(a) + 1] === 'stream-json'); @@ -368,12 +373,12 @@ export function usesStreamJson(engine: EngineDefinition): boolean { /** * Only Codex-style JSON-RPC app-server is reliable for one-shot forge agent dispatch. Claude stream-json and Gemini ACP are reliable as persistent Cesar sessions, but the one-shot companion wrapper can hang or return empty, so forge should use their direct non-interactive CLI agent commands. */ -// @kern-source: adapter-helpers:334 +// @kern-source: adapter-helpers:339 export function shouldUseCompanionForAgent(engine: EngineDefinition): boolean { return engine.companion?.protocol === 'jsonrpc'; } -// @kern-source: adapter-helpers:339 +// @kern-source: adapter-helpers:344 export function checkEnvVars(engine: EngineDefinition): string|null { if (!engine.env) return null; for (const [envVar, config] of Object.entries(engine.env)) { @@ -387,7 +392,7 @@ export function checkEnvVars(engine: EngineDefinition): string|null { /** * Drive `claude` via the kern-engines PTY subscription TUI? DEFAULT is now NO — claude dispatches via `claude --print` (-p), which Anthropic no longer meters under the subscription and is far more reliable than scraping the TUI. Opt back INTO the PTY brain with AGON_CLAUDE_PTY=1 / `agon config claudeBackend pty` (single source of truth: claudeBrainUsesPty). Claude is identified by id OR binary === 'claude' so this agrees with the persistent-session and cesar/session gates. cwd (when known) lets a project-local config override apply. */ -// @kern-source: adapter-helpers:350 +// @kern-source: adapter-helpers:355 export function shouldUseClaudePty(engine: EngineDefinition, cwd?: string): boolean { if (engine.id !== 'claude' && engine.binary !== 'claude') { return false; @@ -398,7 +403,7 @@ export function shouldUseClaudePty(engine: EngineDefinition, cwd?: string): bool /** * When a caller passed a systemPrompt, prepend it to the prompt with the same [System Instructions]/[User Message] framing the legacy non-flag CLI path uses. Claude's TUI has no --system-prompt equivalent we can apply through stdin, so this is the honest way to keep forge/tribunal/brainstorm system instructions intact on the pty path. */ -// @kern-source: adapter-helpers:357 +// @kern-source: adapter-helpers:362 export function composeClaudePtyPrompt(prompt: string, systemPrompt?: string): string { if (!systemPrompt) return prompt; return `[System Instructions]\n${systemPrompt}\n\n[User Message]\n${prompt}`; @@ -407,7 +412,7 @@ export function composeClaudePtyPrompt(prompt: string, systemPrompt?: string): s /** * Structured answer-channel transport for ONE-SHOT claude exec dispatch (council/forge/brainstorm/tribunal/synthesis members), bypassing the flaky TUI scrape. ON by default ('file' — claude writes its answer via its native Write tool to a temp file we read as authoritative). AGON_CLAUDE_ANSWER_CHANNEL: 'off'/'0'/'false' → off (raw scrape, for debugging); 'mcp' → the DeliverAnswer MCP variant (reserved); anything else (incl. unset) → 'file'. Never worse than the scrape — we fall back to it when claude doesn't deliver. */ -// @kern-source: adapter-helpers:364 +// @kern-source: adapter-helpers:369 export function answerChannelMode(): string { const v = (process.env.AGON_CLAUDE_ANSWER_CHANNEL || '').trim().toLowerCase(); if (v === 'off' || v === '0' || v === 'false') return 'off'; @@ -418,7 +423,7 @@ export function answerChannelMode(): string { /** * Whether the file answer-channel applies for a given one-shot dispatch mode. TRUE for both exec (council/forge/brainstorm/tribunal/synthesis members) AND agent (forge one-shot agent dispatch) — both lose long-form output to TUI scrape corruption and benefit from the authoritative file. Gated by answerChannelMode()==='file'. Defaults mode to 'exec'. */ -// @kern-source: adapter-helpers:373 +// @kern-source: adapter-helpers:378 export function useFileChannelForMode(mode?: 'exec'|'agent'): boolean { const m = mode ?? 'exec'; return (m === 'exec' || m === 'agent') && answerChannelMode() === 'file'; @@ -427,7 +432,7 @@ export function useFileChannelForMode(mode?: 'exec'|'agent'): boolean { /** * Appended to a one-shot claude prompt under file answer-channel mode: instruct claude to deliver its COMPLETE answer by writing it (markdown only, no commentary) to answerFile with its native Write tool. This is what makes the answer arrive as a clean file instead of a scraped TUI frame. */ -// @kern-source: adapter-helpers:380 +// @kern-source: adapter-helpers:385 export function fileChannelInstruction(answerFile: string): string { return `\n\n---\n[ANSWER DELIVERY — REQUIRED] After composing your COMPLETE final answer, use your Write tool to write that answer — markdown only, no preamble, no commentary, nothing but the answer itself — to this exact file path:\n${answerFile}\nThat file is the ONLY channel by which your answer is captured. Write it exactly once, at the very end. Do NOT skip it.`; } @@ -435,7 +440,7 @@ export function fileChannelInstruction(answerFile: string): string { /** * Read the authoritative answer a one-shot claude wrote to the channel file. Returns '' when the file is absent/empty (caller falls back to the scrape). Accepts raw markdown (file mode, native Write) OR a {text} JSON envelope (mcp mode, DeliverAnswer) transparently. */ -// @kern-source: adapter-helpers:386 +// @kern-source: adapter-helpers:391 export function readAnswerChannelFile(answerFile: string): string { try { if (!existsSync(answerFile)) return ''; @@ -455,7 +460,7 @@ export function readAnswerChannelFile(answerFile: string): string { /** * Create a fresh temp dir + answer file for file-mode delivery and append the delivery instruction to the composed prompt. Returns the augmented prompt, the answer file path to read after the turn, and the dir to remove afterwards. */ -// @kern-source: adapter-helpers:404 +// @kern-source: adapter-helpers:409 export function setupFileAnswerChannel(composed: string): { prompt:string, answerFile:string, dir:string } { const dir = mkdtempSync(join(tmpdir(), 'agon-ac-')); const answerFile = join(dir, 'answer.md'); @@ -465,7 +470,7 @@ export function setupFileAnswerChannel(composed: string): { prompt:string, answe /** * Drive interactive `claude` under a pty so the subscription billing path is used. Lazy-imports @kernlang/agon-engines — runs a python3 daemon (kern_engines.cli.daemon) over stdio JSON-RPC, no native node deps. cwd is plumbed through so worktree dispatches land in the right repo; systemPrompt is prepended to the prompt; extraArgv (model/effort launch flags) is forwarded to the engine exec. When AGON_CLAUDE_ANSWER_CHANNEL=file and mode is exec OR agent, claude is asked to Write its answer to a temp file we read as the AUTHORITATIVE result (clean — no TUI scrape), falling back to the scrape if the file is absent. Never throws — returns unavailable:true on any unexpected failure so kern callers can fall through to legacy paths with a simple !result.unavailable check. */ -// @kern-source: adapter-helpers:412 +// @kern-source: adapter-helpers:417 export async function runClaudePtyDispatch(prompt: string, timeoutSec: number, signal?: AbortSignal, mode?: 'exec'|'agent', cwd?: string, systemPrompt?: string, env?: Record, extraArgv?: string[]): Promise<{exitCode:number,stdout:string,stderr:string,durationMs:number,timedOut:boolean,unavailable?:boolean}> { const start = Date.now(); try { diff --git a/packages/adapter-cli/src/generated/adapter.ts b/packages/adapter-cli/src/generated/adapter.ts index 9a53f5234..1e9943c9b 100644 --- a/packages/adapter-cli/src/generated/adapter.ts +++ b/packages/adapter-cli/src/generated/adapter.ts @@ -90,7 +90,7 @@ export class CliAdapter implements EngineAdapter { } // Try companion protocol (JSONRPC app-server) first — faster, more stable if (options.engine.companion) { - const companionResult = await companionDispatch({ config: options.engine.companion, binaryPath: binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: (options.mode === 'agent') ? 'agent' : ((options.mode === 'review') ? 'review' : 'exec'), model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, env: iso.env }); + const companionResult = await companionDispatch({ config: options.engine.companion, binaryPath: binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: (options.mode === 'agent') ? 'agent' : ((options.mode === 'review') ? 'review' : 'exec'), model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, textOnly: options.textOnly, env: iso.env }); // Exit code 2 = companion not available, fall through to CLI spawn // Also fall through if companion returned empty output (stream-json capture failure) if (companionResult.exitCode !== 2 && companionResult.stdout.trim()) { diff --git a/packages/adapter-cli/src/kern/adapter-helpers.kern b/packages/adapter-cli/src/kern/adapter-helpers.kern index 21f5d6c1e..bf4c14c8c 100644 --- a/packages/adapter-cli/src/kern/adapter-helpers.kern +++ b/packages/adapter-cli/src/kern/adapter-helpers.kern @@ -295,10 +295,11 @@ fn name=resolveAgentArgs params="engine:EngineDefinition, permissionLevel:'full' >>> fn name=stripStreamJson params="stdout:string" returns=string - doc "Extract text content from Claude stream-json NDJSON output. Returns plain text with system/hook messages removed." + doc "Extract text content from Claude stream-json NDJSON output. The final result event is the authoritative answer when present — concatenating it with the assistant text blocks duplicates the answer, and in multi-turn tool runs the early assistant texts are tool preambles ('I'll quickly verify...'), not the answer. Assistant texts remain the fallback for streams the result never closed (timeout kill, max-tokens is_error)." handler <<< const lines = stdout.split('\n'); - const textParts: string[] = []; + const assistantParts: string[] = []; + let resultText = ''; for (const line of lines) { const trimmed = line.trim(); @@ -308,21 +309,25 @@ fn name=stripStreamJson params="stdout:string" returns=string // assistant message → extract text blocks if (msg.type === 'assistant' && msg.message?.content) { for (const block of msg.message.content) { - if (block.type === 'text' && block.text) textParts.push(block.text); + if (block.type === 'text' && block.text) assistantParts.push(block.text); } } - // result → extract result text + // result → the final answer (last one wins if several appear). Only a + // string result is authoritative; non-string payloads (objects) keep + // the old stringify-into-parts behavior instead of masking the + // assistant text with '{}'. else if (msg.type === 'result' && msg.result && !msg.is_error) { - textParts.push(typeof msg.result === 'string' ? msg.result : JSON.stringify(msg.result)); + if (typeof msg.result === 'string') resultText = msg.result; + else assistantParts.push(JSON.stringify(msg.result)); } // system, hook, etc. → skip } catch { // Not JSON — raw text from non-Claude engines, keep as-is - textParts.push(trimmed); + assistantParts.push(trimmed); } } - return textParts.join('\n'); + return resultText.trim() ? resultText : assistantParts.join('\n'); >>> fn name=usesStreamJson params="engine:EngineDefinition" returns=boolean diff --git a/packages/adapter-cli/src/kern/adapter.kern b/packages/adapter-cli/src/kern/adapter.kern index eb4f2fc9e..e7923ce27 100644 --- a/packages/adapter-cli/src/kern/adapter.kern +++ b/packages/adapter-cli/src/kern/adapter.kern @@ -75,7 +75,7 @@ service name=CliAdapter implements=EngineAdapter return value="ptyResult" comment raw="// Try companion protocol (JSONRPC app-server) first — faster, more stable" if cond="options.engine.companion" - let name=companionResult value="await companionDispatch({ config: options.engine.companion, binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: options.mode === 'agent' ? 'agent' : options.mode === 'review' ? 'review' : 'exec', model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, env: iso.env, })" + let name=companionResult value="await companionDispatch({ config: options.engine.companion, binaryPath, prompt: options.prompt, cwd: options.cwd, timeout: options.timeout, mode: options.mode === 'agent' ? 'agent' : options.mode === 'review' ? 'review' : 'exec', model: resolveModel(options.engine, options.cwd) ?? undefined, signal: options.signal, systemPrompt: options.systemPrompt, textOnly: options.textOnly, env: iso.env, })" comment raw="// Exit code 2 = companion not available, fall through to CLI spawn" comment raw="// Also fall through if companion returned empty output (stream-json capture failure)" if cond="companionResult.exitCode !== 2 && companionResult.stdout.trim()" diff --git a/packages/cli/src/commands/brainstorm.ts b/packages/cli/src/commands/brainstorm.ts index 13dd48a46..39fba1289 100644 --- a/packages/cli/src/commands/brainstorm.ts +++ b/packages/cli/src/commands/brainstorm.ts @@ -32,6 +32,11 @@ export const brainstormCommand = defineCommand({ description: 'Timeout in seconds', default: '120', }, + style: { + type: 'string', + description: "'divergent' (default: seats get distinct stances, synthesis keeps 2-3 directions) or 'grounded' (convergent, file-path-anchored single answer)", + default: 'divergent', + }, label: { type: 'string', description: 'Human-readable suffix baked into the run dir name (orchestrators: distinguish parallel runs without grep).', @@ -53,6 +58,10 @@ export const brainstormCommand = defineCommand({ ? args.engines.split(',').map((s) => s.trim()) : filterDefaultOrchestrationEngines(registry.activeIds(config)); + if (args.style !== 'divergent' && args.style !== 'grounded') { + throw new Error(`Unknown --style "${args.style}" — use 'divergent' or 'grounded'.`); + } + if (args.quiet) process.env.AGON_QUIET = '1'; const startedAt = new Date().toISOString(); const { path: outputDir } = createRunDir({ @@ -64,6 +73,7 @@ export const brainstormCommand = defineCommand({ if (!quiet) { header(`Brainstorm: ${args.question}`); info(`Engines: ${available.join(', ')}`); + info(`Style: ${args.style}`); } const seatState = new Map(); @@ -72,6 +82,7 @@ export const brainstormCommand = defineCommand({ result = await runBrainstorm({ question: args.question, engines: available, + style: args.style, registry, adapter, timeout: parseInt(args.timeout, 10), diff --git a/packages/core/src/generated/models/types.ts b/packages/core/src/generated/models/types.ts index 439a0f941..41d3e44db 100644 --- a/packages/core/src/generated/models/types.ts +++ b/packages/core/src/generated/models/types.ts @@ -1,9 +1,5 @@ // @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/models/types.kern -// @kern-source: types:531 -// @kern-source: types:532 -// @kern-source: types:533 -// @kern-source: types:534 // @kern-source: types:535 // @kern-source: types:536 // @kern-source: types:537 @@ -37,6 +33,10 @@ // @kern-source: types:565 // @kern-source: types:566 // @kern-source: types:567 +// @kern-source: types:568 +// @kern-source: types:569 +// @kern-source: types:570 +// @kern-source: types:571 // @kern-source: types:1 export type EngineMode = 'exec' | 'review' | 'agent'; @@ -87,16 +87,18 @@ export interface CompanionConfig { serverCmd: string[]; sandbox?: 'read-only'|'workspace-write'|'danger-full-access'; cwdArg?: string; + systemPromptFlag?: string; + textOnlyArgs?: string[]; features?: {threadResume?:boolean, nativeReview?:boolean, structuredOutput?:boolean}; } -// @kern-source: types:36 +// @kern-source: types:38 export interface CliModelEntry { id: string; name?: string; } -// @kern-source: types:40 +// @kern-source: types:42 export interface EngineCliModelConfig { default?: string; list?: CliModelEntry[]; @@ -106,7 +108,7 @@ export interface EngineCliModelConfig { /** * Proactive context-window management metadata for an engine's Cesar brain session. Engines WITHOUT this object get NO budget behavior — the pre-turn gate is inert (zero regression risk). Thresholds are fractions of the EFFECTIVE window (contextWindow minus reserveTokens). See token-estimator.kern / session-budget.kern. */ -// @kern-source: types:45 +// @kern-source: types:47 export interface SessionBudget { contextWindow: number; reserveTokens?: number; @@ -117,7 +119,7 @@ export interface SessionBudget { charsPerToken?: number; } -// @kern-source: types:62 +// @kern-source: types:64 export interface EngineDefinition { schemaVersion: 1|2|3; id: string; @@ -152,7 +154,7 @@ export interface EngineDefinition { isolationHints?: {configEnv?:string, strictMcpArgs?:string[], personalPaths?:string[], authFiles?:string[], authMarker?:string, setupHint?:string, loginArgs?:string[], supportsProjectMcp?:boolean}; } -// @kern-source: types:100 +// @kern-source: types:102 export interface DispatchOptions { engine: EngineDefinition; prompt: string; @@ -164,6 +166,7 @@ export interface DispatchOptions { signal?: AbortSignal; images?: ImageAttachment[]; systemPrompt?: string; + textOnly?: boolean; tools?: Array<{type:string,function:{name:string,description:string,parameters:Record}}>; messages?: Array<{role:string,content:any,tool_calls?:any[],tool_call_id?:string}>; onApproval?: (tool:string, command:string, reason?:string) => Promise; @@ -178,7 +181,7 @@ export interface DispatchOptions { /** * Structured parts captured at stream time. Enables compaction to fold over typed data instead of parsing strings. */ -// @kern-source: types:126 +// @kern-source: types:130 export interface DispatchResult { exitCode: number; stdout: string; @@ -192,14 +195,14 @@ export interface DispatchResult { finishReason?: string; } -// @kern-source: types:142 +// @kern-source: types:146 export interface AgentDispatchResult extends DispatchResult { diff: string; diffLines: number; filesChanged: number; } -// @kern-source: types:147 +// @kern-source: types:151 export interface EngineAdapter { dispatch: (options:DispatchOptions)=>Promise; dispatchStream?: (options:DispatchOptions)=>AsyncGenerator; @@ -209,7 +212,7 @@ export interface EngineAdapter { getVersion: (engine:EngineDefinition)=>Promise; } -// @kern-source: types:155 +// @kern-source: types:159 export interface FitnessResult { pass: boolean; diffLines: number; @@ -222,7 +225,7 @@ export interface FitnessResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:166 +// @kern-source: types:170 export interface ScoreWeights { pass: number; quality: number; @@ -231,7 +234,7 @@ export interface ScoreWeights { duration: number; } -// @kern-source: types:173 +// @kern-source: types:177 export interface ScoreComponents { passScore: number; qualityScore: number; @@ -241,7 +244,7 @@ export interface ScoreComponents { composite: number; } -// @kern-source: types:181 +// @kern-source: types:185 export interface GlickoRating { mu: number; phi: number; @@ -251,7 +254,7 @@ export interface GlickoRating { lastActive: string; } -// @kern-source: types:189 +// @kern-source: types:193 export interface EngineMeta { firstSeen: string; lastActive: string; @@ -260,7 +263,7 @@ export interface EngineMeta { versions: string[]; } -// @kern-source: types:196 +// @kern-source: types:200 export interface RatingRecord { global: Record; byMode: {forge:Record,brainstorm:Record,tribunal:Record,critique:Record}; @@ -269,7 +272,7 @@ export interface RatingRecord { lastUpdated: string; } -// @kern-source: types:203 +// @kern-source: types:207 export interface AgonConfig { debug?: boolean; timeout?: number; @@ -508,7 +511,7 @@ export const DEFAULT_AGON_CONFIG: Required = { browserExtensionIds: [], }; -// @kern-source: types:397 +// @kern-source: types:401 export interface ScoutBid { engineId: string; confidence: number; @@ -519,7 +522,7 @@ export interface ScoutBid { needsCompetition: boolean; } -// @kern-source: types:406 +// @kern-source: types:410 export interface RoutingDecision { action: 'chat'|'build'|'pipeline'|'campfire'|'forge'|'brainstorm'|'tribunal'|'agent'|'team-agent'; leadEngine: string; @@ -531,14 +534,14 @@ export interface RoutingDecision { bids: ScoutBid[]; } -// @kern-source: types:416 +// @kern-source: types:420 export interface CampfireMessage { engineId: string; content: string; isLead: boolean; } -// @kern-source: types:421 +// @kern-source: types:425 export interface ForgeOptions { task: string; fitnessCmd: string; @@ -564,7 +567,7 @@ export interface ForgeOptions { synthEngine?: string; } -// @kern-source: types:450 +// @kern-source: types:454 export interface EngineResult { engineId: string; pass: boolean; @@ -586,14 +589,14 @@ export interface EngineResult { syntaxInvalidFiles?: string[]; } -// @kern-source: types:470 +// @kern-source: types:474 export interface SkippedEngine { engineId: string; status: string; reason?: string; } -// @kern-source: types:475 +// @kern-source: types:479 export interface DispatchMetric { engineId: string; phase: 'stage1'|'stage1-fallback'|'stage2-scout'|'stage2-scout-fallback'|'stage2-follower'|'stage2-fallback'|'synthesis'|'gauntlet'; @@ -607,7 +610,7 @@ export interface DispatchMetric { tokens?: {prompt:number, response:number, costUsd:number}; } -// @kern-source: types:487 +// @kern-source: types:491 export interface ForgeManifest { forgeId: string; forgeDir: string; @@ -639,7 +642,7 @@ export interface ForgeManifest { gauntlet?: GauntletResult; } -// @kern-source: types:517 +// @kern-source: types:521 export interface ConvergenceEntry { file: string; fn: string; @@ -647,7 +650,7 @@ export interface ConvergenceEntry { reason: string; } -// @kern-source: types:523 +// @kern-source: types:527 export interface ForgeJudgment { winner: string; strengths: { engineId: string; category: string; reason: string }[]; @@ -658,7 +661,7 @@ export interface ForgeJudgment { export type ForgeEventType = 'baseline:start' | 'baseline:done' | 'stage1:start' | 'stage1:dispatch' | 'stage1:score' | 'stage1:accepted' | 'stage2:start' | 'stage2:dispatch' | 'stage2:score' | 'stage2:done' | 'engine:failed' | 'engine:worktree' | 'winner:determined' | 'forge:no-candidate-diff' | 'synthesis:start' | 'synthesis:critique' | 'synthesis:refine' | 'synthesis:score' | 'synthesis:done' | 'elo:update' | 'gauntlet:start' | 'gauntlet:breaker-dispatch' | 'gauntlet:breaker-done' | 'gauntlet:attack-landed' | 'gauntlet:repair-start' | 'gauntlet:repair-done' | 'gauntlet:corpus-save' | 'gauntlet:done' | 'forge:done' | 'forge:engine-skipped' | 'forge:already-satisfied' | 'forge:single-survivor' | 'forge:no-engines-available' | 'forge:fatal' | 'forge:auto-finalize' | 'forge:health-check-start' | 'forge:health-check-done'; -// @kern-source: types:530 +// @kern-source: types:534 export interface ForgeEvent { type: ForgeEventType; engineId?: string; @@ -707,7 +710,7 @@ export interface ForgeEventMap { export type ForgeEventCallback = (event: ForgeEvent) => void; -// @kern-source: types:569 +// @kern-source: types:573 export interface BrainstormBid { engineId: string; confidence: number; @@ -716,26 +719,26 @@ export interface BrainstormBid { score?: number; } -// @kern-source: types:576 +// @kern-source: types:580 export interface BrainstormGroup { members: string[]; representative: string; similarity: number; } -// @kern-source: types:581 +// @kern-source: types:585 export interface BrainstormDedupStatus { status: 'not-needed' | 'applied' | 'unavailable' | 'failed' | 'timed-out'; detail?: string; } -// @kern-source: types:585 +// @kern-source: types:589 export interface BrainstormSynthesisStatus { status: 'completed' | 'fallback'; detail?: string; } -// @kern-source: types:592 +// @kern-source: types:596 export interface PanelHealth { requested: number; responded: number; @@ -744,7 +747,7 @@ export interface PanelHealth { banner: string | null; } -// @kern-source: types:599 +// @kern-source: types:603 export interface BrainstormResult { question: string; bids: BrainstormBid[]; @@ -756,7 +759,7 @@ export interface BrainstormResult { panelHealth?: PanelHealth; } -// @kern-source: types:609 +// @kern-source: types:613 export interface BreakerArtifact { engineId: string; testScript: string; @@ -766,7 +769,7 @@ export interface BreakerArtifact { validated: boolean; } -// @kern-source: types:617 +// @kern-source: types:621 export interface GauntletResult { winnerId: string; breakerArtifacts: BreakerArtifact[]; @@ -779,7 +782,7 @@ export interface GauntletResult { patchPath?: string; } -// @kern-source: types:628 +// @kern-source: types:632 export interface CorpusEntry { forgeId: string; taskClass: TaskClass; @@ -789,7 +792,7 @@ export interface CorpusEntry { pattern?: string; } -// @kern-source: types:636 +// @kern-source: types:640 export interface GapPattern { pattern: string; taskClass: TaskClass; @@ -800,7 +803,7 @@ export interface GapPattern { skillPath?: string; } -// @kern-source: types:645 +// @kern-source: types:649 export interface Critique { file: string; lines: string; @@ -808,5 +811,5 @@ export interface Critique { minimalFix: string; } -// @kern-source: types:651 +// @kern-source: types:655 export const DEFAULT_CONFIG: Required = DEFAULT_AGON_CONFIG; diff --git a/packages/core/src/generated/sessions/companion-dispatch.ts b/packages/core/src/generated/sessions/companion-dispatch.ts index 0553a6ef1..db4b40040 100644 --- a/packages/core/src/generated/sessions/companion-dispatch.ts +++ b/packages/core/src/generated/sessions/companion-dispatch.ts @@ -25,7 +25,7 @@ export interface CompanionResult { } // @kern-source: companion-dispatch:19 -export async function companionDispatch(opts: {config:CompanionConfig, binaryPath:string, prompt:string, cwd:string, timeout:number, mode:'exec'|'review'|'agent', model?:string, signal?:AbortSignal, systemPrompt?:string, env?:Record, onApproval?:(tool:string, command:string, reason?:string)=>Promise}): Promise { +export async function companionDispatch(opts: {config:CompanionConfig, binaryPath:string, prompt:string, cwd:string, timeout:number, mode:'exec'|'review'|'agent', model?:string, signal?:AbortSignal, systemPrompt?:string, textOnly?:boolean, env?:Record, onApproval?:(tool:string, command:string, reason?:string)=>Promise}): Promise { if (opts.config.protocol !== 'jsonrpc' && opts.config.protocol !== 'acp' && opts.config.protocol !== 'stream-json') { return { exitCode: 2, stdout: '', stderr: `Protocol "${opts.config.protocol}" not supported for one-shot dispatch`, durationMs: 0, timedOut: false }; } @@ -61,7 +61,17 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat // honor it (no cwdArg). const serverArgs = opts.config.cwdArg ? [...opts.config.serverCmd, opts.config.cwdArg, opts.cwd] - : opts.config.serverCmd; + : [...opts.config.serverCmd]; + // stream-json has no in-band system-prompt channel — without the flag the + // caller's systemPrompt (seat stances, "do NOT use tools") is silently dropped. + // ACP/JSONRPC forward the systemPrompt in-band (session/new, thread/start), + // so the argv flag applies ONLY to stream-json — never double-apply. + if (opts.config.protocol === 'stream-json' && opts.systemPrompt && opts.config.systemPromptFlag) { + serverArgs.push(opts.config.systemPromptFlag, opts.systemPrompt); + } + if (opts.textOnly && opts.config.textOnlyArgs?.length) { + serverArgs.push(...opts.config.textOnlyArgs); + } const proc = spawn(opts.binaryPath, serverArgs, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, @@ -84,6 +94,7 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat let lastAcpPushWasChunk = false; let turnCompleted: Record | null = null; let turnError: Record | null = null; + let sawToolUse = false; let threadId: string | null = null; let stdinError: Error | null = null; @@ -234,6 +245,7 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat if (raw.type === 'assistant' && raw.message?.content) { for (const block of raw.message.content) { if (block.type === 'text' && block.text) agentMessages.push(block.text); + if (block.type === 'tool_use') sawToolUse = true; } if (raw.message.stop_reason) { turnCompleted = raw; } } @@ -425,6 +437,26 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat } const text = agentMessages.join('\n\n'); + + // stream-json runs --max-turns 1: a tool call ends the ONLY turn, so + // whatever text preceded it is a preamble ("I'll quickly verify..."), not + // the answer. Return empty stdout so the adapter's existing empty-output + // fall-through retries via the plain CLI spawn (exec: --max-turns 10, + // review: --max-turns 50), which can finish the tool loop. Applies to + // exec AND review — review turns inherently want tools, so via companion + // they hit this truncation on nearly every dispatch (review consensus: + // codex/zai/kimi/minimax). Agent mode never routes stream-json companions + // (shouldUseCompanionForAgent). Preamble rides in stderr for diagnosability. + if (isStreamJson && (opts.mode === 'exec' || opts.mode === 'review') && sawToolUse) { + return { + exitCode: 0, + stdout: '', + stderr: `companion turn ended on tool_use (max-turns 1); falling back to CLI spawn. Preamble: ${text.slice(0, 300)}`, + durationMs: Date.now() - startTime, + timedOut: false, + }; + } + return { exitCode: 0, stdout: text, diff --git a/packages/core/src/kern/models/types.kern b/packages/core/src/kern/models/types.kern index 162710962..a6a42ce53 100644 --- a/packages/core/src/kern/models/types.kern +++ b/packages/core/src/kern/models/types.kern @@ -31,6 +31,8 @@ interface name=CompanionConfig field name=serverCmd type="string[]" field name=sandbox type="'read-only'|'workspace-write'|'danger-full-access'" optional=true field name=cwdArg type=string optional=true doc="Flag the companion server uses to set its working directory (e.g. opencode's '--cwd'). When set, companionDispatch appends [cwdArg, opts.cwd] to serverCmd so the server is PINNED to the worktree. Required for servers (opencode) that ignore the spawn cwd / attach to a shared server in the launch repo — relying on the spawn cwd alone leaked writes into the parent repo." + field name=systemPromptFlag type=string optional=true doc="Flag the companion server accepts for a system prompt (e.g. claude's '--system-prompt'). When set, companionDispatch appends [systemPromptFlag, opts.systemPrompt] to serverCmd. Without it the stream-json protocol has no in-band system-prompt channel and the caller's systemPrompt is silently DROPPED — which is how brainstorm seat instructions never reached claude." + field name=textOnlyArgs type="string[]" optional=true doc="Args that disable the server's tools for a plain-text answer (e.g. claude's ['--tools','']). Appended to serverCmd only when the dispatch sets textOnly. Needed because stream-json companions run --max-turns 1: a tool call ends the only turn, so the caller gets the pre-tool preamble instead of an answer." field name=features type="{threadResume?:boolean, nativeReview?:boolean, structuredOutput?:boolean}" optional=true interface name=CliModelEntry @@ -109,6 +111,8 @@ interface name=DispatchOptions field name=signal type=AbortSignal optional=true field name=images type="ImageAttachment[]" optional=true field name=systemPrompt type=string optional=true + field name=textOnly type=boolean optional=true + doc "The caller wants a plain-text answer and the engine must not use tools (brainstorm seats/synthesis, tribunal, council — dispatches whose system prompts already say 'do NOT use tools'). On the companion path this appends the engine's CompanionConfig.textOnlyArgs (e.g. claude --tools '') so a --max-turns 1 turn is always the complete answer instead of a tool-use preamble. Never set it for dispatches that may legitimately need tools (agon ask, room work)." field name=tools type="Array<{type:string,function:{name:string,description:string,parameters:Record}}>" optional=true doc "Native function-calling tool specs (OpenAI-function shape). When present AND the dispatch resolves to the API path (binary-less engine + usable key), the adapter sends them to the model's native tool API and returns any structured call in DispatchResult.parts. IGNORED on the CLI/companion paths — those engines use the in-prompt text-marker protocol. Lets the browser brain's ReAct loop get reliable native tool calls from API-only coding-plan engines that otherwise forget to emit the text marker (narrate-and-stop)." field name=messages type="Array<{role:string,content:any,tool_calls?:any[],tool_call_id?:string}>" optional=true diff --git a/packages/core/src/kern/sessions/companion-dispatch.kern b/packages/core/src/kern/sessions/companion-dispatch.kern index 49913ce14..c94ee2235 100644 --- a/packages/core/src/kern/sessions/companion-dispatch.kern +++ b/packages/core/src/kern/sessions/companion-dispatch.kern @@ -16,7 +16,7 @@ interface name=CompanionResult field name=fileChanges type="Array<{changes:unknown,status:string}>" field name=commands type="Array<{command:string,exitCode:number,output:string}>" -fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binaryPath:string, prompt:string, cwd:string, timeout:number, mode:'exec'|'review'|'agent', model?:string, signal?:AbortSignal, systemPrompt?:string, env?:Record, onApproval?:(tool:string, command:string, reason?:string)=>Promise}" returns="Promise" +fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binaryPath:string, prompt:string, cwd:string, timeout:number, mode:'exec'|'review'|'agent', model?:string, signal?:AbortSignal, systemPrompt?:string, textOnly?:boolean, env?:Record, onApproval?:(tool:string, command:string, reason?:string)=>Promise}" returns="Promise" handler <<< if (opts.config.protocol !== 'jsonrpc' && opts.config.protocol !== 'acp' && opts.config.protocol !== 'stream-json') { return { exitCode: 2, stdout: '', stderr: `Protocol "${opts.config.protocol}" not supported for one-shot dispatch`, durationMs: 0, timedOut: false }; @@ -53,7 +53,17 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar // honor it (no cwdArg). const serverArgs = opts.config.cwdArg ? [...opts.config.serverCmd, opts.config.cwdArg, opts.cwd] - : opts.config.serverCmd; + : [...opts.config.serverCmd]; + // stream-json has no in-band system-prompt channel — without the flag the + // caller's systemPrompt (seat stances, "do NOT use tools") is silently dropped. + // ACP/JSONRPC forward the systemPrompt in-band (session/new, thread/start), + // so the argv flag applies ONLY to stream-json — never double-apply. + if (opts.config.protocol === 'stream-json' && opts.systemPrompt && opts.config.systemPromptFlag) { + serverArgs.push(opts.config.systemPromptFlag, opts.systemPrompt); + } + if (opts.textOnly && opts.config.textOnlyArgs?.length) { + serverArgs.push(...opts.config.textOnlyArgs); + } const proc = spawn(opts.binaryPath, serverArgs, { stdio: ['pipe', 'pipe', 'pipe'], cwd: opts.cwd, @@ -76,6 +86,7 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar let lastAcpPushWasChunk = false; let turnCompleted: Record | null = null; let turnError: Record | null = null; + let sawToolUse = false; let threadId: string | null = null; let stdinError: Error | null = null; @@ -226,6 +237,7 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar if (raw.type === 'assistant' && raw.message?.content) { for (const block of raw.message.content) { if (block.type === 'text' && block.text) agentMessages.push(block.text); + if (block.type === 'tool_use') sawToolUse = true; } if (raw.message.stop_reason) { turnCompleted = raw; } } @@ -417,6 +429,26 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar } const text = agentMessages.join('\n\n'); + + // stream-json runs --max-turns 1: a tool call ends the ONLY turn, so + // whatever text preceded it is a preamble ("I'll quickly verify..."), not + // the answer. Return empty stdout so the adapter's existing empty-output + // fall-through retries via the plain CLI spawn (exec: --max-turns 10, + // review: --max-turns 50), which can finish the tool loop. Applies to + // exec AND review — review turns inherently want tools, so via companion + // they hit this truncation on nearly every dispatch (review consensus: + // codex/zai/kimi/minimax). Agent mode never routes stream-json companions + // (shouldUseCompanionForAgent). Preamble rides in stderr for diagnosability. + if (isStreamJson && (opts.mode === 'exec' || opts.mode === 'review') && sawToolUse) { + return { + exitCode: 0, + stdout: '', + stderr: `companion turn ended on tool_use (max-turns 1); falling back to CLI spawn. Preamble: ${text.slice(0, 300)}`, + durationMs: Date.now() - startTime, + timedOut: false, + }; + } + return { exitCode: 0, stdout: text, diff --git a/packages/core/src/schemas/engine-schema.test.ts b/packages/core/src/schemas/engine-schema.test.ts index 60071ebb0..51fb6155f 100644 --- a/packages/core/src/schemas/engine-schema.test.ts +++ b/packages/core/src/schemas/engine-schema.test.ts @@ -40,6 +40,28 @@ const completeEngine = { }, } as const; +describe('CompanionConfigSchema dispatch fields', () => { + // Regression: Zod strips unknown keys on parse. When these fields were added + // to CompanionConfig without the schema, registry-loaded claude.json silently + // lost them and the system-prompt/tool-disable behavior was inactive at + // runtime (review blocker, codex). + it('preserves systemPromptFlag and textOnlyArgs through validation', () => { + const result = validateEngineConfig({ + ...completeEngine, + companion: { + protocol: 'stream-json', + serverCmd: ['--print', '--max-turns', '1'], + systemPromptFlag: '--system-prompt', + textOnlyArgs: ['--tools', ''], + }, + }, 'companion-engine.json'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.data.companion?.systemPromptFlag).toBe('--system-prompt'); + expect(result.data.companion?.textOnlyArgs).toEqual(['--tools', '']); + }); +}); + describe('EngineDefinitionSchema execution metadata', () => { it('retains every supported engine and API execution field', () => { const result = validateEngineConfig(completeEngine, 'complete-engine.json'); diff --git a/packages/core/src/schemas/engine-schema.ts b/packages/core/src/schemas/engine-schema.ts index cf363e137..a401e6cda 100644 --- a/packages/core/src/schemas/engine-schema.ts +++ b/packages/core/src/schemas/engine-schema.ts @@ -67,6 +67,13 @@ export const CompanionConfigSchema = z.object({ // worktree — required for servers that ignore the spawn cwd (otherwise their // writes leak into the launch repo). cwdArg: z.string().optional(), + // Flag for passing the caller's systemPrompt on the command line (claude + // `--system-prompt`). stream-json has no in-band system-prompt channel — + // without this the systemPrompt is silently dropped on that protocol. + systemPromptFlag: z.string().optional(), + // Args that disable the server's tools for a plain-text answer (claude + // `['--tools','']`). Appended only when the dispatch sets textOnly. + textOnlyArgs: z.array(z.string()).optional(), sandbox: z.enum(['read-only', 'workspace-write', 'danger-full-access']).optional(), features: z.object({ threadResume: z.boolean().optional(), diff --git a/packages/forge/src/generated/brainstorm.ts b/packages/forge/src/generated/brainstorm.ts index c9c68b944..0e45c9ccd 100644 --- a/packages/forge/src/generated/brainstorm.ts +++ b/packages/forge/src/generated/brainstorm.ts @@ -32,7 +32,7 @@ export function calibrateConfidence(engineId: string, rawBid: number): number { } // @kern-source: brainstorm:22 -export function qualityScore(engineId: string, draft: KernDraft): number { +export function structuralScore(draft: KernDraft, style?: string): number { let score = 0; if (draft.approach.length > 10) { score += 20; @@ -45,32 +45,81 @@ export function qualityScore(engineId: string, draft: KernDraft): number { } score += Math.min(draft.steps.length, 7) * 5; score += Math.min(draft.tradeoffs.length, 5) * 5; - score += Math.min(draft.keyFiles.length, 5) * 3; + // keyFiles reward only outside divergent style — reframing drafts rarely name + // files, so counting them systematically buries every non-anchor stance. + if (style !== 'divergent') { + score += Math.min(draft.keyFiles.length, 5) * 3; + } + return score; +} + +// @kern-source: brainstorm:39 +export function qualityScore(engineId: string, draft: KernDraft, style?: string): number { + let score = structuralScore(draft, style); // Use calibrated confidence, not raw self-report score += calibrateConfidence(engineId, draft.confidence) * 0.05; return score; } -// @kern-source: brainstorm:38 -export function rankDrafts(drafts: {engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[]): {engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[] { +// @kern-source: brainstorm:46 +export function rankDrafts(drafts: {engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], style?: string): {engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[] { return [...drafts].sort((a, b) => { - const scoreA = qualityScore(a.engineId, a.draft); - const scoreB = qualityScore(b.engineId, b.draft); + const scoreA = qualityScore(a.engineId, a.draft, style); + const scoreB = qualityScore(b.engineId, b.draft, style); return scoreB - scoreA; }); } -// @kern-source: brainstorm:47 -export async function collectRankedDrafts(opts: {question:string, context?:string, engines:string[], registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}): Promise<{ranked:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], outcomes:SeatOutcome[]}> { +// @kern-source: brainstorm:55 +export function assignStances(engines: string[]): Map { + const stances = [ + 'ANCHOR: give your single best, most direct answer to the question as asked.', + 'CONTRARIAN: assume the approach the question implies (or the most obvious one) is wrong — argue for a fundamentally different one.', + 'FIRST-PRINCIPLES: ignore the structure the question implies; restate the underlying problem in one line and re-derive a solution from scratch.', + 'OUTSIDER: answer as a strong expert from a different domain would — import a pattern this field does not normally use here.', + 'EXPANSIONIST: propose the most ambitious defensible version — what does this look like solved properly at 10x the scope?', + 'WILDCARD: propose something deliberately unconventional that you can still defend technically.', + ]; + // Shuffle per run: a fixed seat→stance mapping would hand the same engine + // the lowest-scoring stance every time and deflate its Glicko rating. + const pool = [...stances]; + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + const map = new Map(); + engines.forEach((id, i) => map.set(id, pool[i % pool.length])); + return map; +} + +// @kern-source: brainstorm:77 +export async function collectRankedDrafts(opts: {question:string, context?:string, engines:string[], style?:string, registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}): Promise<{ranked:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], outcomes:SeatOutcome[]}> { const draftPrompt = buildKernDraftPrompt({ question: opts.question, context: opts.context, mode: 'brainstorm', }); + // Divergent style: each seat gets a distinct stance so the panel actually + // spreads out instead of six engines converging on the stated framing. + // The stance rides in the system prompt — the protocol draft prompt stays + // byte-identical so the draft-block output contract is undisturbed. + const stances = opts.style === 'divergent' ? assignStances(opts.engines) : null; + const baseSystemPrompt = 'You are participating in a brainstorm. Respond directly with your analysis and approach. Do NOT use tools, do NOT read files, do NOT run commands. Just think and write your response as plain text.'; + const draftPromises = opts.engines.map(async (engineId: string) => { const engine = opts.registry.get(engineId); opts.onEvent?.({ type: 'brainstorm:seat-started', data: { engineId } }); + const stance = stances?.get(engineId); + const systemPrompt = stance + ? [ + baseSystemPrompt, + '', + `Your seat stance — ${stance}`, + 'The question may be over-specified: treat its framing as one hypothesis about the underlying problem, not a hard constraint. If a better framing exists, say so in the reasoning field.', + 'Express the stance entirely inside the draft block fields (approach/reasoning/tradeoffs/steps). Do not add any text outside the draft block.', + ].join('\n') + : baseSystemPrompt; // One auto-retry per seat (shorter timeout) — transient flake must not // silently shrink the promised panel. Whatever still fails gets reported // through the panel-health banner instead of vanishing into a 3/6 run. @@ -78,7 +127,8 @@ export async function collectRankedDrafts(opts: {question:string, context?:strin engineId, engine, prompt: draftPrompt, - systemPrompt: 'You are participating in a brainstorm. Respond directly with your analysis and approach. Do NOT use tools, do NOT read files, do NOT run commands. Just think and write your response as plain text.', + systemPrompt, + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout: opts.timeout, @@ -100,10 +150,10 @@ export async function collectRankedDrafts(opts: {question:string, context?:strin const attempts = await Promise.all(draftPromises); const drafts = attempts.flatMap((attempt) => attempt.entry ? [attempt.entry] : []); - return { ranked: rankDrafts(drafts), outcomes: attempts.map((attempt) => attempt.seat) }; + return { ranked: rankDrafts(drafts, opts.style), outcomes: attempts.map((attempt) => attempt.seat) }; } -// @kern-source: brainstorm:90 +// @kern-source: brainstorm:138 export function scoutScore(bid: ScoutBid): number { let score = 0; // Confidence: 40% weight (0-40 points) @@ -117,12 +167,12 @@ export function scoutScore(bid: ScoutBid): number { return score; } -// @kern-source: brainstorm:103 +// @kern-source: brainstorm:151 function warnBrainstorm(message: string): void { console.warn(message); } -// @kern-source: brainstorm:108 +// @kern-source: brainstorm:156 export async function runScout(opts: {question:string, context?:string, engines:string[], scoutCount?:number, registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal}): Promise<{rankedBids:ScoutBid[], leadEngine:string, topConfidence:number, disagreementSpread:number}> { const count = opts.scoutCount ?? 2; // Filter quarantined engines BEFORE slicing, else a dead engine in the first @@ -141,7 +191,7 @@ export async function runScout(opts: {question:string, context?:string, engines: return { rankedBids: bids, leadEngine: (bids.length > 0) ? bids[0].engineId : scouts[0], topConfidence: topConfidence, disagreementSpread: disagreementSpread }; } -// @kern-source: brainstorm:126 +// @kern-source: brainstorm:174 export function fallbackParse(output: string): KernDraft { const stripped = output.replace(/\x60\x60\x60(?:json)?\s*/gi, '').replace(/\x60\x60\x60/g, ''); let depth = 0; @@ -182,9 +232,12 @@ export function fallbackParse(output: string): KernDraft { }; } -// @kern-source: brainstorm:167 -export async function runBrainstorm(opts: {question:string, context?:string, engines:string[], registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}): Promise { +// @kern-source: brainstorm:215 +export async function runBrainstorm(opts: {question:string, context?:string, engines:string[], style?:string, registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}): Promise { const brainstormId = randomUUID().slice(0, 8); + // 'divergent' is the default: brainstorm exists to spread the panel out. + // 'grounded' restores the pre-stance behavior (convergent, file-path-anchored). + const style = opts.style === 'grounded' ? 'grounded' : 'divergent'; // Cold-start: seed newly-dropped model versions from their predecessor before // bidding, so a new engine competes at its family's strength, not 1500. seedNewEnginesFromRegistry(opts.registry); @@ -201,7 +254,7 @@ export async function runBrainstorm(opts: {question:string, context?:string, eng sessionType: 'brainstorm', outputDir: opts.outputDir, }); - sidechain.log('brainstorm:init', undefined, { question: opts.question, engines: __engines }); + sidechain.log('brainstorm:init', undefined, { question: opts.question, engines: __engines, style }); const skippedOutcomes: SeatOutcome[] = __hc.skipped.map((s) => ({ engineId: s.engineId, @@ -220,6 +273,7 @@ export async function runBrainstorm(opts: {question:string, context?:string, eng question: opts.question, context: opts.context, engines: __engines, + style, registry: opts.registry, adapter: opts.adapter, timeout: opts.timeout, @@ -239,7 +293,7 @@ export async function runBrainstorm(opts: {question:string, context?:string, eng const bids: BrainstormBid[] = ranked.map((d, i) => { const reasoning = d.draft.approach + (d.draft.reasoning ? ` — ${d.draft.reasoning}` : ''); const approach = d.draft.steps.map((s: string, j: number) => `${j + 1}. ${s}`).join('\n'); - const score = qualityScore(d.engineId, d.draft); + const score = qualityScore(d.engineId, d.draft, style); return { engineId: d.engineId, confidence: calibrateConfidence(d.engineId, d.draft.confidence), @@ -283,16 +337,31 @@ export async function runBrainstorm(opts: {question:string, context?:string, eng return `## ${d.engineId} (confidence: ${d.draft.confidence}%)\nApproach: ${d.draft.approach}${d.draft.reasoning ? `\nReasoning: ${d.draft.reasoning}` : ''}${d.draft.tradeoffs?.length ? `\nTradeoffs: ${d.draft.tradeoffs.join('; ')}` : ''}${steps ? `\nSteps:\n${steps}` : ''}`; }).join('\n\n'); - const expandPrompt = [ - opts.question, - '', - `Multiple AI engines analyzed this. Here are ALL their drafts — synthesize the best parts from each into one comprehensive answer:`, - '', - allDrafts, - '', - 'Now write the best possible answer by combining the strongest ideas from ALL drafts above. Don\'t just pick one — take the best parts from each.', - 'Be specific and actionable. Include file paths where relevant.', - ].join('\n'); + // Divergent synthesis must keep the spread visible: collapsing every draft + // into one merged answer would undo the stances one dispatch later. It still + // ends with a single recommendation so downstream automation has one + // decidable answer to act on. + const expandPrompt = style === 'divergent' + ? [ + opts.question, + '', + `Multiple AI engines analyzed this from deliberately different stances. Here are ALL their drafts:`, + '', + allDrafts, + '', + 'Present the 2-3 strongest DISTINCT directions from the drafts above — including at least one that challenges the framing of the original question. For each direction: the core idea, why it could win, and its main risk.', + 'Then close with a single clear recommendation: which direction to take first and why.', + ].join('\n') + : [ + opts.question, + '', + `Multiple AI engines analyzed this. Here are ALL their drafts — synthesize the best parts from each into one comprehensive answer:`, + '', + allDrafts, + '', + 'Now write the best possible answer by combining the strongest ideas from ALL drafts above. Don\'t just pick one — take the best parts from each.', + 'Be specific and actionable. Include file paths where relevant.', + ].join('\n'); let response: string; let synthesis: {status:'completed' | 'fallback', detail?:string}; @@ -302,6 +371,7 @@ export async function runBrainstorm(opts: {question:string, context?:string, eng engine: winnerEngine, prompt: expandPrompt, systemPrompt: 'You are expanding on a winning brainstorm approach. Respond directly with your detailed analysis as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout: opts.timeout, diff --git a/packages/forge/src/generated/campfire.ts b/packages/forge/src/generated/campfire.ts index bc091662b..b40eafdc8 100644 --- a/packages/forge/src/generated/campfire.ts +++ b/packages/forge/src/generated/campfire.ts @@ -51,6 +51,7 @@ export async function runCampfire(opts: {topic:string,context?:string,engines:st engine: leadEngine, prompt: basePrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, @@ -82,6 +83,7 @@ export async function runCampfire(opts: {topic:string,context?:string,engines:st engine, prompt: observerPrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, @@ -109,6 +111,7 @@ export async function runCampfire(opts: {topic:string,context?:string,engines:st engine, prompt: basePrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, diff --git a/packages/forge/src/generated/council.ts b/packages/forge/src/generated/council.ts index 0ff575433..361adc65e 100644 --- a/packages/forge/src/generated/council.ts +++ b/packages/forge/src/generated/council.ts @@ -302,7 +302,7 @@ export async function runCouncil(opts: CouncilOptions): Promise { try { const engine = registry.get(engineId); const result: DispatchResult = await adapter.dispatch({ - engine, prompt, systemPrompt: sys, cwd, mode: 'exec', timeout: timeoutSec, outputDir, signal, + engine, prompt, systemPrompt: sys, textOnly: true, cwd, mode: 'exec', timeout: timeoutSec, outputDir, signal, }); const raw = String(result.stdout ?? '').trim(); const cleaned = raw.replace(/[\s\S]*?<\/think>\s*/gi, '').trim(); diff --git a/packages/forge/src/generated/nero.ts b/packages/forge/src/generated/nero.ts index 4fdc33406..48ccb524f 100644 --- a/packages/forge/src/generated/nero.ts +++ b/packages/forge/src/generated/nero.ts @@ -241,6 +241,7 @@ export async function runNero(opts: NeroOptions): Promise { engine, prompt, systemPrompt: sysPrompt, + textOnly: true, cwd: opts.cwd ?? resolveWorkingDir(), mode: 'exec' as any, timeout: opts.timeout, diff --git a/packages/forge/src/generated/seat-dispatch.ts b/packages/forge/src/generated/seat-dispatch.ts index bc1f4b2e7..f3c00d715 100644 --- a/packages/forge/src/generated/seat-dispatch.ts +++ b/packages/forge/src/generated/seat-dispatch.ts @@ -33,13 +33,14 @@ export function classifySeatFailure(result: DispatchResult): 'timeout' | 'empty' * Dispatch one panel seat; on a transient failure (timeout/empty/error) retry ONCE with ~half the timeout (never longer than the first attempt). Never retries a user abort. opts.extract lets a mode keep its own text-extraction (e.g. tribunal's salvage) — an extract throw counts as an 'empty' failure. The outcome note feeds the panel-health banner; detail preserves the underlying stderr/message so diagnosability survives the categorization. */ // @kern-source: seat-dispatch:31 -export async function dispatchSeatWithRetry(adapter: EngineAdapter, opts: {engineId:string, engine:any, prompt:string, systemPrompt?:string, cwd:string, mode:string, timeout:number, outputDir:string, signal?:AbortSignal, extract?:(result:DispatchResult)=>string}): Promise { +export async function dispatchSeatWithRetry(adapter: EngineAdapter, opts: {engineId:string, engine:any, prompt:string, systemPrompt?:string, textOnly?:boolean, cwd:string, mode:string, timeout:number, outputDir:string, signal?:AbortSignal, extract?:(result:DispatchResult)=>string}): Promise { const attemptOnce = async (timeoutSec: number): Promise<{ failure: 'timeout' | 'empty' | 'error' | null; text: string; detail: string; auth: boolean }> => { try { const result = await adapter.dispatch({ engine: opts.engine, prompt: opts.prompt, systemPrompt: opts.systemPrompt, + textOnly: opts.textOnly, cwd: opts.cwd, mode: opts.mode, timeout: timeoutSec, @@ -101,7 +102,7 @@ export async function dispatchSeatWithRetry(adapter: EngineAdapter, opts: {engin /** * Fold seat outcomes into the structured panel-health record + the one-line banner every surface must print when degraded ('panel degraded: codex timeout → retried OK; zai empty → dropped (5/6 responded)'). */ -// @kern-source: seat-dispatch:98 +// @kern-source: seat-dispatch:99 export function buildPanelHealth(outcomes: SeatOutcome[]): { requested: number; responded: number; degraded: boolean; notes: string[]; banner: string | null } { const requested = outcomes.length; const responded = outcomes.filter((o) => o.ok).length; diff --git a/packages/forge/src/generated/synthesis-modus.ts b/packages/forge/src/generated/synthesis-modus.ts index cee4ab148..173305c75 100644 --- a/packages/forge/src/generated/synthesis-modus.ts +++ b/packages/forge/src/generated/synthesis-modus.ts @@ -237,6 +237,7 @@ export async function runSynthesisModus(opts: SynthesisOptions): Promise { const strategy = isThinkStrategy(opts.strategy) ? opts.strategy : 'linear'; const branches = Math.max(1, Math.min(opts.branches ?? 1, 8)); @@ -486,6 +487,7 @@ export async function runThinkChain(opts: {problem:string, strategy:string, engi engine, prompt, systemPrompt: 'You are a structured sequential reasoner. Output ONLY the JSON object requested. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout: opts.timeout, diff --git a/packages/forge/src/generated/tribunal.ts b/packages/forge/src/generated/tribunal.ts index 341c39bcd..86d0c149a 100644 --- a/packages/forge/src/generated/tribunal.ts +++ b/packages/forge/src/generated/tribunal.ts @@ -177,6 +177,7 @@ export async function runTribunal(opts: {question:string, engines:string[], roun engine, prompt, systemPrompt: 'You are a debate participant. Respond directly with your argument as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout, @@ -245,6 +246,7 @@ export async function runTribunal(opts: {question:string, engines:string[], roun engine: summaryEngine, prompt: summaryPrompt, systemPrompt: 'You are synthesizing a debate. Respond directly with your verdict as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout, diff --git a/packages/forge/src/kern/brainstorm.kern b/packages/forge/src/kern/brainstorm.kern index 52e551712..8024c9670 100644 --- a/packages/forge/src/kern/brainstorm.kern +++ b/packages/forge/src/kern/brainstorm.kern @@ -19,7 +19,7 @@ fn name=calibrateConfidence params="engineId:string, rawBid:number" returns=numb comment raw="// Blend: 30% self-reported, 70% track record" return value="Math.round(rawBid * 0.3 + winRate * 100 * 0.7)" -fn name=qualityScore params="engineId:string, draft:KernDraft" returns="number" +fn name=structuralScore params="draft:KernDraft, style?:string" returns="number" handler lang="kern" let name=score kind=let value="0" if cond="draft.approach.length > 10" @@ -30,21 +30,51 @@ fn name=qualityScore params="engineId:string, draft:KernDraft" returns="number" assign target="score" op="+=" value="15" assign target="score" op="+=" value="Math.min(draft.steps.length, 7) * 5" assign target="score" op="+=" value="Math.min(draft.tradeoffs.length, 5) * 5" - assign target="score" op="+=" value="Math.min(draft.keyFiles.length, 5) * 3" + comment raw="// keyFiles reward only outside divergent style — reframing drafts rarely name" + comment raw="// files, so counting them systematically buries every non-anchor stance." + if cond="style !== 'divergent'" + assign target="score" op="+=" value="Math.min(draft.keyFiles.length, 5) * 3" + return value="score" + +fn name=qualityScore params="engineId:string, draft:KernDraft, style?:string" returns="number" + handler lang="kern" + let name=score kind=let value="structuralScore(draft, style)" comment raw="// Use calibrated confidence, not raw self-report" assign target="score" op="+=" value="calibrateConfidence(engineId, draft.confidence) * 0.05" return value="score" -fn name=rankDrafts params="drafts:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[]" returns="{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[]" +fn name=rankDrafts params="drafts:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], style?:string" returns="{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[]" handler <<< return [...drafts].sort((a, b) => { - const scoreA = qualityScore(a.engineId, a.draft); - const scoreB = qualityScore(b.engineId, b.draft); + const scoreA = qualityScore(a.engineId, a.draft, style); + const scoreB = qualityScore(b.engineId, b.draft, style); return scoreB - scoreA; }); >>> -fn name=collectRankedDrafts async=true params="opts:{question:string, context?:string, engines:string[], registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}" returns="Promise<{ranked:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], outcomes:SeatOutcome[]}>" +fn name=assignStances params="engines:string[]" returns="Map" + handler <<< + const stances = [ + 'ANCHOR: give your single best, most direct answer to the question as asked.', + 'CONTRARIAN: assume the approach the question implies (or the most obvious one) is wrong — argue for a fundamentally different one.', + 'FIRST-PRINCIPLES: ignore the structure the question implies; restate the underlying problem in one line and re-derive a solution from scratch.', + 'OUTSIDER: answer as a strong expert from a different domain would — import a pattern this field does not normally use here.', + 'EXPANSIONIST: propose the most ambitious defensible version — what does this look like solved properly at 10x the scope?', + 'WILDCARD: propose something deliberately unconventional that you can still defend technically.', + ]; + // Shuffle per run: a fixed seat→stance mapping would hand the same engine + // the lowest-scoring stance every time and deflate its Glicko rating. + const pool = [...stances]; + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + const map = new Map(); + engines.forEach((id, i) => map.set(id, pool[i % pool.length])); + return map; + >>> + +fn name=collectRankedDrafts async=true params="opts:{question:string, context?:string, engines:string[], style?:string, registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}" returns="Promise<{ranked:{engineId:string, draft:KernDraft, raw:string, seat:SeatOutcome}[], outcomes:SeatOutcome[]}>" handler lang="ts" <<< const draftPrompt = buildKernDraftPrompt({ question: opts.question, @@ -52,9 +82,26 @@ fn name=collectRankedDrafts async=true params="opts:{question:string, context?:s mode: 'brainstorm', }); + // Divergent style: each seat gets a distinct stance so the panel actually + // spreads out instead of six engines converging on the stated framing. + // The stance rides in the system prompt — the protocol draft prompt stays + // byte-identical so the draft-block output contract is undisturbed. + const stances = opts.style === 'divergent' ? assignStances(opts.engines) : null; + const baseSystemPrompt = 'You are participating in a brainstorm. Respond directly with your analysis and approach. Do NOT use tools, do NOT read files, do NOT run commands. Just think and write your response as plain text.'; + const draftPromises = opts.engines.map(async (engineId: string) => { const engine = opts.registry.get(engineId); opts.onEvent?.({ type: 'brainstorm:seat-started', data: { engineId } }); + const stance = stances?.get(engineId); + const systemPrompt = stance + ? [ + baseSystemPrompt, + '', + `Your seat stance — ${stance}`, + 'The question may be over-specified: treat its framing as one hypothesis about the underlying problem, not a hard constraint. If a better framing exists, say so in the reasoning field.', + 'Express the stance entirely inside the draft block fields (approach/reasoning/tradeoffs/steps). Do not add any text outside the draft block.', + ].join('\n') + : baseSystemPrompt; // One auto-retry per seat (shorter timeout) — transient flake must not // silently shrink the promised panel. Whatever still fails gets reported // through the panel-health banner instead of vanishing into a 3/6 run. @@ -62,7 +109,8 @@ fn name=collectRankedDrafts async=true params="opts:{question:string, context?:s engineId, engine, prompt: draftPrompt, - systemPrompt: 'You are participating in a brainstorm. Respond directly with your analysis and approach. Do NOT use tools, do NOT read files, do NOT run commands. Just think and write your response as plain text.', + systemPrompt, + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout: opts.timeout, @@ -84,7 +132,7 @@ fn name=collectRankedDrafts async=true params="opts:{question:string, context?:s const attempts = await Promise.all(draftPromises); const drafts = attempts.flatMap((attempt) => attempt.entry ? [attempt.entry] : []); - return { ranked: rankDrafts(drafts), outcomes: attempts.map((attempt) => attempt.seat) }; + return { ranked: rankDrafts(drafts, opts.style), outcomes: attempts.map((attempt) => attempt.seat) }; >>> fn name=scoutScore params="bid:ScoutBid" returns=number @@ -164,9 +212,12 @@ fn name=fallbackParse params="output:string" returns="KernDraft" }; >>> -fn name=runBrainstorm async=true params="opts:{question:string, context?:string, engines:string[], registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}" returns="Promise" +fn name=runBrainstorm async=true params="opts:{question:string, context?:string, engines:string[], style?:string, registry:EngineRegistry, adapter:EngineAdapter, timeout:number, outputDir:string, signal?:AbortSignal, onEvent?:(event:{type:string,data?:Record})=>void}" returns="Promise" handler lang="ts" <<< const brainstormId = randomUUID().slice(0, 8); + // 'divergent' is the default: brainstorm exists to spread the panel out. + // 'grounded' restores the pre-stance behavior (convergent, file-path-anchored). + const style = opts.style === 'grounded' ? 'grounded' : 'divergent'; // Cold-start: seed newly-dropped model versions from their predecessor before // bidding, so a new engine competes at its family's strength, not 1500. seedNewEnginesFromRegistry(opts.registry); @@ -183,7 +234,7 @@ fn name=runBrainstorm async=true params="opts:{question:string, context?:string, sessionType: 'brainstorm', outputDir: opts.outputDir, }); - sidechain.log('brainstorm:init', undefined, { question: opts.question, engines: __engines }); + sidechain.log('brainstorm:init', undefined, { question: opts.question, engines: __engines, style }); const skippedOutcomes: SeatOutcome[] = __hc.skipped.map((s) => ({ engineId: s.engineId, @@ -202,6 +253,7 @@ fn name=runBrainstorm async=true params="opts:{question:string, context?:string, question: opts.question, context: opts.context, engines: __engines, + style, registry: opts.registry, adapter: opts.adapter, timeout: opts.timeout, @@ -221,7 +273,7 @@ fn name=runBrainstorm async=true params="opts:{question:string, context?:string, const bids: BrainstormBid[] = ranked.map((d, i) => { const reasoning = d.draft.approach + (d.draft.reasoning ? ` — ${d.draft.reasoning}` : ''); const approach = d.draft.steps.map((s: string, j: number) => `${j + 1}. ${s}`).join('\n'); - const score = qualityScore(d.engineId, d.draft); + const score = qualityScore(d.engineId, d.draft, style); return { engineId: d.engineId, confidence: calibrateConfidence(d.engineId, d.draft.confidence), @@ -265,16 +317,31 @@ fn name=runBrainstorm async=true params="opts:{question:string, context?:string, return `## ${d.engineId} (confidence: ${d.draft.confidence}%)\nApproach: ${d.draft.approach}${d.draft.reasoning ? `\nReasoning: ${d.draft.reasoning}` : ''}${d.draft.tradeoffs?.length ? `\nTradeoffs: ${d.draft.tradeoffs.join('; ')}` : ''}${steps ? `\nSteps:\n${steps}` : ''}`; }).join('\n\n'); - const expandPrompt = [ - opts.question, - '', - `Multiple AI engines analyzed this. Here are ALL their drafts — synthesize the best parts from each into one comprehensive answer:`, - '', - allDrafts, - '', - 'Now write the best possible answer by combining the strongest ideas from ALL drafts above. Don\'t just pick one — take the best parts from each.', - 'Be specific and actionable. Include file paths where relevant.', - ].join('\n'); + // Divergent synthesis must keep the spread visible: collapsing every draft + // into one merged answer would undo the stances one dispatch later. It still + // ends with a single recommendation so downstream automation has one + // decidable answer to act on. + const expandPrompt = style === 'divergent' + ? [ + opts.question, + '', + `Multiple AI engines analyzed this from deliberately different stances. Here are ALL their drafts:`, + '', + allDrafts, + '', + 'Present the 2-3 strongest DISTINCT directions from the drafts above — including at least one that challenges the framing of the original question. For each direction: the core idea, why it could win, and its main risk.', + 'Then close with a single clear recommendation: which direction to take first and why.', + ].join('\n') + : [ + opts.question, + '', + `Multiple AI engines analyzed this. Here are ALL their drafts — synthesize the best parts from each into one comprehensive answer:`, + '', + allDrafts, + '', + 'Now write the best possible answer by combining the strongest ideas from ALL drafts above. Don\'t just pick one — take the best parts from each.', + 'Be specific and actionable. Include file paths where relevant.', + ].join('\n'); let response: string; let synthesis: {status:'completed' | 'fallback', detail?:string}; @@ -284,6 +351,7 @@ fn name=runBrainstorm async=true params="opts:{question:string, context?:string, engine: winnerEngine, prompt: expandPrompt, systemPrompt: 'You are expanding on a winning brainstorm approach. Respond directly with your detailed analysis as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout: opts.timeout, diff --git a/packages/forge/src/kern/campfire.kern b/packages/forge/src/kern/campfire.kern index 5176ce4d4..570e528f0 100644 --- a/packages/forge/src/kern/campfire.kern +++ b/packages/forge/src/kern/campfire.kern @@ -45,6 +45,7 @@ fn name=runCampfire async=true params="opts:{topic:string,context?:string,engine engine: leadEngine, prompt: basePrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, @@ -76,6 +77,7 @@ fn name=runCampfire async=true params="opts:{topic:string,context?:string,engine engine, prompt: observerPrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, @@ -103,6 +105,7 @@ fn name=runCampfire async=true params="opts:{topic:string,context?:string,engine engine, prompt: basePrompt, systemPrompt: 'You are in a campfire conversation. Respond directly with your thoughts as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, diff --git a/packages/forge/src/kern/council.kern b/packages/forge/src/kern/council.kern index 2ee440573..b693498ae 100644 --- a/packages/forge/src/kern/council.kern +++ b/packages/forge/src/kern/council.kern @@ -298,7 +298,7 @@ fn name=runCouncil async=true params="opts:CouncilOptions" returns="Promise[\s\S]*?<\/think>\s*/gi, '').trim(); diff --git a/packages/forge/src/kern/nero.kern b/packages/forge/src/kern/nero.kern index 7bd21ef8a..eb0fdaf70 100644 --- a/packages/forge/src/kern/nero.kern +++ b/packages/forge/src/kern/nero.kern @@ -234,6 +234,7 @@ fn name=runNero async=true params="opts:NeroOptions" returns="Promisestring}" returns="Promise" export=true +fn name=dispatchSeatWithRetry async=true params="adapter:EngineAdapter, opts:{engineId:string, engine:any, prompt:string, systemPrompt?:string, textOnly?:boolean, cwd:string, mode:string, timeout:number, outputDir:string, signal?:AbortSignal, extract?:(result:DispatchResult)=>string}" returns="Promise" export=true doc "Dispatch one panel seat; on a transient failure (timeout/empty/error) retry ONCE with ~half the timeout (never longer than the first attempt). Never retries a user abort. opts.extract lets a mode keep its own text-extraction (e.g. tribunal's salvage) — an extract throw counts as an 'empty' failure. The outcome note feeds the panel-health banner; detail preserves the underlying stderr/message so diagnosability survives the categorization." handler <<< const attemptOnce = async (timeoutSec: number): Promise<{ failure: 'timeout' | 'empty' | 'error' | null; text: string; detail: string; auth: boolean }> => { @@ -37,6 +37,7 @@ fn name=dispatchSeatWithRetry async=true params="adapter:EngineAdapter, opts:{en engine: opts.engine, prompt: opts.prompt, systemPrompt: opts.systemPrompt, + textOnly: opts.textOnly, cwd: opts.cwd, mode: opts.mode, timeout: timeoutSec, diff --git a/packages/forge/src/kern/synthesis-modus.kern b/packages/forge/src/kern/synthesis-modus.kern index f2a302da8..9d5bc69e0 100644 --- a/packages/forge/src/kern/synthesis-modus.kern +++ b/packages/forge/src/kern/synthesis-modus.kern @@ -223,6 +223,7 @@ fn name=runSynthesisModus async=true params="opts:SynthesisOptions" returns="Pro engine, prompt: buildSynthesisDraftPrompt(prompt), systemPrompt: 'You are participating in a synthesis competition. Produce your best independent draft. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout, @@ -263,6 +264,7 @@ fn name=runSynthesisModus async=true params="opts:SynthesisOptions" returns="Pro engine, prompt: buildSynthesisSwapPrompt(prompt, fromId, original), systemPrompt: "You are improving another engine's draft in a synthesis competition. Produce a better version with a ## REASONING section. Do NOT use tools, read files, or run commands.", + textOnly: true, cwd, mode: 'exec', timeout, @@ -301,6 +303,7 @@ fn name=runSynthesisModus async=true params="opts:SynthesisOptions" returns="Pro engine: judgeEngine, prompt: buildSynthesisJudgePrompt(prompt, entries), systemPrompt: 'You are an impartial judge in a synthesis competition. Score entries and declare a winner. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'review', timeout, diff --git a/packages/forge/src/kern/thinking.kern b/packages/forge/src/kern/thinking.kern index 75fde7a27..d1b049d8d 100644 --- a/packages/forge/src/kern/thinking.kern +++ b/packages/forge/src/kern/thinking.kern @@ -420,6 +420,7 @@ fn name=runAdversarialCritique async=true params="opts:{problem:string, thoughts engine: critic, prompt, systemPrompt: 'You are an adversarial reviewer of a reasoning chain. Respond with terse, specific critique bullets only. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: opts.cwd ?? process.cwd(), mode: 'exec', timeout: opts.timeout, @@ -452,6 +453,7 @@ fn name=runThinkChain async=true params="opts:{problem:string, strategy:string, engine, prompt, systemPrompt: 'You are a structured sequential reasoner. Output ONLY the JSON object requested. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd, mode: 'exec', timeout: opts.timeout, diff --git a/packages/forge/src/kern/tribunal.kern b/packages/forge/src/kern/tribunal.kern index 41ae3eb2f..211c39ab8 100644 --- a/packages/forge/src/kern/tribunal.kern +++ b/packages/forge/src/kern/tribunal.kern @@ -159,6 +159,7 @@ fn name=runTribunal async=true params="opts:{question:string, engines:string[], engine, prompt, systemPrompt: 'You are a debate participant. Respond directly with your argument as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout, @@ -227,6 +228,7 @@ fn name=runTribunal async=true params="opts:{question:string, engines:string[], engine: summaryEngine, prompt: summaryPrompt, systemPrompt: 'You are synthesizing a debate. Respond directly with your verdict as plain text. Do NOT use tools, read files, or run commands.', + textOnly: true, cwd: process.cwd(), mode: 'exec', timeout, diff --git a/packages/forge/test/kern/brainstorm.test.kern b/packages/forge/test/kern/brainstorm.test.kern new file mode 100644 index 000000000..3937b3636 --- /dev/null +++ b/packages/forge/test/kern/brainstorm.test.kern @@ -0,0 +1,11 @@ +test name="Brainstorm divergence contracts" target="../../src/kern/brainstorm.kern" + it name="brainstorm source stays valid KERN" + expect no=schemaViolations + expect no=semanticViolations + expect no=codegenErrors + + it name="grounded style rewards keyFiles" + expect fn=structuralScore args={{[{"approach": "use a message queue to decouple the pipeline", "reasoning": "reduces coupling", "tradeoffs": ["latency", "ops burden"], "confidence": 50, "keyFiles": ["src/a.ts", "src/b.ts"], "steps": ["one", "two", "three"]}, "grounded"]}} equals=76 + + it name="divergent style drops the keyFiles reward so reframing drafts are not buried" + expect fn=structuralScore args={{[{"approach": "use a message queue to decouple the pipeline", "reasoning": "reduces coupling", "tradeoffs": ["latency", "ops burden"], "confidence": 50, "keyFiles": ["src/a.ts", "src/b.ts"], "steps": ["one", "two", "three"]}, "divergent"]}} equals=70 diff --git a/tests/unit/brainstorm-style.test.ts b/tests/unit/brainstorm-style.test.ts new file mode 100644 index 000000000..0a620f62a --- /dev/null +++ b/tests/unit/brainstorm-style.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { assignStances, collectRankedDrafts, runBrainstorm } from '../../packages/forge/src/generated/brainstorm.js'; + +const STANCE_LABELS = ['ANCHOR', 'CONTRARIAN', 'FIRST-PRINCIPLES', 'OUTSIDER', 'EXPANSIONIST', 'WILDCARD']; + +const DRAFT_BLOCK = `draft { + approach: "use a message queue to decouple the pipeline" + reasoning: "reduces coupling between stages" + tradeoffs: "latency", "ops burden" + confidence: 70 + keyFiles: "src/a.ts" + steps { + 1: "one" + 2: "two" + 3: "three" + } +}`; + +function makeFakes() { + const calls: { engineId: string; prompt: string; systemPrompt?: string; textOnly?: boolean }[] = []; + const adapter = { + dispatch: async (o: any) => { + calls.push({ engineId: o.engine?.id, prompt: o.prompt, systemPrompt: o.systemPrompt, textOnly: o.textOnly }); + return { exitCode: 0, stdout: DRAFT_BLOCK, stderr: '', timedOut: false }; + }, + } as any; + const registry = { + get: (id: string) => ({ id, binary: id }), + list: () => [], + findBinary: () => null, + } as any; + return { calls, adapter, registry }; +} + +const SIX_ENGINES = ['e1', 'e2', 'e3', 'e4', 'e5', 'e6']; + +function baseOpts(overrides: Record = {}) { + const { calls, adapter, registry } = makeFakes(); + return { + calls, + opts: { + question: 'how should we cache?', + engines: SIX_ENGINES, + registry, + adapter, + timeout: 5, + outputDir: mkdtempSync(join(tmpdir(), 'agon-brainstorm-style-')), + ...overrides, + }, + }; +} + +describe('brainstorm divergent style', () => { + describe('assignStances', () => { + it('gives every engine exactly one stance from the known set', () => { + const map = assignStances(SIX_ENGINES); + expect(map.size).toBe(6); + for (const id of SIX_ENGINES) { + const stance = map.get(id)!; + expect(STANCE_LABELS.some((label) => stance.startsWith(`${label}:`))).toBe(true); + } + }); + + it('uses all six distinct stances for a six-engine panel', () => { + const map = assignStances(SIX_ENGINES); + expect(new Set(map.values()).size).toBe(6); + }); + + it('cycles stances when the panel is larger than the stance pool', () => { + const engines = [...SIX_ENGINES, 'e7', 'e8']; + const map = assignStances(engines); + expect(map.size).toBe(8); + for (const id of engines) expect(map.get(id)).toBeTruthy(); + }); + }); + + describe('collectRankedDrafts stance injection', () => { + it('divergent style: each seat gets a distinct stance in the system prompt, user prompt stays identical and stance-free', async () => { + const { calls, opts } = baseOpts({ style: 'divergent' }); + await collectRankedDrafts(opts as any); + expect(calls.length).toBe(6); + const stanceLines = calls.map((c) => c.systemPrompt ?? ''); + for (const sp of stanceLines) { + expect(sp).toContain('Your seat stance —'); + expect(sp).toContain('one hypothesis about the underlying problem'); + expect(sp).toContain('Do not add any text outside the draft block'); + } + expect(new Set(stanceLines).size).toBe(6); + // Protocol draft prompt must stay byte-identical across seats — the + // stance rides ONLY in the system prompt. + expect(new Set(calls.map((c) => c.prompt)).size).toBe(1); + expect(calls[0].prompt).not.toContain('seat stance'); + }); + + it('grounded/absent style: system prompt is the plain brainstorm instruction with no stance', async () => { + const { calls, opts } = baseOpts(); + await collectRankedDrafts(opts as any); + expect(calls.length).toBe(6); + for (const c of calls) { + expect(c.systemPrompt).not.toContain('seat stance'); + expect(c.systemPrompt).toContain('You are participating in a brainstorm'); + } + }); + }); + + describe('runBrainstorm style dispatch + synthesis prompt', () => { + async function synthesisPromptFor(style?: string) { + const { calls, opts } = baseOpts(style ? { style } : {}); + await runBrainstorm(opts as any); + const synthesis = calls.filter((c) => c.prompt.includes('Multiple AI engines analyzed')); + expect(synthesis.length).toBe(1); + return synthesis[0].prompt; + } + + it('defaults to divergent: synthesis keeps distinct directions and one closing recommendation', async () => { + const prompt = await synthesisPromptFor(); + expect(prompt).toContain('deliberately different stances'); + expect(prompt).toContain('DISTINCT directions'); + expect(prompt).toContain('challenges the framing'); + expect(prompt).toContain('single clear recommendation'); + expect(prompt).not.toContain('Include file paths where relevant'); + }); + + it('grounded style restores the convergent synthesis prompt', async () => { + const prompt = await synthesisPromptFor('grounded'); + expect(prompt).toContain('synthesize the best parts from each into one comprehensive answer'); + expect(prompt).toContain('Be specific and actionable. Include file paths where relevant.'); + expect(prompt).not.toContain('DISTINCT directions'); + }); + + it('marks every seat and the synthesis dispatch textOnly so engines cannot burn the turn on tools', async () => { + const { calls, opts } = baseOpts(); + await runBrainstorm(opts as any); + expect(calls.length).toBeGreaterThan(0); + for (const c of calls) expect(c.textOnly).toBe(true); + }); + }); +}); diff --git a/tests/unit/companion-dispatch.test.ts b/tests/unit/companion-dispatch.test.ts index 18c089480..8ac916147 100644 --- a/tests/unit/companion-dispatch.test.ts +++ b/tests/unit/companion-dispatch.test.ts @@ -45,6 +45,96 @@ describe('companionDispatch', () => { expect(Date.now() - startedAt).toBeLessThan(3000); }); + it('forwards the system prompt via systemPromptFlag on the stream-json path', async () => { + // Fake server echoes its argv back as the result — proves the flag+prompt + // landed on the command line (stream-json has no in-band system-prompt channel). + // The '--' keeps node from eating the appended flags as node options; the + // interval keeps stdin writable until dispatch teardown kills the process. + const script = "process.stdout.write(JSON.stringify({ type: 'result', result: process.argv.slice(1).join(' ') }) + '\\n'); setInterval(() => {}, 1000);"; + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { + protocol: 'stream-json', + serverCmd: ['-e', script, '--'], + systemPromptFlag: '--system-prompt', + }, + prompt: 'hello', + cwd: process.cwd(), + timeout: 5, + mode: 'exec', + systemPrompt: 'SEAT_STANCE_MARKER do not use tools', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('--system-prompt'); + expect(result.stdout).toContain('SEAT_STANCE_MARKER'); + }); + + it('appends textOnlyArgs only when the dispatch sets textOnly', async () => { + const script = "process.stdout.write(JSON.stringify({ type: 'result', result: process.argv.slice(1).join(' ') }) + '\\n'); setInterval(() => {}, 1000);"; + const config = { + protocol: 'stream-json' as const, + serverCmd: ['-e', script, '--'], + textOnlyArgs: ['--tools', ''], + }; + const base = { binaryPath: process.execPath, config, prompt: 'hello', cwd: process.cwd(), timeout: 5, mode: 'exec' as const }; + + const withTextOnly = await companionDispatch({ ...base, textOnly: true }); + expect(withTextOnly.stdout).toContain('--tools'); + + const withoutTextOnly = await companionDispatch({ ...base }); + expect(withoutTextOnly.stdout).not.toContain('--tools'); + }); + + it('returns empty stdout when a stream-json exec turn ends on tool_use, so the adapter falls through to CLI spawn', async () => { + const script = [ + "process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'I will quickly verify the file paths.' }, { type: 'tool_use', name: 'Read', input: {} }] } }) + '\\n');", + "process.stdout.write(JSON.stringify({ type: 'result', result: 'I will quickly verify the file paths.' }) + '\\n');", + 'setInterval(() => {}, 1000);', + ].join(''); + + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { + protocol: 'stream-json', + serverCmd: ['-e', script], + }, + prompt: 'hello', + cwd: process.cwd(), + timeout: 5, + mode: 'exec', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('tool_use'); + expect(result.stderr).toContain('quickly verify'); + }); + + it('applies the tool_use backstop to review mode too (review turns inherently want tools)', async () => { + const script = [ + "process.stdout.write(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'Let me examine the diff first.' }, { type: 'tool_use', name: 'Bash', input: {} }] } }) + '\\n');", + "process.stdout.write(JSON.stringify({ type: 'result', result: 'Let me examine the diff first.' }) + '\\n');", + 'setInterval(() => {}, 1000);', + ].join(''); + + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { + protocol: 'stream-json', + serverCmd: ['-e', script], + }, + prompt: 'review this', + cwd: process.cwd(), + timeout: 5, + mode: 'review', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toContain('tool_use'); + }); + it('concatenates token-level ACP agent_message_chunk deltas instead of one word per paragraph', async () => { // Fake ACP server: answers initialize/session/new, then streams the agent // message as per-word chunks (kimi style) with a tool_call in the middle, diff --git a/tests/unit/strip-stream-json.test.ts b/tests/unit/strip-stream-json.test.ts new file mode 100644 index 000000000..7d675dda3 --- /dev/null +++ b/tests/unit/strip-stream-json.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { stripStreamJson } from '../../packages/adapter-cli/src/generated/adapter-helpers.js'; + +function ndjson(...events: unknown[]): string { + return events.map((e) => JSON.stringify(e)).join('\n'); +} + +const assistantText = (text: string) => ({ type: 'assistant', message: { content: [{ type: 'text', text }] } }); + +describe('stripStreamJson', () => { + it('prefers the final result event so the answer is not duplicated', () => { + const out = stripStreamJson(ndjson( + assistantText('## Answer\nfull answer body'), + { type: 'result', result: '## Answer\nfull answer body', is_error: false }, + )); + expect(out).toBe('## Answer\nfull answer body'); + }); + + it('drops tool preambles when a later result carries the real answer (multi-turn tool run)', () => { + const out = stripStreamJson(ndjson( + assistantText("I'll quickly verify the file paths."), + { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read', input: {} }] } }, + assistantText('The verified answer.'), + { type: 'result', result: 'The verified answer.', is_error: false }, + )); + expect(out).toBe('The verified answer.'); + }); + + it('falls back to assistant texts when the stream has no result event (timeout kill)', () => { + const out = stripStreamJson(ndjson( + assistantText('partial answer up to the cutoff'), + )); + expect(out).toBe('partial answer up to the cutoff'); + }); + + it('falls back to assistant texts when the result event is an error (max-tokens truncation)', () => { + const out = stripStreamJson(ndjson( + assistantText('truncated but valid text'), + { type: 'result', result: '', is_error: true }, + )); + expect(out).toBe('truncated but valid text'); + }); + + it('never lets a non-string result payload mask the assistant text', () => { + const out = stripStreamJson(ndjson( + assistantText('the actual answer'), + { type: 'result', result: { some: 'object' }, is_error: false }, + )); + expect(out).toContain('the actual answer'); + expect(out).not.toBe('{"some":"object"}'); + }); + + it('keeps raw non-JSON lines as-is', () => { + expect(stripStreamJson('plain engine output\nsecond line')).toBe('plain engine output\nsecond line'); + }); + + it('skips system and hook events', () => { + const out = stripStreamJson(ndjson( + { type: 'system', subtype: 'init' }, + { type: 'system', subtype: 'hook_started' }, + assistantText('answer'), + { type: 'result', result: 'answer', is_error: false }, + )); + expect(out).toBe('answer'); + }); +});