Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openwiki/operations/credentials-and-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
124 changes: 107 additions & 17 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -180,6 +186,7 @@ async function runOpenWikiAgentCore(
const agent = createDeepAgent({
model,
tools: [],
middleware: createOpenWikiMiddleware(),
checkpointer,
backend: new LocalShellBackend({
maxOutputBytes: 100_000,
Expand Down Expand Up @@ -378,49 +385,132 @@ 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,
models,
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";
}

// 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,
): void {
if (!isSubagentDisabled()) {
return;
}

const profile = buildSubagentDisabledProfile();

registerHarnessProfile(provider, profile);

if (!modelId.includes(":")) {
registerHarnessProfile(`${provider}:${modelId}`, profile);
}
}

export function isSubagentDisabled(): boolean {
return process.env.OPENWIKI_DISABLE_SUBAGENTS === "1";
}

export function createOpenWikiMiddleware() {
if (!isSubagentDisabled()) {
return [];
}

return [
createMiddleware({
name: "OpenWikiToolExclusionMiddleware",
wrapModelCall: async (request, handler) => {
return handler({
...request,
tools: request.tools?.filter(
(tool) => tool.name !== SUBAGENT_TASK_TOOL_NAME,
),
});
},
}),
];
}

function shouldRetryOpenRouterServerError(
failure: OpenRouterFetchFailure | null,
attemptIndex: number,
Expand Down
32 changes: 24 additions & 8 deletions src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `
Expand Down
92 changes: 92 additions & 0 deletions src/agent/subagent-disable.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<typeof createOpenWikiMiddleware>,
tools: Array<{ name: string }>,
): Promise<Array<{ name: string }>> {
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",
);
});