Skip to content
Open
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 @@ -39,6 +39,10 @@ The file stores provider configuration and API keys:
- Optional OAuth callback settings: `OPENWIKI_OAUTH_CALLBACK_PORT` controls the
local callback port, and `OPENWIKI_HTTPS_OAUTH_REDIRECT_URI` stores the
Slack-only HTTPS callback URL created by `openwiki ngrok start`.
- Run-hardening options (all opt-in; unset, behavior is unchanged):
- `OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS` — sets a `maxInputTokens` profile on OpenRouter models so DeepAgents' summarization middleware triggers early enough to stay under OpenRouter's payload limit on constrained/budget models, instead of failing with an HTTP 500 on large transcripts. Suggested value for constrained models: `15000`.
- `OPENWIKI_DISABLE_MODEL_FALLBACK` — set to `1` to disable OpenRouter's `route: "fallback"` model list for deterministic single-model runs.
- `OPENWIKI_DISABLE_SUBAGENTS` — set to `1` to disable subagent task delegation entirely, for runs where transcript growth from subagent fan-out is the binding constraint.

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
139 changes: 137 additions & 2 deletions src/agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite";
import { ChatOpenAI } from "@langchain/openai";
import { ChatOpenRouter } from "@langchain/openrouter";
import type { Event as ProtocolEvent } from "@langchain/protocol";
import { createDeepAgent } from "deepagents";
import { createDeepAgent, registerHarnessProfile } from "deepagents";
import { createMiddleware } from "langchain";
import { createOpenWikiConnectorTools } from "../connectors/tools.js";
import { ensureWriteConnectorSkill } from "../connectors/write-connector-skill.js";
import {
Expand Down Expand Up @@ -46,6 +47,7 @@ import {
OPENAI_COMPATIBLE_BASE_URL_ENV_KEY,
OPENROUTER_API_KEY_ENV_KEY,
OPENROUTER_BASE_URL,
OPENROUTER_FALLBACK_MODEL_IDS,
OPENWIKI_MODEL_ID_ENV_KEY,
OPENWIKI_PROVIDER_ENV_KEY,
OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY,
Expand Down Expand Up @@ -163,6 +165,13 @@ async function runOpenWikiAgentCore(
emitDebug(options, "openwiki.snapshot=created");
const model = createModel(provider, modelId, providerRetryAttempts);
emitDebug(options, `model.provider=${provider}`);
configureDeepAgentHarness(provider, modelId);
if (provider === "openrouter") {
emitDebug(
options,
`openrouter.route=fallback models=${JSON.stringify(createModelRoute(provider, modelId))}`,
);
}
emitDebug(options, "model=initialized");
const threadId = options.threadId ?? createThreadId(cwd, createRunThreadId());
emitDebug(options, `thread=${threadId}`);
Expand All @@ -177,6 +186,7 @@ async function runOpenWikiAgentCore(
const agent = createDeepAgent({
model,
tools: createOpenWikiConnectorTools(),
middleware: createOpenWikiMiddleware(),
checkpointer,
backend: new OpenWikiLocalShellBackend({
docsOnly: command !== "chat",
Expand Down Expand Up @@ -471,13 +481,17 @@ function createModel(
}

if (provider === "openrouter") {
return new ChatOpenRouter({
const model = new ChatOpenRouter({
apiKey: process.env[OPENROUTER_API_KEY_ENV_KEY],
baseURL: OPENROUTER_BASE_URL,
model: modelId,
models: createModelRoute(provider, modelId),
route: "fallback",
siteName: "OpenWiki",
...retryOptions,
});
applyOpenRouterMaxInputTokens(model);
return model;
}

const baseURL = resolveProviderBaseUrl(provider);
Expand All @@ -495,6 +509,127 @@ function createModel(
});
}

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

/**
* The OpenRouter `models` list tried in order via `route: "fallback"`. Opt out
* with `OPENWIKI_DISABLE_MODEL_FALLBACK=1` for deterministic single-model runs.
*/
export function createModelRoute(
provider: OpenWikiProvider,
modelId: string,
): string[] {
if (provider !== "openrouter" || isModelFallbackDisabled()) {
return [modelId];
}

return Array.from(new Set([modelId, ...OPENROUTER_FALLBACK_MODEL_IDS]));
}

/**
* Parses `OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS`. Returns `undefined` when
* unset or non-numeric, so the opt-in cap is a no-op unless a valid value is
* configured. Pure so the parsing edge cases are testable without a real
* model instance.
*/
export function resolveOpenRouterMaxInputTokens(
raw: string | undefined,
): number | undefined {
if (!raw) {
return undefined;
}

const parsed = parseInt(raw, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}

/**
* Sets a `maxInputTokens` profile on the OpenRouter model instance (opt-in via
* `OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS`) so DeepAgents' summarization
* middleware triggers early enough to stay under OpenRouter's payload limit
* on constrained/budget models, instead of failing with an HTTP 500 on large
* transcripts. `defineProperty` avoids crashing on a frozen/non-extensible
* model instance.
*/
function applyOpenRouterMaxInputTokens(model: ChatOpenRouter): void {
const maxInputTokens = resolveOpenRouterMaxInputTokens(
process.env.OPENWIKI_OPENROUTER_MAX_INPUT_TOKENS,
);
if (maxInputTokens === undefined) {
return;
}

Object.defineProperty(model, "profile", {
value: { maxInputTokens },
writable: true,
configurable: true,
enumerable: true,
});
}

// 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,
),
});
},
}),
];
}

const CHATGPT_LOGIN_INCOMPLETE_MESSAGE =
"ChatGPT login is incomplete. Run `openwiki code --init` or `openwiki personal --init` to sign in with your ChatGPT account.";

Expand Down
37 changes: 30 additions & 7 deletions src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Run discipline:
- Shell execute commands run on the host. If you use execute, run commands from the current runtime root unless a source-specific instruction explicitly tells you to inspect a connector raw file or configured local repository path.
- Do not exhaustively read every file. For a local knowledge wiki, inspect the existing wiki structure and only the relevant connector evidence or configured local repository paths. For an explicit repository source, 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 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 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.
Expand Down Expand Up @@ -75,13 +76,7 @@ Wiki-first question answering:
- When the wiki answers the question, do not inspect or mention raw connector data.
- When you do inspect raw data, keep reads narrow: list latest raw items for the relevant connector, open only the specific files needed, and summarize only the minimum evidence required to answer or update the wiki.

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 ${output.docsLocation}.
- 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(output.docsLocation)}

