From ee68af188fd109c9495343c9d25b53f7036b3761 Mon Sep 17 00:00:00 2001 From: Erik Fehn Date: Thu, 2 Jul 2026 17:02:09 -0700 Subject: [PATCH 1/2] Harden OpenRouter path: configure maxInputTokens to trigger early summarization, disable subagents, and ignore openwiki.* directories --- .../operations/credentials-and-updates.md | 4 + src/agent/index.ts | 107 +++++++++++++++--- src/agent/prompt.ts | 32 ++++-- 3 files changed, 118 insertions(+), 25 deletions(-) diff --git a/openwiki/operations/credentials-and-updates.md b/openwiki/operations/credentials-and-updates.md index e67268b2..853c9b30 100644 --- a/openwiki/operations/credentials-and-updates.md +++ b/openwiki/operations/credentials-and-updates.md @@ -20,6 +20,10 @@ The file stores provider configuration and API keys: - `OPENWIKI_MODEL_ID` — the default model ID - Provider API keys: `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `BASETEN_API_KEY`, `FIREWORKS_API_KEY` - Optional LangSmith settings: `LANGSMITH_API_KEY`, `LANGCHAIN_PROJECT`, `LANGCHAIN_TRACING_V2` +- Run-hardening options: + - `OPENWIKI_DISABLE_MODEL_FALLBACK` — set to `1` to disable OpenRouter fallback routing + - `OPENWIKI_DISABLE_SUBAGENTS` — set to `1` to disable subagent task delegation entirely + - `OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS` — set a custom input token cap (suggested: `15000` for constrained models) for OpenRouter models to force early summarization and prevent HTTP 500 payload errors on large transcripts The loader merges those values into `process.env`, while preferring existing process-level values over file values. Deprecated keys (`OPENAI_BASE_URL`, `OPENAI_ORG_ID`, `OPENAI_PROJECT`) are skipped on load and removed on save. diff --git a/src/agent/index.ts b/src/agent/index.ts index 641ebcc0..6401cf28 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -5,7 +5,12 @@ import { ChatAnthropic } from "@langchain/anthropic"; import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; import { ChatOpenAI } from "@langchain/openai"; import { ChatOpenRouter } from "@langchain/openrouter"; -import { createDeepAgent, LocalShellBackend } from "deepagents"; +import { createMiddleware } from "langchain"; +import { + createDeepAgent, + LocalShellBackend, + registerHarnessProfile, +} from "deepagents"; import { loadOpenWikiEnv, openWikiEnvDir } from "../env.js"; import { createSystemPrompt, createUserPrompt } from "./prompt.js"; import type { @@ -164,6 +169,7 @@ async function runOpenWikiAgentCore( emitDebug(options, "openwiki.snapshot=created"); const model = await createModel(provider, modelId); emitDebug(options, `model.provider=${provider}`); + configureDeepAgentHarness(provider, modelId); if (provider === "openrouter") { emitDebug( options, @@ -180,6 +186,7 @@ async function runOpenWikiAgentCore( const agent = createDeepAgent({ model, tools: [], + middleware: createOpenWikiMiddleware(), checkpointer, backend: new LocalShellBackend({ maxOutputBytes: 100_000, @@ -378,16 +385,15 @@ function resolveModelId( } async function createModel(provider: OpenWikiProvider, modelId: string) { + let model; if (provider === "anthropic") { - return new ChatAnthropic(modelId, { + model = new ChatAnthropic(modelId, { apiKey: process.env[getProviderApiKeyEnvKey(provider)], }); - } - - if (provider === "openrouter") { + } else if (provider === "openrouter") { const models = createModelRoute(provider, modelId); - return new ChatOpenRouter({ + model = new ChatOpenRouter({ apiKey: process.env[OPENROUTER_API_KEY_ENV_KEY], baseURL: OPENROUTER_BASE_URL, model: modelId, @@ -395,32 +401,99 @@ async function createModel(provider: OpenWikiProvider, modelId: string) { route: "fallback", siteName: "OpenWiki", }); + } else { + const providerConfig = getProviderConfig(provider); + + model = new ChatOpenAI({ + apiKey: process.env[getProviderApiKeyEnvKey(provider)], + configuration: providerConfig.baseURL + ? { + baseURL: providerConfig.baseURL, + } + : undefined, + model: modelId, + }); } - const providerConfig = getProviderConfig(provider); + // Set the profile property to help limit transcript size when using OpenRouter (opt-in) + if ( + provider === "openrouter" && + process.env.OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS + ) { + const rawVal = process.env.OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS; + const parsedVal = parseInt(rawVal, 10); + if (Number.isFinite(parsedVal)) { + Object.defineProperty(model, "profile", { + value: { maxInputTokens: parsedVal }, + writable: true, + configurable: true, + enumerable: true, + }); + } + } - return new ChatOpenAI({ - apiKey: process.env[getProviderApiKeyEnvKey(provider)], - configuration: providerConfig.baseURL - ? { - baseURL: providerConfig.baseURL, - } - : undefined, - model: modelId, - }); + return model; } function createModelRoute( provider: OpenWikiProvider, modelId: string, ): string[] { - if (provider !== "openrouter") { + if (provider !== "openrouter" || isModelFallbackDisabled()) { return [modelId]; } return Array.from(new Set([modelId, ...OPENROUTER_FALLBACK_MODEL_IDS])); } +function isModelFallbackDisabled(): boolean { + return process.env.OPENWIKI_DISABLE_MODEL_FALLBACK === "1"; +} + +function configureDeepAgentHarness( + provider: OpenWikiProvider, + modelId: string, +): void { + if (!isSubagentDisabled()) { + return; + } + + const profile = { + excludedTools: ["task"], + generalPurposeSubagent: { + enabled: false, + }, + }; + + registerHarnessProfile(provider, profile); + + if (!modelId.includes(":")) { + registerHarnessProfile(`${provider}:${modelId}`, profile); + } +} + +function isSubagentDisabled(): boolean { + return process.env.OPENWIKI_DISABLE_SUBAGENTS === "1"; +} + +function createOpenWikiMiddleware() { + if (!isSubagentDisabled()) { + return []; + } + + return [ + createMiddleware({ + name: "OpenWikiToolExclusionMiddleware", + wrapModelCall: async (request, handler) => { + return handler({ + ...request, + tools: request.tools?.filter((tool) => tool.name !== "task"), + }); + }, + }), + ]; +} + function shouldRetryOpenRouterServerError( failure: OpenRouterFetchFailure | null, attemptIndex: number, diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts index f7d02d2a..088428bf 100644 --- a/src/agent/prompt.ts +++ b/src/agent/prompt.ts @@ -22,19 +22,14 @@ Run discipline: - Never pass host absolute paths like /Users/... to filesystem tools; that creates nested paths inside the repo instead of touching the intended file. - Shell execute commands run on the host. If you use execute, run commands from the target repository directory and keep them inside that repository. - Do not exhaustively read every file. Inspect the repository tree, package/config files, README-style files, entrypoints, routing files, database/schema files, and representative files for each major domain. -- Do not call glob with **/* from the repository root. Use targeted discovery by directory and extension. Prefer shell commands like rg --files with excludes for .git, node_modules, dist, build, cache directories, and existing generated wiki output. +- Do not call glob with **/* from the repository root. Use targeted discovery by directory and extension. Prefer shell commands like rg --files with excludes for .git, node_modules, dist, build, cache directories, existing generated wiki output, and any temporary/testing or generated openwiki.* directories. +- Do not read, write, or search any openwiki.* variant directories (anything other than openwiki/ itself); they are testing/validation artifacts, not part of the codebase documentation. - Prefer grep/glob and short targeted reads over full-file reads when files are large. - Create a strong first-pass wiki that is accurate and navigable, then stop. The wiki can be refined in later update runs. - Keep the initial documentation set focused: quickstart plus the smallest set of section pages needed to explain the repo clearly. - Do not run commands that search outside the target repository. -Subagent discipline: -- You may use the task tool to parallelize read-only research during init and update runs when the repository has multiple substantial domains. -- Default to 1-2 subagents for large or unfamiliar repositories. Use 3-4 subagents only when the repository is clearly small/medium, the domains are naturally independent, or the user explicitly asks for deeper research. -- Subagents must only inspect and summarize. They must not create, edit, delete, or move files, and they must not write to ${OPEN_WIKI_DIR}/. -- Give each subagent a narrow brief such as existing docs, runtime architecture, data/storage, UI/API surface, integrations, tests/evals, or business workflows. -- Ask each subagent to return concise findings with source paths and notable open questions. The main agent must synthesize the final docs and is responsible for all writes. -- Treat subagent reports as internal discovery notes. Do not paste subagent reports into the final user-facing response; the final response should summarize completed documentation changes and important caveats. +${createSubagentInstructions()} Planning discipline: - After discovery and before writing final documentation, create a temporary ${OPEN_WIKI_DIR}/_plan.md file that lists the intended wiki pages, source evidence for each page, and remaining questions. @@ -131,6 +126,27 @@ ${createModeInstructions(command)} `.trim(); } +function createSubagentInstructions(): string { + if (process.env.OPENWIKI_DISABLE_SUBAGENTS === "1") { + return ` +Subagent discipline: +- Do not use the task tool or delegate research to subagents during this run. +- Perform repository discovery directly with ls, glob, grep, read_file, and targeted shell execute commands. +- Keep discovery concise and write documentation as soon as the main architecture, workflows, operations, and tests are understood. +`.trim(); + } + + return ` +Subagent discipline: +- You may use the task tool to parallelize read-only research during init and update runs when the repository has multiple substantial domains. +- Default to 1-2 subagents for large or unfamiliar repositories. Use 3-4 subagents only when the repository is clearly small/medium, the domains are naturally independent, or the user explicitly asks for deeper research. +- Subagents must only inspect and summarize. They must not create, edit, delete, or move files, and they must not write to ${OPEN_WIKI_DIR}/. +- Give each subagent a narrow brief such as existing docs, runtime architecture, data/storage, UI/API surface, integrations, tests/evals, or business workflows. +- Ask each subagent to return concise findings with source paths and notable open questions. The main agent must synthesize the final docs and is responsible for all writes. +- Treat subagent reports as internal discovery notes. Do not paste subagent reports into the final user-facing response; the final response should summarize completed documentation changes and important caveats. +`.trim(); +} + export function createModeInstructions(command: OpenWikiCommand): string { if (command === "chat") { return ` From 6e17267074f64c2c931ebdbf1b5ff649315bb9f7 Mon Sep 17 00:00:00 2001 From: Erik Fehn Date: Sat, 4 Jul 2026 10:58:08 -0700 Subject: [PATCH 2/2] test: verify OPENWIKI_DISABLE_SUBAGENTS excludes the task tool Adds node:test coverage (no new deps) for the subagent-disable path: - disabled profile drops the task tool and sets generalPurposeSubagent.enabled=false - the exclusion middleware filters the task tool out of the model call when set - default behavior is unchanged (no middleware, no tools removed) when unset - the opt-in gate only triggers on the literal "1" Extracts the harness profile into a pure buildSubagentDisabledProfile() and shares the task tool name via a single constant so the profile and middleware stay in sync. Adds a "test" script. Co-Authored-By: Claude Fable 5 --- package.json | 3 +- src/agent/index.ts | 35 +++++++++--- src/agent/subagent-disable.test.ts | 92 ++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 src/agent/subagent-disable.test.ts diff --git a/package.json b/package.json index f8b32fa3..2c263049 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "lint:check": "eslint .", "prebuild": "pnpm run clean", "prepack": "pnpm run build", - "start": "node dist/cli.js" + "start": "node dist/cli.js", + "test": "tsx --test src/**/*.test.ts" }, "dependencies": { "@langchain/anthropic": "^1.5.1", diff --git a/src/agent/index.ts b/src/agent/index.ts index 6401cf28..69b70dcb 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -450,6 +450,26 @@ function isModelFallbackDisabled(): boolean { return process.env.OPENWIKI_DISABLE_MODEL_FALLBACK === "1"; } +// The subagent delegation tool. Excluded from the model both via the harness +// profile (below) and via runtime middleware so the same name is enforced in +// one place. +const SUBAGENT_TASK_TOOL_NAME = "task"; + +/** + * The harness profile applied when subagents are disabled: it drops the task + * tool and turns off the general-purpose subagent. Pure and exported so the + * disabled shape can be asserted in tests without touching the global harness + * registry. + */ +export function buildSubagentDisabledProfile() { + return { + excludedTools: [SUBAGENT_TASK_TOOL_NAME], + generalPurposeSubagent: { + enabled: false, + }, + }; +} + function configureDeepAgentHarness( provider: OpenWikiProvider, modelId: string, @@ -458,12 +478,7 @@ function configureDeepAgentHarness( return; } - const profile = { - excludedTools: ["task"], - generalPurposeSubagent: { - enabled: false, - }, - }; + const profile = buildSubagentDisabledProfile(); registerHarnessProfile(provider, profile); @@ -472,11 +487,11 @@ function configureDeepAgentHarness( } } -function isSubagentDisabled(): boolean { +export function isSubagentDisabled(): boolean { return process.env.OPENWIKI_DISABLE_SUBAGENTS === "1"; } -function createOpenWikiMiddleware() { +export function createOpenWikiMiddleware() { if (!isSubagentDisabled()) { return []; } @@ -487,7 +502,9 @@ function createOpenWikiMiddleware() { wrapModelCall: async (request, handler) => { return handler({ ...request, - tools: request.tools?.filter((tool) => tool.name !== "task"), + tools: request.tools?.filter( + (tool) => tool.name !== SUBAGENT_TASK_TOOL_NAME, + ), }); }, }), diff --git a/src/agent/subagent-disable.test.ts b/src/agent/subagent-disable.test.ts new file mode 100644 index 00000000..20011811 --- /dev/null +++ b/src/agent/subagent-disable.test.ts @@ -0,0 +1,92 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + buildSubagentDisabledProfile, + createOpenWikiMiddleware, + isSubagentDisabled, +} from "./index.js"; + +const ENV_KEY = "OPENWIKI_DISABLE_SUBAGENTS"; + +function withEnv(value: string | undefined, fn: () => T): T { + const previous = process.env[ENV_KEY]; + if (value === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = value; + } + try { + return fn(); + } finally { + if (previous === undefined) { + delete process.env[ENV_KEY]; + } else { + process.env[ENV_KEY] = previous; + } + } +} + +// Invoke a middleware's wrapModelCall with a captured request so we can inspect +// the tool list the model would actually be handed. +async function toolsSeenByModel( + middleware: ReturnType, + tools: Array<{ name: string }>, +): Promise> { + assert.equal(middleware.length, 1, "expected exactly one middleware"); + let captured: Array<{ name: string }> = tools; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (middleware[0] as any).wrapModelCall( + { tools }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async (request: any) => { + captured = request.tools; + return {}; + }, + ); + return captured; +} + +test('isSubagentDisabled only reports true for exactly "1"', () => { + assert.equal( + withEnv("1", isSubagentDisabled), + true, + "OPENWIKI_DISABLE_SUBAGENTS=1 should disable subagents", + ); + assert.equal( + withEnv(undefined, isSubagentDisabled), + false, + "unset should leave subagents enabled", + ); + assert.equal( + withEnv("true", isSubagentDisabled), + false, + 'only the literal "1" opts in; other truthy strings do not', + ); +}); + +test("disabled profile drops the task tool and turns off the general-purpose subagent", () => { + const profile = buildSubagentDisabledProfile(); + assert.deepEqual(profile.excludedTools, ["task"]); + assert.equal(profile.generalPurposeSubagent.enabled, false); +}); + +test("default behavior is unchanged: no middleware and no tools removed when unset", async () => { + const middleware = withEnv(undefined, createOpenWikiMiddleware); + assert.deepEqual(middleware, [], "unset should add no middleware"); +}); + +test("when disabled, the task tool is excluded from the model call", async () => { + const middleware = withEnv("1", createOpenWikiMiddleware); + const tools = [ + { name: "read_file" }, + { name: "task" }, + { name: "write_file" }, + ]; + const seen = await toolsSeenByModel(middleware, tools); + assert.deepEqual( + seen.map((tool) => tool.name), + ["read_file", "write_file"], + "task must be filtered out; all other tools pass through unchanged", + ); +});