From b844fedbebdd26280d0c8a822c260f2780908012 Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Fri, 7 Aug 2026 16:47:04 +0200 Subject: [PATCH 1/3] feat(core): manage Python sidecar dependencies --- packages/cli/src/generated/commands/doctor.ts | 52 +++++++------ packages/cli/src/kern/commands/doctor.kern | 36 +++++---- .../src/generated/blocks/dedup-resolver.ts | 18 ++++- .../blocks/syntax-validator-bridge.ts | 4 +- .../src/generated/blocks/task-classifier.ts | 4 +- packages/core/src/generated/rag/embed.ts | 6 +- .../sessions/history-search-bridge.ts | 4 +- packages/core/src/index.ts | 2 +- .../core/src/kern/blocks/dedup-resolver.kern | 11 +++ .../kern/blocks/syntax-validator-bridge.kern | 4 +- .../core/src/kern/blocks/task-classifier.kern | 4 +- packages/core/src/kern/rag/embed.kern | 4 +- .../kern/sessions/history-search-bridge.kern | 4 +- packages/dedup/install-python.mjs | 28 +++++++ packages/dedup/package.json | 3 +- packages/forge/src/generated/dedup-bridge.ts | 4 +- packages/forge/src/kern/dedup-bridge.kern | 4 +- tests/unit/dedup-python-resolver.test.ts | 77 +++++++++++++++++++ 18 files changed, 203 insertions(+), 66 deletions(-) create mode 100644 packages/dedup/install-python.mjs create mode 100644 tests/unit/dedup-python-resolver.test.ts diff --git a/packages/cli/src/generated/commands/doctor.ts b/packages/cli/src/generated/commands/doctor.ts index bf3358ffe..5a5813a45 100644 --- a/packages/cli/src/generated/commands/doctor.ts +++ b/packages/cli/src/generated/commands/doctor.ts @@ -12,7 +12,7 @@ import { join, dirname, basename } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { EngineRegistry, loadConfig, resolveWorkingDir, repoRoot, headSha, worktreeCreate, worktreeRemoveBestEffort, resolveDedupSidecar, agonPath } from '@kernlang/agon-core'; +import { EngineRegistry, loadConfig, resolveWorkingDir, repoRoot, headSha, worktreeCreate, worktreeRemoveBestEffort, resolveDedupSidecar, resolveSidecarPython, agonPath } from '@kernlang/agon-core'; import { resolveBuiltinEnginesDir } from '../lib/engines-dir.js'; @@ -192,26 +192,30 @@ export interface PythonDoctorResult { * Every Python file the bridges actually spawn. Doctor confirms ALL of them are reachable through resolveDedupSidecar — a bad package that ships only some is still a problem. */ // @kern-source: doctor:161 -export const EXPECTED_SIDECARS: string[] = ['history-search.py','syntax-validator.py','classifier.py','sidecar.py']; +export const EXPECTED_SIDECARS: string[] = ['history-search.py','syntax-validator.py','classifier.py','sidecar.py','embedder.py']; /** - * Probe whether the Python interpreter exists, every expected sidecar file resolves, and the deps (fastembed, numpy, tree-sitter + grammars) are importable. Fail-soft: not having Python means semantic features fall back, not that agon is broken. + * Return a shell-safe sidecar install command for any working directory, or an empty string when the installer is unavailable. */ // @kern-source: doctor:164 +export function formatPythonSidecarInstallCommand(installScript: string|null): string { + return installScript + ? `node ${shellQuoteForDoctor(installScript)}` + : ''; +} + +/** + * Probe whether the Python interpreter exists, every expected sidecar file resolves, and the deps (fastembed, numpy, tree-sitter + grammars) are importable. Fail-soft: not having Python means semantic features fall back, not that agon is broken. + */ +// @kern-source: doctor:172 export function diagnoseDedupPython(): PythonDoctorResult { - const python = process.env.AGON_PYTHON || 'python3'; - // Tiny probe — imports every dep used across the 4 sidecars in one shot. + const python = resolveSidecarPython(); + // Tiny probe — imports every dependency used across the sidecars in one shot. // Exit 0 = all importable; non-zero stderr names the first missing module. const probe = 'import fastembed, numpy, tree_sitter, tree_sitter_python, tree_sitter_typescript, tree_sitter_javascript, tree_sitter_json'; - // Pick a real path for the install command. requirements.txt ships in - // @kernlang/agon-dedup so we resolve it the same way as the .py files — works in - // both the monorepo and a published install. Falls back to the - // package-name form if resolution somehow fails. - const requirementsPath = resolveDedupSidecar('requirements.txt'); - const pipInstall = requirementsPath - ? `python3 -m pip install --user -r ${requirementsPath}` - : 'python3 -m pip install --user fastembed numpy tree-sitter tree-sitter-python tree-sitter-typescript tree-sitter-javascript tree-sitter-json'; + // Resolve the published installer so the command works from any cwd. + const pipInstall = formatPythonSidecarInstallCommand(resolveDedupSidecar('install-python.mjs')); let result; try { @@ -223,7 +227,7 @@ export function diagnoseDedupPython(): PythonDoctorResult { return { status: 'fail', detail: `${python} probe threw: ${err instanceof Error ? err.message : String(err)}`, - installCommand: 'Install Python 3.10+ from https://python.org or your package manager, then run the pip install above.', + installCommand: pipInstall || 'Install Python 3.10+ from https://python.org or your package manager.', }; } if (result.error || result.status === null) { @@ -231,12 +235,12 @@ export function diagnoseDedupPython(): PythonDoctorResult { return { status: 'fail', detail: msg, - installCommand: 'Install Python 3.10+ (semantic history, syntax validator, task classifier, brainstorm dedup all degrade without it)', + installCommand: 'Install Python 3.10+ (semantic history, syntax validator, task classifier, brainstorm dedup, and RAG embeddings all degrade without it)', }; } if (result.status === 0) { // Confirm EVERY expected sidecar resolves — a partial dedup package - // would otherwise pass the probe while still leaving 3 of 4 bridges + // would otherwise pass the probe while leaving one or more bridges // broken at runtime. const missing = EXPECTED_SIDECARS.filter((name: string) => !resolveDedupSidecar(name)); if (missing.length === EXPECTED_SIDECARS.length) { @@ -250,7 +254,7 @@ export function diagnoseDedupPython(): PythonDoctorResult { return { status: 'fail', detail: `@kernlang/agon-dedup is partial — missing sidecar(s): ${missing.join(', ')}`, - installCommand: 'reinstall @kernlang/agon-dedup — the published tarball should include all four .py files', + installCommand: 'reinstall @kernlang/agon-dedup — the published tarball should include all five .py files', }; } const sample = resolveDedupSidecar('history-search.py'); @@ -264,11 +268,11 @@ export function diagnoseDedupPython(): PythonDoctorResult { return { status: 'warn', detail: `${python} OK, but ${missingModule} not installed`, - installCommand: pipInstall, + installCommand: pipInstall || undefined, }; } -// @kern-source: doctor:236 +// @kern-source: doctor:238 export function checkDoctorWorktree(cwd: string): {ok:boolean; message:string; cleanupCommand:string} { let root = ''; let tempDir = ''; @@ -305,7 +309,7 @@ export function checkDoctorWorktree(cwd: string): {ok:boolean; message:string; c /** * Diagnose the live Cesar harness: selected engine, backend capability, native/MCP session state, and observed tool reliability. */ -// @kern-source: doctor:270 +// @kern-source: doctor:272 export function buildHarnessDoctorReport(registry: EngineRegistry, config: any, cesar?: any): HarnessDoctorReport { const rows: string[][] = []; const selected = String((config as any)?.cesarEngine ?? (config as any)?.forgeFixedStarter ?? 'claude'); @@ -396,14 +400,14 @@ export function buildHarnessDoctorReport(registry: EngineRegistry, config: any, }; } -// @kern-source: doctor:362 +// @kern-source: doctor:364 export interface ReviewDoctorReport { rows: string[][]; ok: boolean; summary: string; } -// @kern-source: doctor:367 +// @kern-source: doctor:369 export const REVIEW_DOCTOR_DIFF: string = [ 'diff --git a/sample.ts b/sample.ts', 'new file mode 100644', @@ -419,7 +423,7 @@ export const REVIEW_DOCTOR_DIFF: string = [ /** * Review-specific reliability smoke test (#8). For each engine, runs a real review of a tiny synthetic buggy diff through the full runReviewCore pipeline (prompt → dispatch → sentinel parse → repair) under a short hard timeout, then classifies whether the engine produced machine-parseable output. This catches engines that pass `doctor engines` (binary/key reachable) but hang or emit unparseable output in actual review use — the kimi/zai failure mode the user hit. */ -// @kern-source: doctor:379 +// @kern-source: doctor:381 export async function runReviewDoctor(registry: EngineRegistry, config: any, adapter: any, engineIds: string[], timeoutSec: number): Promise { // Align the inner dispatch timeout with the orchestrator wall clock (+grace) // so a misbehaving dispatch path that defers abort still can't exceed the @@ -475,7 +479,7 @@ export async function runReviewDoctor(registry: EngineRegistry, config: any, ada }; } -// @kern-source: doctor:436 +// @kern-source: doctor:438 export const doctorCommand: any = defineCommand({ meta: { name: 'doctor', diff --git a/packages/cli/src/kern/commands/doctor.kern b/packages/cli/src/kern/commands/doctor.kern index f5ecb9fa5..f6d42a1a6 100644 --- a/packages/cli/src/kern/commands/doctor.kern +++ b/packages/cli/src/kern/commands/doctor.kern @@ -4,7 +4,7 @@ import from="node:fs" names="mkdtempSync,rmSync,writeFileSync,existsSync" import from="node:os" names="tmpdir,homedir" import from="node:path" names="join,dirname,basename" import from="node:url" names="fileURLToPath" -import from="@kernlang/agon-core" names="EngineRegistry,loadConfig,resolveWorkingDir,repoRoot,headSha,worktreeCreate,worktreeRemoveBestEffort,resolveDedupSidecar,agonPath" +import from="@kernlang/agon-core" names="EngineRegistry,loadConfig,resolveWorkingDir,repoRoot,headSha,worktreeCreate,worktreeRemoveBestEffort,resolveDedupSidecar,resolveSidecarPython,agonPath" import from="../lib/engines-dir.js" names="resolveBuiltinEnginesDir" import from="@kernlang/agon-core" names="EngineDefinition" types=true import from="@kernlang/agon-adapter-cli" names="createCliAdapter" @@ -158,25 +158,27 @@ interface name=PythonDoctorResult field name=detail type=string field name=installCommand type=string optional=true -const name=EXPECTED_SIDECARS type="string[]" value={{ ['history-search.py','syntax-validator.py','classifier.py','sidecar.py'] }} +const name=EXPECTED_SIDECARS type="string[]" value={{ ['history-search.py','syntax-validator.py','classifier.py','sidecar.py','embedder.py'] }} export=true doc "Every Python file the bridges actually spawn. Doctor confirms ALL of them are reachable through resolveDedupSidecar — a bad package that ships only some is still a problem." +fn name=formatPythonSidecarInstallCommand params="installScript:string|null" returns=string export=true + doc "Return a shell-safe sidecar install command for any working directory, or an empty string when the installer is unavailable." + handler <<< + return installScript + ? `node ${shellQuoteForDoctor(installScript)}` + : ''; + >>> + fn name=diagnoseDedupPython returns=PythonDoctorResult export=true doc "Probe whether the Python interpreter exists, every expected sidecar file resolves, and the deps (fastembed, numpy, tree-sitter + grammars) are importable. Fail-soft: not having Python means semantic features fall back, not that agon is broken." handler <<< - const python = process.env.AGON_PYTHON || 'python3'; - // Tiny probe — imports every dep used across the 4 sidecars in one shot. + const python = resolveSidecarPython(); + // Tiny probe — imports every dependency used across the sidecars in one shot. // Exit 0 = all importable; non-zero stderr names the first missing module. const probe = 'import fastembed, numpy, tree_sitter, tree_sitter_python, tree_sitter_typescript, tree_sitter_javascript, tree_sitter_json'; - // Pick a real path for the install command. requirements.txt ships in - // @kernlang/agon-dedup so we resolve it the same way as the .py files — works in - // both the monorepo and a published install. Falls back to the - // package-name form if resolution somehow fails. - const requirementsPath = resolveDedupSidecar('requirements.txt'); - const pipInstall = requirementsPath - ? `python3 -m pip install --user -r ${requirementsPath}` - : 'python3 -m pip install --user fastembed numpy tree-sitter tree-sitter-python tree-sitter-typescript tree-sitter-javascript tree-sitter-json'; + // Resolve the published installer so the command works from any cwd. + const pipInstall = formatPythonSidecarInstallCommand(resolveDedupSidecar('install-python.mjs')); let result; try { @@ -188,7 +190,7 @@ fn name=diagnoseDedupPython returns=PythonDoctorResult export=true return { status: 'fail', detail: `${python} probe threw: ${err instanceof Error ? err.message : String(err)}`, - installCommand: 'Install Python 3.10+ from https://python.org or your package manager, then run the pip install above.', + installCommand: pipInstall || 'Install Python 3.10+ from https://python.org or your package manager.', }; } if (result.error || result.status === null) { @@ -196,12 +198,12 @@ fn name=diagnoseDedupPython returns=PythonDoctorResult export=true return { status: 'fail', detail: msg, - installCommand: 'Install Python 3.10+ (semantic history, syntax validator, task classifier, brainstorm dedup all degrade without it)', + installCommand: 'Install Python 3.10+ (semantic history, syntax validator, task classifier, brainstorm dedup, and RAG embeddings all degrade without it)', }; } if (result.status === 0) { // Confirm EVERY expected sidecar resolves — a partial dedup package - // would otherwise pass the probe while still leaving 3 of 4 bridges + // would otherwise pass the probe while leaving one or more bridges // broken at runtime. const missing = EXPECTED_SIDECARS.filter((name: string) => !resolveDedupSidecar(name)); if (missing.length === EXPECTED_SIDECARS.length) { @@ -215,7 +217,7 @@ fn name=diagnoseDedupPython returns=PythonDoctorResult export=true return { status: 'fail', detail: `@kernlang/agon-dedup is partial — missing sidecar(s): ${missing.join(', ')}`, - installCommand: 'reinstall @kernlang/agon-dedup — the published tarball should include all four .py files', + installCommand: 'reinstall @kernlang/agon-dedup — the published tarball should include all five .py files', }; } const sample = resolveDedupSidecar('history-search.py'); @@ -229,7 +231,7 @@ fn name=diagnoseDedupPython returns=PythonDoctorResult export=true return { status: 'warn', detail: `${python} OK, but ${missingModule} not installed`, - installCommand: pipInstall, + installCommand: pipInstall || undefined, }; >>> diff --git a/packages/core/src/generated/blocks/dedup-resolver.ts b/packages/core/src/generated/blocks/dedup-resolver.ts index cff9d0e9d..b4d59d873 100644 --- a/packages/core/src/generated/blocks/dedup-resolver.ts +++ b/packages/core/src/generated/blocks/dedup-resolver.ts @@ -1,4 +1,4 @@ -// @generated by kern v3.5.7 — DO NOT EDIT. Source: src/kern/blocks/dedup-resolver.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/blocks/dedup-resolver.kern import { createRequire } from 'node:module'; @@ -8,10 +8,24 @@ import { fileURLToPath } from 'node:url'; import { existsSync } from 'node:fs'; +import { agonPath } from '../signals/config.js'; + +/** + * Resolve the Python interpreter for optional sidecars: explicit AGON_PYTHON, then Agon managed virtualenv, then python3. + */ +// @kern-source: dedup-resolver:21 +export function resolveSidecarPython(): string { + if (process.env.AGON_PYTHON) return process.env.AGON_PYTHON; + const managed = process.platform === 'win32' + ? agonPath('python-sidecar', 'Scripts', 'python.exe') + : agonPath('python-sidecar', 'bin', 'python'); + return existsSync(managed) ? managed : 'python3'; +} + /** * Return the absolute path of a Python sidecar shipped in @kernlang/agon-dedup, or null if not found. `filename` is the bare filename (e.g. 'history-search.py'), not a path. */ -// @kern-source: dedup-resolver:20 +// @kern-source: dedup-resolver:31 export function resolveDedupSidecar(filename: string): string | null { // Mode 1 — production install. require.resolve finds package.json by // package-name resolution, dirname gives us the install location. diff --git a/packages/core/src/generated/blocks/syntax-validator-bridge.ts b/packages/core/src/generated/blocks/syntax-validator-bridge.ts index 4ab848dfe..284c3f161 100644 --- a/packages/core/src/generated/blocks/syntax-validator-bridge.ts +++ b/packages/core/src/generated/blocks/syntax-validator-bridge.ts @@ -4,7 +4,7 @@ import { spawnSync } from 'node:child_process'; import { extname } from 'node:path'; -import { resolveDedupSidecar } from './dedup-resolver.js'; +import { resolveDedupSidecar, resolveSidecarPython } from './dedup-resolver.js'; // @kern-source: syntax-validator-bridge:10 export interface SyntaxValidatorInput { @@ -68,7 +68,7 @@ export function validateSyntax(files: SyntaxValidatorInput[]): SyntaxValidatorRe const sidecar = resolveDedupSidecar('syntax-validator.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/core/src/generated/blocks/task-classifier.ts b/packages/core/src/generated/blocks/task-classifier.ts index 86c0cedce..1e3829dda 100644 --- a/packages/core/src/generated/blocks/task-classifier.ts +++ b/packages/core/src/generated/blocks/task-classifier.ts @@ -4,7 +4,7 @@ import type { TaskClass } from '../models/types.js'; import { spawnSync } from 'node:child_process'; -import { resolveDedupSidecar } from './dedup-resolver.js'; +import { resolveDedupSidecar, resolveSidecarPython } from './dedup-resolver.js'; // @kern-source: task-classifier:8 export function classifyTaskRegex(description: string): TaskClass { @@ -50,7 +50,7 @@ export function classifyTaskSemantic(description: string): TaskClass | null { const sidecar = resolveDedupSidecar('classifier.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/core/src/generated/rag/embed.ts b/packages/core/src/generated/rag/embed.ts index d40aebb84..63d5678ec 100644 --- a/packages/core/src/generated/rag/embed.ts +++ b/packages/core/src/generated/rag/embed.ts @@ -1,8 +1,8 @@ -// @generated by kern v4.0.0 — DO NOT EDIT. Source: src/kern/rag/embed.kern +// @generated by kern v4.5.0 — DO NOT EDIT. Source: src/kern/rag/embed.kern import { spawnSync } from 'node:child_process'; -import { resolveDedupSidecar } from '../blocks/dedup-resolver.js'; +import { resolveDedupSidecar, resolveSidecarPython } from '../blocks/dedup-resolver.js'; /** * Generous: the FIRST run downloads the ~30MB MiniLM model into ~/.agon/cache/fastembed. Warm runs take seconds. Override with AGON_RAG_EMBED_TIMEOUT_MS. @@ -31,7 +31,7 @@ export function embedTexts(texts: string[]): RagEmbedResult | null { if (texts.length === 0) return null; const sidecar = resolveDedupSidecar('embedder.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); const timeout = Number(process.env.AGON_RAG_EMBED_TIMEOUT_MS) || RAG_EMBED_TIMEOUT_MS; const input = texts.map((text, i) => JSON.stringify({ id: String(i), text })).join('\n') + '\n'; let result; diff --git a/packages/core/src/generated/sessions/history-search-bridge.ts b/packages/core/src/generated/sessions/history-search-bridge.ts index c0c80db9e..25a37270d 100644 --- a/packages/core/src/generated/sessions/history-search-bridge.ts +++ b/packages/core/src/generated/sessions/history-search-bridge.ts @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; -import { resolveDedupSidecar } from '../blocks/dedup-resolver.js'; +import { resolveDedupSidecar, resolveSidecarPython } from '../blocks/dedup-resolver.js'; // @kern-source: history-search-bridge:8 export interface HistorySearchItem { @@ -42,7 +42,7 @@ export function searchHistorySemantic(query: string, items: HistorySearchItem[], const sidecar = resolveDedupSidecar('history-search.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2585d59d0..b7b44732c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ export { validateSyntax, detectLanguageFromPath, SYNTAX_VALIDATOR_TIMEOUT_MS, SYNTAX_VALIDATOR_DISABLE_ENV, } from './syntax-validator.js'; -export { resolveDedupSidecar } from './generated/blocks/dedup-resolver.js'; +export { resolveDedupSidecar, resolveSidecarPython } from './generated/blocks/dedup-resolver.js'; export { createRunDir, writeRunStatus, printRunSummary, findLatestRunDir, sanitizeRunLabel, diff --git a/packages/core/src/kern/blocks/dedup-resolver.kern b/packages/core/src/kern/blocks/dedup-resolver.kern index 315b85590..f0e57e3fe 100644 --- a/packages/core/src/kern/blocks/dedup-resolver.kern +++ b/packages/core/src/kern/blocks/dedup-resolver.kern @@ -16,6 +16,17 @@ import from="node:module" names="createRequire" import from="node:path" names="dirname,resolve" import from="node:url" names="fileURLToPath" import from="node:fs" names="existsSync" +import from="../signals/config.js" names="agonPath" + +fn name=resolveSidecarPython params="" returns=string export=true + doc "Resolve the Python interpreter for optional sidecars: explicit AGON_PYTHON, then Agon managed virtualenv, then python3." + handler <<< + if (process.env.AGON_PYTHON) return process.env.AGON_PYTHON; + const managed = process.platform === 'win32' + ? agonPath('python-sidecar', 'Scripts', 'python.exe') + : agonPath('python-sidecar', 'bin', 'python'); + return existsSync(managed) ? managed : 'python3'; + >>> fn name=resolveDedupSidecar params="filename:string" returns="string | null" doc "Return the absolute path of a Python sidecar shipped in @kernlang/agon-dedup, or null if not found. `filename` is the bare filename (e.g. 'history-search.py'), not a path." diff --git a/packages/core/src/kern/blocks/syntax-validator-bridge.kern b/packages/core/src/kern/blocks/syntax-validator-bridge.kern index 335f775cf..d2407d046 100644 --- a/packages/core/src/kern/blocks/syntax-validator-bridge.kern +++ b/packages/core/src/kern/blocks/syntax-validator-bridge.kern @@ -5,7 +5,7 @@ import from="node:child_process" names="spawnSync" import from="node:path" names="extname" -import from="./dedup-resolver.js" names="resolveDedupSidecar" +import from="./dedup-resolver.js" names="resolveDedupSidecar,resolveSidecarPython" interface name=SyntaxValidatorInput field name=path type=string @@ -51,7 +51,7 @@ fn name=validateSyntax params="files:SyntaxValidatorInput[]" returns="SyntaxVali const sidecar = resolveDedupSidecar('syntax-validator.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/core/src/kern/blocks/task-classifier.kern b/packages/core/src/kern/blocks/task-classifier.kern index aa95d63d9..ebdf6ae69 100644 --- a/packages/core/src/kern/blocks/task-classifier.kern +++ b/packages/core/src/kern/blocks/task-classifier.kern @@ -1,6 +1,6 @@ import from="../models/types.js" names="TaskClass" types=true import from="node:child_process" names="spawnSync" -import from="./dedup-resolver.js" names="resolveDedupSidecar" +import from="./dedup-resolver.js" names="resolveDedupSidecar,resolveSidecarPython" // Regex fast-path classifier — instant, but falls through to 'other' // often. The semantic escalation below catches those cases via a Python @@ -43,7 +43,7 @@ fn name=classifyTaskSemantic params="description:string" returns="TaskClass | nu const sidecar = resolveDedupSidecar('classifier.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/core/src/kern/rag/embed.kern b/packages/core/src/kern/rag/embed.kern index 555bf1e12..861b6f91e 100644 --- a/packages/core/src/kern/rag/embed.kern +++ b/packages/core/src/kern/rag/embed.kern @@ -4,7 +4,7 @@ // L2-normalized, so cosine similarity is a plain dot product downstream. import from="node:child_process" names="spawnSync" -import from="../blocks/dedup-resolver.js" names="resolveDedupSidecar" +import from="../blocks/dedup-resolver.js" names="resolveDedupSidecar,resolveSidecarPython" const name=RAG_EMBED_TIMEOUT_MS type=number value={{ 180000 }} doc "Generous: the FIRST run downloads the ~30MB MiniLM model into ~/.agon/cache/fastembed. Warm runs take seconds. Override with AGON_RAG_EMBED_TIMEOUT_MS." @@ -24,7 +24,7 @@ fn name=embedTexts params="texts:string[]" returns="RagEmbedResult | null" expor if (texts.length === 0) return null; const sidecar = resolveDedupSidecar('embedder.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); const timeout = Number(process.env.AGON_RAG_EMBED_TIMEOUT_MS) || RAG_EMBED_TIMEOUT_MS; const input = texts.map((text, i) => JSON.stringify({ id: String(i), text })).join('\n') + '\n'; let result; diff --git a/packages/core/src/kern/sessions/history-search-bridge.kern b/packages/core/src/kern/sessions/history-search-bridge.kern index e56b8a1e2..7ccdf635e 100644 --- a/packages/core/src/kern/sessions/history-search-bridge.kern +++ b/packages/core/src/kern/sessions/history-search-bridge.kern @@ -3,7 +3,7 @@ // degrade gracefully to chronological / substring fallback. import from="node:child_process" names="spawnSync" -import from="../blocks/dedup-resolver.js" names="resolveDedupSidecar" +import from="../blocks/dedup-resolver.js" names="resolveDedupSidecar,resolveSidecarPython" interface name=HistorySearchItem field name=id type=string @@ -31,7 +31,7 @@ fn name=searchHistorySemantic params="query:string, items:HistorySearchItem[], t const sidecar = resolveDedupSidecar('history-search.py'); if (!sidecar) return null; - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); let result; try { diff --git a/packages/dedup/install-python.mjs b/packages/dedup/install-python.mjs new file mode 100644 index 000000000..2a36f908f --- /dev/null +++ b/packages/dedup/install-python.mjs @@ -0,0 +1,28 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const agonHome = process.env.AGON_HOME || join(homedir(), '.agon'); +const venvDir = join(agonHome, 'python-sidecar'); +const managedPython = process.platform === 'win32' + ? join(venvDir, 'Scripts', 'python.exe') + : join(venvDir, 'bin', 'python'); +const requirements = join(dirname(fileURLToPath(import.meta.url)), 'requirements.txt'); + +mkdirSync(agonHome, { recursive: true }); +if (!existsSync(managedPython)) { + const configuredBootstrap = process.env.AGON_BOOTSTRAP_PYTHON; + const bootstrapCommand = configuredBootstrap || (process.platform === 'win32' ? 'py' : 'python3'); + const bootstrapArgs = !configuredBootstrap && process.platform === 'win32' ? ['-3', '-m', 'venv', venvDir] : ['-m', 'venv', venvDir]; + const create = spawnSync(bootstrapCommand, bootstrapArgs, { stdio: 'inherit' }); + if (create.error) console.error(`[agon] could not start Python bootstrap ${bootstrapCommand}: ${create.error.message}`); + if (create.status !== 0) process.exit(create.status ?? 1); +} + +const install = spawnSync(managedPython, ['-m', 'pip', 'install', '-r', requirements], { stdio: 'inherit' }); +if (install.error) console.error(`[agon] could not start managed Python ${managedPython}: ${install.error.message}`); +if (install.status !== 0 && !install.error) console.error('[agon] Python sidecar dependency installation failed.'); +process.exit(install.status ?? 1); diff --git a/packages/dedup/package.json b/packages/dedup/package.json index 1af8de13a..2a5bea2a6 100644 --- a/packages/dedup/package.json +++ b/packages/dedup/package.json @@ -5,6 +5,7 @@ "type": "module", "files": [ "*.py", + "install-python.mjs", "requirements.txt", "README.md", "LICENSE" @@ -13,7 +14,7 @@ "access": "public" }, "scripts": { - "install:python": "python3 -m pip install --user -r requirements.txt", + "install:python": "node install-python.mjs", "test:sidecar": "node tests/smoke.mjs", "test:classifier": "node tests/classifier-smoke.mjs", "test:history-search": "node tests/history-search-smoke.mjs", diff --git a/packages/forge/src/generated/dedup-bridge.ts b/packages/forge/src/generated/dedup-bridge.ts index 565f321b2..9fac35f46 100644 --- a/packages/forge/src/generated/dedup-bridge.ts +++ b/packages/forge/src/generated/dedup-bridge.ts @@ -6,7 +6,7 @@ import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import type { BrainstormGroup, BrainstormDedupStatus } from '@kernlang/agon-core'; -import { resolveDedupSidecar } from '@kernlang/agon-core'; +import { resolveDedupSidecar, resolveSidecarPython } from '@kernlang/agon-core'; /** * Cluster paraphrased drafts via the optional Python embedding sidecar. Always returns an explicit status and bounds the sidecar wall clock; abort still rejects so user cancellation propagates. @@ -29,7 +29,7 @@ export async function dedupBrainstormDrafts(drafts: {engineId:string, text:strin return { groups: null, status: { status: 'unavailable', detail: 'dedup sidecar not installed' } }; } - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); const timeoutMs = Math.max(1, opts?.timeoutMs ?? 5_000); return await new Promise<{groups:BrainstormGroup[] | null, status:BrainstormDedupStatus}>((resolveOuter, rejectOuter) => { diff --git a/packages/forge/src/kern/dedup-bridge.kern b/packages/forge/src/kern/dedup-bridge.kern index 7a9186864..d355ce9f4 100644 --- a/packages/forge/src/kern/dedup-bridge.kern +++ b/packages/forge/src/kern/dedup-bridge.kern @@ -5,7 +5,7 @@ import from="node:child_process" names="spawn" import from="node:child_process" names="ChildProcessWithoutNullStreams" types=true import from="@kernlang/agon-core" names="BrainstormGroup,BrainstormDedupStatus" types=true -import from="@kernlang/agon-core" names="resolveDedupSidecar" +import from="@kernlang/agon-core" names="resolveDedupSidecar,resolveSidecarPython" fn name=dedupBrainstormDrafts async=true params="drafts:{engineId:string, text:string}[], opts?:{timeoutMs?:number, signal?:AbortSignal}" returns="Promise<{groups:BrainstormGroup[] | null, status:BrainstormDedupStatus}>" doc "Cluster paraphrased drafts via the optional Python embedding sidecar. Always returns an explicit status and bounds the sidecar wall clock; abort still rejects so user cancellation propagates." @@ -26,7 +26,7 @@ fn name=dedupBrainstormDrafts async=true params="drafts:{engineId:string, text:s return { groups: null, status: { status: 'unavailable', detail: 'dedup sidecar not installed' } }; } - const python = process.env.AGON_PYTHON || 'python3'; + const python = resolveSidecarPython(); const timeoutMs = Math.max(1, opts?.timeoutMs ?? 5_000); return await new Promise<{groups:BrainstormGroup[] | null, status:BrainstormDedupStatus}>((resolveOuter, rejectOuter) => { diff --git a/tests/unit/dedup-python-resolver.test.ts b/tests/unit/dedup-python-resolver.test.ts new file mode 100644 index 000000000..211876477 --- /dev/null +++ b/tests/unit/dedup-python-resolver.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveSidecarPython } from '../../packages/core/src/generated/blocks/dedup-resolver.js'; +import { EXPECTED_SIDECARS, formatPythonSidecarInstallCommand } from '../../packages/cli/src/generated/commands/doctor.js'; +import { cleanupTestAgonHome, setupTestAgonHome } from '../helpers/agon-home.js'; + +let agonHome: string; +const originalPython = process.env.AGON_PYTHON; + +beforeEach(() => { + agonHome = setupTestAgonHome('dedup-python-resolver'); + delete process.env.AGON_PYTHON; +}); + +afterEach(() => { + if (originalPython === undefined) delete process.env.AGON_PYTHON; + else process.env.AGON_PYTHON = originalPython; + cleanupTestAgonHome(agonHome); +}); + +describe('resolveSidecarPython', () => { + it('prefers an explicit AGON_PYTHON override', () => { + process.env.AGON_PYTHON = '/explicit/python'; + expect(resolveSidecarPython()).toBe('/explicit/python'); + }); + + it('discovers the managed Agon virtualenv before system python3', () => { + const managed = process.platform === 'win32' + ? join(agonHome, 'python-sidecar', 'Scripts', 'python.exe') + : join(agonHome, 'python-sidecar', 'bin', 'python'); + mkdirSync(join(managed, '..'), { recursive: true }); + writeFileSync(managed, ''); + expect(resolveSidecarPython()).toBe(managed); + }); + + it('falls back to python3 when no override or managed environment exists', () => { + expect(resolveSidecarPython()).toBe('python3'); + }); +}); + +describe('Python sidecar packaging and repair', () => { + it('checks the RAG embedder and publishes the managed installer', () => { + expect(EXPECTED_SIDECARS).toContain('embedder.py'); + const packageJson = JSON.parse(readFileSync(new URL('../../packages/dedup/package.json', import.meta.url), 'utf8')); + expect(packageJson.files).toContain('install-python.mjs'); + }); + + it('returns an executable cwd-independent installer command', () => { + expect(formatPythonSidecarInstallCommand('/tmp/path with spaces/install-python.mjs')) + .toBe("node '/tmp/path with spaces/install-python.mjs'"); + expect(formatPythonSidecarInstallCommand("/tmp/it's/$(unsafe)/`unsafe`/install-python.mjs")) + .toBe("node '/tmp/it'\\''s/$(unsafe)/`unsafe`/install-python.mjs'"); + expect(formatPythonSidecarInstallCommand('/tmp/line\nbreak/install-python.mjs')) + .toBe("node '/tmp/line\nbreak/install-python.mjs'"); + }); + + it('omits the repair command when the installer is unavailable', () => { + expect(formatPythonSidecarInstallCommand(null)).toBe(''); + }); + + it('reports a missing Python bootstrap command', () => { + const installer = fileURLToPath(new URL('../../packages/dedup/install-python.mjs', import.meta.url)); + const result = spawnSync(process.execPath, [installer], { + encoding: 'utf8', + env: { + ...process.env, + AGON_HOME: join(agonHome, 'isolated-installer'), + AGON_BOOTSTRAP_PYTHON: join(agonHome, 'missing-python'), + }, + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('[agon] could not start Python bootstrap'); + }); +}); From b0ab731a95fe41bcd59bec2ab9e6734ef675fd35 Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Fri, 7 Aug 2026 16:47:25 +0200 Subject: [PATCH 2/3] fix(core): reject companion writes outside agent mode --- .../generated/sessions/companion-dispatch.ts | 29 ++++--- .../src/kern/sessions/companion-dispatch.kern | 29 ++++--- tests/unit/companion-dispatch.test.ts | 78 +++++++++++++++++++ 3 files changed, 116 insertions(+), 20 deletions(-) diff --git a/packages/core/src/generated/sessions/companion-dispatch.ts b/packages/core/src/generated/sessions/companion-dispatch.ts index e963337a5..0553a6ef1 100644 --- a/packages/core/src/generated/sessions/companion-dispatch.ts +++ b/packages/core/src/generated/sessions/companion-dispatch.ts @@ -162,20 +162,27 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat const alwaysOpt = options.find((o: any) => o.kind === 'allow_always' || o.optionId?.includes('proceed_always')); const rejectOpt = options.find((o: any) => o.kind === 'reject_once') ?? options.find((o: any) => o.optionId?.includes('reject')); - if (opts.onApproval) { + const respondWithOption = (option: any) => { + if (option?.optionId) { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId: option.optionId } }) + '\n'); + } else { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: 'Permission denied' } }) + '\n'); + } + }; + + if (opts.mode !== 'agent') { + respondWithOption(rejectOpt); + } else if (opts.onApproval) { opts.onApproval(String(tName), String(tCmd), 'Agent tool approval requested').then((result: boolean | string) => { const approved = typeof result === 'string' ? false : result; - const optionId = approved - ? (allowOpt?.optionId ?? alwaysOpt?.optionId ?? 'proceed_once') - : (rejectOpt?.optionId ?? 'reject'); - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + const option = approved ? (allowOpt ?? alwaysOpt) : rejectOpt; + respondWithOption(option); }).catch(() => { - const optionId = rejectOpt?.optionId ?? 'reject'; - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + respondWithOption(rejectOpt); }); } else { - const optionId = allowOpt?.optionId ?? alwaysOpt?.optionId ?? 'proceed_once'; - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + const option = opts.mode === 'agent' ? (allowOpt ?? alwaysOpt) : rejectOpt; + respondWithOption(option); } return; } @@ -190,7 +197,9 @@ export async function companionDispatch(opts: {config:CompanionConfig, binaryPat const isV2 = m.startsWith('item/'); const buildResult = (approved: boolean) => isV2 ? { decision: approved ? 'accept' : 'decline' } : { approved }; - if (opts.onApproval) { + if (opts.mode !== 'agent') { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: buildResult(false) }) + '\n'); + } else if (opts.onApproval) { opts.onApproval(String(toolName), String(toolCmd), 'Agent tool approval requested').then((result: boolean | string) => { if (typeof result === 'string') { fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: result } }) + '\n'); diff --git a/packages/core/src/kern/sessions/companion-dispatch.kern b/packages/core/src/kern/sessions/companion-dispatch.kern index 1c9724b2c..49913ce14 100644 --- a/packages/core/src/kern/sessions/companion-dispatch.kern +++ b/packages/core/src/kern/sessions/companion-dispatch.kern @@ -154,20 +154,27 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar const alwaysOpt = options.find((o: any) => o.kind === 'allow_always' || o.optionId?.includes('proceed_always')); const rejectOpt = options.find((o: any) => o.kind === 'reject_once') ?? options.find((o: any) => o.optionId?.includes('reject')); - if (opts.onApproval) { + const respondWithOption = (option: any) => { + if (option?.optionId) { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId: option.optionId } }) + '\n'); + } else { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: 'Permission denied' } }) + '\n'); + } + }; + + if (opts.mode !== 'agent') { + respondWithOption(rejectOpt); + } else if (opts.onApproval) { opts.onApproval(String(tName), String(tCmd), 'Agent tool approval requested').then((result: boolean | string) => { const approved = typeof result === 'string' ? false : result; - const optionId = approved - ? (allowOpt?.optionId ?? alwaysOpt?.optionId ?? 'proceed_once') - : (rejectOpt?.optionId ?? 'reject'); - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + const option = approved ? (allowOpt ?? alwaysOpt) : rejectOpt; + respondWithOption(option); }).catch(() => { - const optionId = rejectOpt?.optionId ?? 'reject'; - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + respondWithOption(rejectOpt); }); } else { - const optionId = allowOpt?.optionId ?? alwaysOpt?.optionId ?? 'proceed_once'; - fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: { optionId } }) + '\n'); + const option = opts.mode === 'agent' ? (allowOpt ?? alwaysOpt) : rejectOpt; + respondWithOption(option); } return; } @@ -182,7 +189,9 @@ fn name=companionDispatch async=true params="opts:{config:CompanionConfig, binar const isV2 = m.startsWith('item/'); const buildResult = (approved: boolean) => isV2 ? { decision: approved ? 'accept' : 'decline' } : { approved }; - if (opts.onApproval) { + if (opts.mode !== 'agent') { + fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: buildResult(false) }) + '\n'); + } else if (opts.onApproval) { opts.onApproval(String(toolName), String(toolCmd), 'Agent tool approval requested').then((result: boolean | string) => { if (typeof result === 'string') { fireWriteStdin(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: { code: -32001, message: result } }) + '\n'); diff --git a/tests/unit/companion-dispatch.test.ts b/tests/unit/companion-dispatch.test.ts index 972deb8d7..18c089480 100644 --- a/tests/unit/companion-dispatch.test.ts +++ b/tests/unit/companion-dispatch.test.ts @@ -83,4 +83,82 @@ describe('companionDispatch', () => { // into two '\n\n'-joined paragraphs. expect(result.stdout).toBe("I'll start by\n\n Done."); }); + + it.each([ + { mode: 'exec', options: "[{ optionId: 'allow', kind: 'allow_once' }, { optionId: 'deny', kind: 'reject_once' }]", approve: false, expected: 'deny' }, + { mode: 'review', options: "[{ optionId: 'allow', kind: 'allow_once' }, { optionId: 'deny', kind: 'reject_once' }]", approve: false, expected: 'deny' }, + { mode: 'exec', options: "[{ optionId: 'allow', kind: 'allow_once' }]", approve: false, expected: 'error' }, + { mode: 'exec', options: "[{ optionId: 'allow', kind: 'allow_once' }, { optionId: 'deny', kind: 'reject_once' }]", approve: true, expected: 'deny' }, + { mode: 'agent', options: "[{ optionId: 'allow', kind: 'allow_once' }, { optionId: 'deny', kind: 'reject_once' }]", approve: true, expected: 'allow' }, + ] as const)('enforces ACP write policy in $mode mode', async ({ mode, options, approve, expected }) => { + const script = [ + "const rl = require('node:readline').createInterface({ input: process.stdin });", + "const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n');", + 'let promptId = 0;', + "rl.on('line', (line) => {", + ' const msg = JSON.parse(line);', + " if (msg.method === 'initialize') w({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1 } });", + " if (msg.method === 'session/new') w({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 's1' } });", + " if (msg.method === 'session/prompt') {", + ' promptId = msg.id;', + ` w({ jsonrpc: '2.0', id: 99, method: 'session/request_permission', params: { options: ${options}, toolCall: { name: 'write_file', args: { file_path: 'unsafe.txt' } } } });`, + ' }', + ' if (msg.id === 99 && !msg.method) {', + " const text = msg.error ? 'error' : msg.result.optionId;", + " w({ jsonrpc: '2.0', method: 'session/update', params: { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } } });", + " w({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } });", + ' }', + '});', + ].join(''); + + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { + protocol: 'acp', + serverCmd: ['-e', script], + }, + prompt: 'do not write', + cwd: process.cwd(), + timeout: 5, + mode, + onApproval: approve ? async () => true : undefined, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(expected); + }); + + it('rejects non-agent approval callbacks for vendor approval requests', async () => { + const script = [ + "const rl = require('node:readline').createInterface({ input: process.stdin });", + "const w = (o) => process.stdout.write(JSON.stringify(o) + '\\n');", + 'let promptId = 0;', + "rl.on('line', (line) => {", + ' const msg = JSON.parse(line);', + " if (msg.method === 'initialize') w({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1 } });", + " if (msg.method === 'session/new') w({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 's1' } });", + " if (msg.method === 'session/prompt') {", + ' promptId = msg.id;', + " w({ jsonrpc: '2.0', id: 99, method: 'item/fileChange/requestApproval', params: { path: 'unsafe.txt' } });", + ' }', + " if (msg.id === 99 && !msg.method) {", + " w({ jsonrpc: '2.0', method: 'session/update', params: { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: msg.result.decision } } } });", + " w({ jsonrpc: '2.0', id: promptId, result: { stopReason: 'end_turn' } });", + ' }', + '});', + ].join(''); + + const result = await companionDispatch({ + binaryPath: process.execPath, + config: { protocol: 'acp', serverCmd: ['-e', script] }, + prompt: 'do not write', + cwd: process.cwd(), + timeout: 5, + mode: 'review', + onApproval: async () => true, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe('decline'); + }); }); From acd1f5c90c84f376c64395f37a91c1ce0b7dc8e5 Mon Sep 17 00:00:00 2001 From: Raphael Antonietti Date: Fri, 7 Aug 2026 16:47:44 +0200 Subject: [PATCH 3/3] fix(forge): correct team prompt and tribunal routing --- .../forge/src/generated/team-brainstorm.ts | 8 +-- packages/forge/src/generated/team-forge.ts | 4 +- packages/forge/src/generated/team-tribunal.ts | 37 +++++------ packages/forge/src/kern/team-brainstorm.kern | 8 +-- packages/forge/src/kern/team-forge.kern | 4 +- packages/forge/src/kern/team-tribunal.kern | 35 +++++----- tests/integration/forge-e2e.test.ts | 13 +++- tests/unit/team-prompt-routing.test.ts | 66 +++++++++++++++++++ 8 files changed, 123 insertions(+), 52 deletions(-) create mode 100644 tests/unit/team-prompt-routing.test.ts diff --git a/packages/forge/src/generated/team-brainstorm.ts b/packages/forge/src/generated/team-brainstorm.ts index 3fb2d180c..0c2997783 100644 --- a/packages/forge/src/generated/team-brainstorm.ts +++ b/packages/forge/src/generated/team-brainstorm.ts @@ -59,7 +59,7 @@ export async function runTeamCoopBrainstorm(team: TeamSpec, question: string, co engine, prompt: draftPrompt, systemPrompt: 'Respond directly with your brainstorm draft. Do not use tools, read files, or run commands.', - cwd: process.cwd(), + cwd: outputDir, mode: 'exec', timeout, outputDir, @@ -131,7 +131,7 @@ export async function runTeamCoopBrainstorm(team: TeamSpec, question: string, co engine: synthEngine, prompt: synthPrompt, systemPrompt: 'Synthesize the team proposal as plain text. Do not use tools, read files, or run commands.', - cwd: process.cwd(), + cwd: outputDir, mode: 'exec', timeout, outputDir, @@ -251,8 +251,8 @@ export async function runTeamBrainstorm(options: TeamBrainstormOptions): Promise engine: judgeEngine, prompt: judgePrompt, systemPrompt: 'Judge the proposals only. Do not use tools, read files, or run commands.', - cwd: process.cwd(), - mode: 'review', + cwd: options.outputDir, + mode: 'exec', timeout: options.timeout, outputDir: options.outputDir, signal: options.signal, diff --git a/packages/forge/src/generated/team-forge.ts b/packages/forge/src/generated/team-forge.ts index 9fcd9dc58..fe1260e65 100644 --- a/packages/forge/src/generated/team-forge.ts +++ b/packages/forge/src/generated/team-forge.ts @@ -173,8 +173,8 @@ export async function runTeamCoopForge(team: TeamSpec, task: string, fitnessCmd: engine: planEngine, prompt: planPrompt, systemPrompt: 'Produce a plan only. Do not edit files, run tools, or execute commands.', - cwd, - mode: 'review', + cwd: forgeDir, + mode: 'exec', timeout, outputDir: forgeDir, signal, diff --git a/packages/forge/src/generated/team-tribunal.ts b/packages/forge/src/generated/team-tribunal.ts index 1c8032078..5bdcda26c 100644 --- a/packages/forge/src/generated/team-tribunal.ts +++ b/packages/forge/src/generated/team-tribunal.ts @@ -57,8 +57,9 @@ export async function runTeamCoopTribunal(team: TeamSpec, position: string, ques const stratResult = await adapter.dispatch({ engine: registry.get(architect.engineId), prompt: strategyPrompt, - cwd: process.cwd(), - mode: 'review', + systemPrompt: 'Return text only. Do not use tools, read files, or modify the workspace.', + cwd: outputDir, + mode: 'exec', timeout, outputDir, signal, @@ -79,8 +80,9 @@ export async function runTeamCoopTribunal(team: TeamSpec, position: string, ques const supportResult = await adapter.dispatch({ engine: registry.get(impl.engineId), prompt: supportPrompt, - cwd: process.cwd(), - mode: 'review', + systemPrompt: 'Return text only. Do not use tools, read files, or modify the workspace.', + cwd: outputDir, + mode: 'exec', timeout, outputDir, signal, @@ -104,8 +106,9 @@ export async function runTeamCoopTribunal(team: TeamSpec, position: string, ques const synthResult = await adapter.dispatch({ engine: registry.get(synthesizer.engineId), prompt: synthPrompt, - cwd: process.cwd(), - mode: 'review', + systemPrompt: 'Return text only. Do not use tools, read files, or modify the workspace.', + cwd: outputDir, + mode: 'exec', timeout, outputDir, signal, @@ -133,7 +136,7 @@ export async function runTeamCoopTribunal(team: TeamSpec, position: string, ques }; } -// @kern-source: team-tribunal:130 +// @kern-source: team-tribunal:133 export async function runTeamTribunal(options: TeamTribunalOptions): Promise { const config = loadConfig(process.cwd()); const matchId = randomUUID().slice(0, 8); @@ -170,11 +173,9 @@ export async function runTeamTribunal(options: TeamTribunalOptions): Promise id !== cesarId); - let cesarCompeting = false; if (competitors.length < 2) { // Not enough competitors without Cesar — add all back competitors = [...available]; - cesarCompeting = true; } const [teamA, teamB] = composeTeams( @@ -208,14 +209,11 @@ export async function runTeamTribunal(options: TeamTribunalOptions): Promise m.engineId), ...teamB.members.map((m: any) => m.engineId)]); - let judgeId = cesarId; - if (cesarCompeting || teamMemberIds.has(cesarId)) { - const altJudge = available.find((id: string) => !teamMemberIds.has(id)); - if (altJudge) { - judgeId = altJudge; - } else { - sidechain.log('team-tribunal:judge-conflict', cesarId, { warning: 'Cesar judging own debate — not enough engines for impartial judge' }); - } + const impartialJudge = available.find((id: string) => id === cesarId && !teamMemberIds.has(id)) + ?? available.find((id: string) => !teamMemberIds.has(id)); + const judgeId = impartialJudge ?? (available.includes(cesarId) ? cesarId : available[0]); + if (!impartialJudge) { + sidechain.log('team-tribunal:judge-conflict', judgeId, { warning: 'Judge also competed — not enough engines for an impartial judge' }); } const judgeEngine = options.registry.get(judgeId); const judgePrompt = `## TRIBUNAL JUDGE\nYou are an impartial judge. Two teams debated the following question. Evaluate their arguments and declare a winner.\n\nQuestion: ${options.question}\n\n## TEAM ALPHA (${posA}):\n${resultA.arguments[resultA.arguments.length - 1]}\n\n## TEAM BETA (${posB}):\n${resultB.arguments[resultB.arguments.length - 1]}\n\nAnalyze each team's argument strengths and weaknesses.\nYou MUST end your response with exactly these two lines:\nSCORE_ALPHA: \nSCORE_BETA: \nThen declare: WINNER: "ALPHA" or "BETA" or "DRAW"`; @@ -223,8 +221,9 @@ export async function runTeamTribunal(options: TeamTribunalOptions): Promise id !== cesarId); - let cesarCompeting = false; if (competitors.length < 2) { // Not enough competitors without Cesar — add all back competitors = [...available]; - cesarCompeting = true; } const [teamA, teamB] = composeTeams( @@ -202,14 +203,11 @@ fn name=runTeamTribunal async=true params="options:TeamTribunalOptions" returns= // --- Pick impartial judge — must not be on either team --- const teamMemberIds = new Set([...teamA.members.map((m: any) => m.engineId), ...teamB.members.map((m: any) => m.engineId)]); - let judgeId = cesarId; - if (cesarCompeting || teamMemberIds.has(cesarId)) { - const altJudge = available.find((id: string) => !teamMemberIds.has(id)); - if (altJudge) { - judgeId = altJudge; - } else { - sidechain.log('team-tribunal:judge-conflict', cesarId, { warning: 'Cesar judging own debate — not enough engines for impartial judge' }); - } + const impartialJudge = available.find((id: string) => id === cesarId && !teamMemberIds.has(id)) + ?? available.find((id: string) => !teamMemberIds.has(id)); + const judgeId = impartialJudge ?? (available.includes(cesarId) ? cesarId : available[0]); + if (!impartialJudge) { + sidechain.log('team-tribunal:judge-conflict', judgeId, { warning: 'Judge also competed — not enough engines for an impartial judge' }); } const judgeEngine = options.registry.get(judgeId); const judgePrompt = `## TRIBUNAL JUDGE\nYou are an impartial judge. Two teams debated the following question. Evaluate their arguments and declare a winner.\n\nQuestion: ${options.question}\n\n## TEAM ALPHA (${posA}):\n${resultA.arguments[resultA.arguments.length - 1]}\n\n## TEAM BETA (${posB}):\n${resultB.arguments[resultB.arguments.length - 1]}\n\nAnalyze each team's argument strengths and weaknesses.\nYou MUST end your response with exactly these two lines:\nSCORE_ALPHA: \nSCORE_BETA: \nThen declare: WINNER: "ALPHA" or "BETA" or "DRAW"`; @@ -217,8 +215,9 @@ fn name=runTeamTribunal async=true params="options:TeamTribunalOptions" returns= const judgeResult = await options.adapter.dispatch({ engine: judgeEngine, prompt: judgePrompt, - cwd: process.cwd(), - mode: 'review', + systemPrompt: 'Return text only. Do not use tools, read files, or modify the workspace.', + cwd: options.outputDir, + mode: 'exec', timeout: options.timeout, outputDir: options.outputDir, signal: options.signal, diff --git a/tests/integration/forge-e2e.test.ts b/tests/integration/forge-e2e.test.ts index 3ae6137c7..7d69ecf83 100644 --- a/tests/integration/forge-e2e.test.ts +++ b/tests/integration/forge-e2e.test.ts @@ -376,6 +376,9 @@ describe('Forge E2E', () => { const events: any[] = []; const adapter: EngineAdapter = { dispatch: async (options: DispatchOptions): Promise => { + if (options.prompt.includes('YOUR ROLE: ARCHITECT')) { + return { exitCode: 0, stdout: 'Plan: write team.ts', stderr: '', durationMs: 1, timedOut: false }; + } if (options.mode === 'review') { return { exitCode: 0, stdout: 'No obvious issues.', stderr: '', durationMs: 1, timedOut: false }; } @@ -910,6 +913,9 @@ describe('Forge E2E', () => { const adapter: EngineAdapter = { dispatch: async (options: DispatchOptions): Promise => { + if (options.prompt.includes('YOUR ROLE: ARCHITECT')) { + return { exitCode: 0, stdout: 'Plan: write team.ts', stderr: '', durationMs: 1, timedOut: false }; + } if (options.mode === 'review') { return { exitCode: 0, stdout: 'APPROVED', stderr: '', durationMs: 1, timedOut: false }; } @@ -966,8 +972,8 @@ describe('Forge E2E', () => { process.env.AGON_TEST_FORGE_API_KEY = 'test-key'; const dispatch = vi.fn(async (options: DispatchOptions): Promise => { - if (options.mode !== 'review') { - throw new Error('plain dispatch must not implement API-only team forge work'); + if (options.mode !== 'exec') { + throw new Error('team architect planning must use plain exec dispatch'); } return { exitCode: 0, stdout: 'Plan: write team.ts', stderr: '', durationMs: 1, timedOut: false }; }); @@ -1016,7 +1022,8 @@ describe('Forge E2E', () => { }, registry, adapter); expect(dispatch).toHaveBeenCalled(); - expect(dispatch.mock.calls.every(([options]) => options.mode === 'review')).toBe(true); + expect(dispatch.mock.calls.every(([options]) => options.mode === 'exec')).toBe(true); + expect(dispatch.mock.calls.every(([options]) => options.cwd === forgeDir)).toBe(true); expect(dispatchAgent).toHaveBeenCalledTimes(2); expect(result.winnerTeamId).toBeTruthy(); const winnerOutput = result.submissions[result.winnerTeamId!].finalOutput as any; diff --git a/tests/unit/team-prompt-routing.test.ts b/tests/unit/team-prompt-routing.test.ts new file mode 100644 index 000000000..e6d33e5a2 --- /dev/null +++ b/tests/unit/team-prompt-routing.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EngineRegistry } from '../../packages/core/src/index.js'; +import { runTeamBrainstorm, runTeamTribunal } from '../../packages/forge/src/index.js'; +import { cleanupTestAgonHome, setupTestAgonHome } from '../helpers/agon-home.js'; + +let agonHome: string; +beforeEach(() => { agonHome = setupTestAgonHome('team-prompt-routing'); }); +afterEach(() => { cleanupTestAgonHome(agonHome); }); + +function registryWithTwoEngines(): EngineRegistry { + const registry = new EngineRegistry(); + for (const id of ['e1', 'e2']) { + registry.register({ + schemaVersion: 3, id, displayName: id, binary: process.execPath, + exec: { args: ['{prompt}'] }, review: { args: ['{prompt}'] }, + } as any); + } + return registry; +} + +function recordingAdapter(modes: string[], systemPrompts: string[] = [], cwds: string[] = []) { + return { + dispatch: async (options: { mode: string; prompt: string; systemPrompt?: string; cwd: string }) => { + modes.push(options.mode); + systemPrompts.push(options.systemPrompt ?? ''); + cwds.push(options.cwd); + const judge = options.prompt.includes('JUDGE'); + return { + exitCode: 0, timedOut: false, stderr: '', durationMs: 1, + stdout: judge ? 'SCORE_ALPHA: 70\nSCORE_BETA: 30\nWINNER: ALPHA' : 'Prompt-grounded team contribution', + }; + }, + } as any; +} + +describe('team prompt routing', () => { + it('uses exec for every team-tribunal strategy, synthesis, and judge prompt', async () => { + const modes: string[] = []; + const systemPrompts: string[] = []; + const cwds: string[] = []; + await runTeamTribunal({ + question: 'rollback?', membersPerSide: 1, rounds: 1, mode: 'red-team', + composeMode: 'explicit', explicitTeams: [['e1'], ['e2']], engines: ['e1', 'e2'], + registry: registryWithTwoEngines(), adapter: recordingAdapter(modes, systemPrompts, cwds), + timeout: 10, outputDir: agonHome, + } as any); + expect(modes.length).toBeGreaterThanOrEqual(5); + expect(new Set(modes)).toEqual(new Set(['exec'])); + expect(systemPrompts.every((prompt) => prompt.includes('Do not use tools'))).toBe(true); + expect(new Set(cwds)).toEqual(new Set([agonHome])); + }); + + it('uses exec for the team-brainstorm judge as well as draft and synthesis prompts', async () => { + const modes: string[] = []; + const cwds: string[] = []; + await runTeamBrainstorm({ + question: 'migration?', membersPerSide: 1, + composeMode: 'explicit', explicitTeams: [['e1'], ['e2']], engines: ['e1', 'e2'], + registry: registryWithTwoEngines(), adapter: recordingAdapter(modes, [], cwds), + timeout: 10, outputDir: agonHome, + } as any); + expect(modes.length).toBeGreaterThanOrEqual(5); + expect(new Set(modes)).toEqual(new Set(['exec'])); + expect(new Set(cwds)).toEqual(new Set([agonHome])); + }); +});