Planning discipline:
- After discovery and before writing final documentation, create a temporary ${output.planPath} file that lists the intended wiki pages, source evidence for each page, and remaining questions.
Expand Down Expand Up @@ -165,6 +160,34 @@ ${createModeInstructions(command, outputMode)}
`.trim();
}

/**
* Subagent discipline text. When `OPENWIKI_DISABLE_SUBAGENTS=1`, tells the
* agent not to delegate to the task tool at all — paired with the
* harness-profile/middleware exclusion in `src/agent/index.ts` that actually
* removes the tool, for runs where transcript growth from subagent fan-out is
* the binding constraint.
*/
function createSubagentInstructions(docsLocation: string): 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 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 ${docsLocation}.
- 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,
outputMode: OpenWikiOutputMode = "local-wiki",
Expand Down
10 changes: 10 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,16 @@ export const SUGGESTED_MODEL_IDS = PROVIDER_CONFIGS[
DEFAULT_PROVIDER
].modelOptions.map((model) => model.id);

/**
* Fallback model ids OpenRouter tries, in order, when the primary model
* request fails (`route: "fallback"` in `createModel`). Opt out entirely with
* `OPENWIKI_DISABLE_MODEL_FALLBACK=1` for deterministic single-model runs.
*/
export const OPENROUTER_FALLBACK_MODEL_IDS = [
"openai/gpt-5.4-mini",
"anthropic/claude-sonnet-5",
];

export function getProviderConfig(provider: OpenWikiProvider): ProviderConfig {
return PROVIDER_CONFIGS[provider];
}
Expand Down
79 changes: 79 additions & 0 deletions test/openrouter-hardening.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import {
createModelRoute,
isModelFallbackDisabled,
resolveOpenRouterMaxInputTokens,
} from "../src/agent/index.ts";
import { OPENROUTER_FALLBACK_MODEL_IDS } from "../src/constants.ts";

const FALLBACK_ENV_KEY = "OPENWIKI_DISABLE_MODEL_FALLBACK";
let previousFallbackValue: string | undefined;

beforeEach(() => {
previousFallbackValue = process.env[FALLBACK_ENV_KEY];
delete process.env[FALLBACK_ENV_KEY];
});

afterEach(() => {
if (previousFallbackValue === undefined) {
delete process.env[FALLBACK_ENV_KEY];
} else {
process.env[FALLBACK_ENV_KEY] = previousFallbackValue;
}
});

describe("isModelFallbackDisabled", () => {
test('only reports true for exactly "1"', () => {
process.env[FALLBACK_ENV_KEY] = "1";
expect(isModelFallbackDisabled()).toBe(true);

delete process.env[FALLBACK_ENV_KEY];
expect(isModelFallbackDisabled()).toBe(false);

process.env[FALLBACK_ENV_KEY] = "true";
expect(isModelFallbackDisabled()).toBe(false);
});
});

describe("createModelRoute", () => {
test("non-openrouter providers never get a fallback list", () => {
expect(createModelRoute("anthropic", "claude-sonnet-5")).toEqual([
"claude-sonnet-5",
]);
});

test("openrouter gets the primary model plus fallback ids by default", () => {
const route = createModelRoute("openrouter", "z-ai/glm-5.2");
expect(route[0]).toBe("z-ai/glm-5.2");
for (const fallback of OPENROUTER_FALLBACK_MODEL_IDS) {
expect(route).toContain(fallback);
}
});

test("does not duplicate the primary model when it is also a fallback id", () => {
const [primary] = OPENROUTER_FALLBACK_MODEL_IDS;
const route = createModelRoute("openrouter", primary);
expect(route.filter((id) => id === primary)).toHaveLength(1);
});

test("OPENWIKI_DISABLE_MODEL_FALLBACK=1 collapses openrouter to a single model", () => {
process.env[FALLBACK_ENV_KEY] = "1";
expect(createModelRoute("openrouter", "z-ai/glm-5.2")).toEqual([
"z-ai/glm-5.2",
]);
});
});

describe("resolveOpenRouterMaxInputTokens", () => {
test("returns undefined when unset", () => {
expect(resolveOpenRouterMaxInputTokens(undefined)).toBeUndefined();
});

test("returns undefined for non-numeric values", () => {
expect(resolveOpenRouterMaxInputTokens("not-a-number")).toBeUndefined();
});

test("parses a valid integer string", () => {
expect(resolveOpenRouterMaxInputTokens("15000")).toBe(15000);
});
});
Loading