feat: adapt pullfrog for gitea + ollama

This commit is contained in:
2026-05-31 01:01:00 -05:00
parent 36ac64a5b6
commit 2aca1a3aa3
183 changed files with 1419 additions and 28292 deletions
-1056
View File
File diff suppressed because it is too large Load Diff
+3 -7
View File
@@ -1,10 +1,6 @@
import { claude } from "./claude.ts";
// v2 harness — adapted to opencode-ai >=1.14.x SDK-v2 / Effect-ts CLI rewrite.
// The legacy v1 module (`./opencode.ts`) is kept around for reference + fast
// revert; the active runner is the v2 module below.
import { opencode } from "./opencode_v2.ts";
import { ollamaAgent } from "./ollama.ts";
import type { Agent } from "./shared.ts";
export type { Agent, AgentUsage } from "./shared.ts";
export type { Agent } from "./shared.ts";
export const agents = { claude, opencode } satisfies Record<string, Agent>;
export const agents = { ollama: ollamaAgent } satisfies Record<string, Agent>;
+199
View File
@@ -0,0 +1,199 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { Ollama, type Message, type ToolCall } from "ollama";
import { log } from "../utils/cli.ts";
import { agent, type AgentResult, type AgentRunContext } from "./shared.ts";
const DEFAULT_MODEL = "qwen3.6:35b";
const MAX_ITERATIONS = 100;
interface OllamaTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
async function buildMcpClient(mcpServerUrl: string): Promise<Client> {
const client = new Client(
{ name: "shockbot-agent", version: "0.1.0" },
{ capabilities: {} },
);
const transport = new StreamableHTTPClientTransport(new URL(mcpServerUrl));
// @ts-expect-error — StreamableHTTPClientTransport.sessionId is string|undefined but Transport
// expects string; this is an @modelcontextprotocol/sdk internal type mismatch, not our bug.
await client.connect(transport);
return client;
}
async function getOllamaTools(mcpClient: Client): Promise<OllamaTool[]> {
const { tools } = await mcpClient.listTools();
return tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description ?? "",
parameters: (t.inputSchema as Record<string, unknown>) ?? {
type: "object",
properties: {},
},
},
}));
}
async function callMcpTool(
mcpClient: Client,
toolName: string,
args: Record<string, unknown>,
): Promise<string> {
try {
const result = await mcpClient.callTool({
name: toolName,
arguments: args,
});
const content = result.content as
| Array<{ type: string; text?: string }>
| undefined;
if (!content || content.length === 0)
return JSON.stringify({ success: true });
const text = content
.map((c) => (c.type === "text" ? (c.text ?? "") : ""))
.filter(Boolean)
.join("\n");
return text || JSON.stringify(result);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.debug(`Tool ${toolName} error: ${msg}`);
return JSON.stringify({ error: msg });
}
}
async function runOllamaLoop(ctx: AgentRunContext): Promise<AgentResult> {
const ollamaHost = process.env.OLLAMA_HOST ?? "";
if (!ollamaHost) {
const errorMsg =
"OLLAMA_HOST environment variable is not set. Please set it to the URL of your Ollama instance.";
log.error(errorMsg);
return { success: false, error: errorMsg };
}
const model = ctx.model ?? process.env.OLLAMA_MODEL ?? DEFAULT_MODEL;
log.info(`» connecting to Ollama at ${ollamaHost}, model ${model}`);
const ollama = new Ollama({ host: ollamaHost });
const mcpClient = await buildMcpClient(ctx.mcpServerUrl);
log.info("» fetching MCP tool list...");
const tools = await getOllamaTools(mcpClient);
log.info(`» ${tools.length} tools available`);
const messages: Message[] = [
{
role: "user",
content: ctx.instructions.full,
},
];
let iterations = 0;
let pendingModeNudge = false;
while (iterations < MAX_ITERATIONS) {
iterations++;
log.info(`» Ollama turn ${iterations}/${MAX_ITERATIONS}...`);
let response: Awaited<ReturnType<typeof ollama.chat>>;
try {
response = await ollama.chat({
model,
messages,
tools,
options: {
think: false,
} as Record<string, unknown>,
});
} catch (err) {
const lastError = err instanceof Error ? err.message : String(err);
log.error(`Ollama error: ${lastError}`);
return { success: false, error: `Ollama request failed: ${lastError}` };
}
const assistantMessage = response.message;
messages.push(assistantMessage);
const toolCalls: ToolCall[] | undefined = assistantMessage.tool_calls;
if (!toolCalls || toolCalls.length === 0) {
// If we just gave the model a nudge after select_mode and it still won't
// call tools, it's genuinely done (or stuck) — exit cleanly.
if (pendingModeNudge) {
log.info("» agent finished after mode nudge (no tool calls)");
} else {
log.info("» agent finished (no tool calls)");
}
return {
success: true,
output: assistantMessage.content || undefined,
};
}
pendingModeNudge = false;
const calledSelectMode = toolCalls.some(
(tc) => tc.function.name === "select_mode",
);
for (const toolCall of toolCalls) {
const toolName = toolCall.function.name;
const toolArgs = toolCall.function.arguments;
log.info(`» calling tool: ${toolName}`);
log.debug(` args: ${JSON.stringify(toolArgs)}`);
if (ctx.onToolUse) {
ctx.onToolUse({ toolName, input: toolArgs });
}
const result = await callMcpTool(
mcpClient,
toolName,
toolArgs as Record<string, unknown>,
);
log.debug(` result: ${result.slice(0, 200)}`);
messages.push({
role: "tool",
content: result,
});
}
// After select_mode, explicitly tell the model to act on the returned guidance.
// Without this nudge, smaller models tend to treat the mode instructions as
// informational and stop rather than executing the workflow steps.
if (calledSelectMode) {
pendingModeNudge = true;
messages.push({
role: "user",
content:
"You have selected a mode and received your workflow instructions. " +
"Now execute the first step of that workflow immediately by calling the appropriate tool. " +
"Do not describe what you will do — just call the tool.",
});
}
}
log.warning(`» agent hit max iterations (${MAX_ITERATIONS})`);
return {
success: false,
error: `Agent exceeded maximum iterations (${MAX_ITERATIONS})`,
};
}
export const ollamaAgent = agent({
name: "ollama",
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
return runOllamaLoop(ctx);
},
});
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { modelAliases } from "../models.ts";
import { geminiHighThinkingOverrides } from "./opencode.ts";
describe("geminiHighThinkingOverrides", () => {
// Expected truth pulled the same way the helper does — both must derive from
// the registry so the test exercises the wiring, not a hand-maintained list.
const expectedApiIds = modelAliases
.filter((a) => a.provider === "google")
.map((a) => a.resolve.replace(/^google\//, ""));
const overrides = geminiHighThinkingOverrides();
it("covers every direct-Google alias in the registry", () => {
expect(Object.keys(overrides).sort()).toEqual([...expectedApiIds].sort());
});
it("is non-empty (catches accidental whole-provider removal)", () => {
expect(Object.keys(overrides).length).toBeGreaterThan(0);
});
it("strips the `google/` prefix from each resolve to get the bare API id", () => {
for (const id of Object.keys(overrides)) {
expect(id).not.toMatch(/^google\//);
}
});
it("pins every entry to thinkingLevel: high", () => {
for (const [id, value] of Object.entries(overrides)) {
expect(value, `entry for ${id}`).toEqual({
options: { thinkingConfig: { thinkingLevel: "high" } },
});
}
});
});
-1264
View File
File diff suppressed because it is too large Load Diff
-139
View File
@@ -1,139 +0,0 @@
/**
* Source for the opencode plugin we drop into the per-run tmpdir at
* `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`. The harness already
* redirects `XDG_CONFIG_HOME` to `ctx.tmpdir/.config` (see `opencode.ts`
* `homeEnv`), so opencode's auto-discovery scans the tmpdir, never the user's
* working tree. opencode's `Global.Path.config` resolves to
* `path.join(xdgConfig, "opencode")` and the config layer auto-discovers
* plugins from every directory in its scan list — including
* `Global.Path.config` — by globbing `{plugin,plugins}/*.{ts,js}` via
* `ConfigPlugin.load(dir)`.
*
* We MUST NOT write into the user's repo working tree. The repo is a checkout
* the agent operates on; only the agent's own tools (gated by
* `OPENCODE_PERMISSION`) may modify it. The whole reason we redirect HOME and
* XDG_CONFIG_HOME is so harness-side files (config, plugins, scratch state)
* land in the tmpdir.
*
* Why this plugin exists: opencode's `task` tool runs subagents in-process and
* the CLI's `cli/cmd/run.ts` event loop filters `part.sessionID !== sessionID`,
* so subagent-internal `message.part.updated` events are silently discarded
* before reaching our parent NDJSON stream. plugins, by contrast, receive
* EVERY bus event via `bus.subscribeAll()` regardless of session.
*
* The plugin re-emits every relevant bus event onto opencode's stdout as a
* single JSON line wrapped in a sentinel envelope. our `runOpenCode` parser
* recognises the envelope, unpacks it, and routes the inner part through the
* existing handlers with a per-session label from `SessionLabeler` so each
* subagent's tool calls / text appear inline alongside the orchestrator's.
*
* Dumb plugin / smart parent split: the plugin emits every part for every
* session. the parent dedupes against the orchestrator's own session id (which
* it already knows from the `init` event). this keeps the plugin trivial and
* keeps the per-session attribution logic on the parent side where the
* SessionLabeler already lives.
*
* Event-name prefixing: the wrapped event-type sentinel is
* `pullfrog_bus_event` — picked to be unmistakably ours so a future opencode
* release that introduces a coincidentally-named event type won't collide.
*/
export const PULLFROG_BUS_EVENT_TYPE = "pullfrog_bus_event" as const;
export const PULLFROG_OPENCODE_PLUGIN_FILENAME = "pullfrog-events.ts" as const;
/**
* Source written verbatim to `<XDG_CONFIG_HOME>/opencode/plugin/pullfrog-events.ts`.
*
* - Structural typing only (no runtime import of `@opencode-ai/plugin`):
* opencode installs that dep into the directory containing the plugin
* alongside discovery, but a) the dep isn't required for the structural
* shape we use, and b) keeping zero imports avoids any module-resolution
* coupling to opencode's plugin-loader internals across versions.
* - default export is the plugin factory (opencode's plugin loader accepts
* default exports as the server entrypoint).
* - we only forward `message.part.updated`. that's where the user-visible
* subagent activity (tool calls, text, step transitions) lives. add more
* event types here if the parent needs them.
* - JSON.stringify+single write keeps the line atomic up to PIPE_BUF (4KB on
* Linux). longer parts may interleave with concurrent stdout writers; the
* parser tolerates non-JSON lines (logs them at debug) so a torn line is a
* missed event, not a crash.
*/
export const PULLFROG_OPENCODE_PLUGIN_SOURCE = `// AUTOGENERATED by Pullfrog. do not edit; it'll be overwritten on the next run.
// surfaces opencode subagent activity that the CLI's run-loop discards. see
// action/agents/opencodePlugin.ts in pullfrog/app for why this exists. lives
// inside the per-run tmpdir (XDG_CONFIG_HOME/opencode/plugin/), never inside
// the user's working tree.
const PULLFROG_BUS_EVENT_TYPE = ${JSON.stringify(PULLFROG_BUS_EVENT_TYPE)};
// the first sessionID we see on a message.part.updated event is the
// orchestrator — opencode's run command creates exactly one top-level session
// before any subagent is dispatched, and the user-prompt text part fires
// before the first task tool_use. we lock that sessionID in here and use it
// to filter: the orchestrator's events are already streamed by the CLI's
// run-loop, so we only forward (a) all subagent events, and (b) the
// orchestrator's task tool dispatches at status="running". the CLI only
// emits task tool_use at status=completed (after the subagent finishes), so
// without the early announce the parent's labeler binds subagent sessions
// before recordTaskDispatch fires and the lens label is lost.
let orchestratorSessionID: string | undefined;
function isOrchestratorTaskDispatch(part: {
type?: string;
tool?: string;
state?: { status?: string };
}): boolean {
if (part.type !== "tool") return false;
if (part.tool !== "task") return false;
// only forward at status="running" (not "pending"). at pending the
// state.input is still {} — the orchestrator has emitted the part shell
// but the LLM hasn't filled in description/subagent_type/prompt yet. by
// running, input is populated and recordTaskDispatch can derive the lens
// label correctly.
return part.state?.status === "running";
}
export default async function pullfrogEventsPlugin() {
return {
event: async (input: {
event: {
type: string;
properties?: {
part?: {
sessionID?: string;
type?: string;
tool?: string;
state?: { status?: string };
};
};
};
}) => {
const event = input.event;
if (!event || typeof event !== "object") return;
if (event.type !== "message.part.updated") return;
const part = event.properties?.part;
const sessionID = part?.sessionID;
if (typeof sessionID !== "string" || sessionID.length === 0) return;
if (orchestratorSessionID === undefined) orchestratorSessionID = sessionID;
if (sessionID === orchestratorSessionID) {
// skip orchestrator events EXCEPT early task dispatches.
if (!part || !isOrchestratorTaskDispatch(part)) return;
}
try {
const line = JSON.stringify({
type: PULLFROG_BUS_EVENT_TYPE,
bus_event: event,
});
process.stdout.write(line + "\\n");
} catch {
// a circular reference or BigInt etc. would throw; swallow rather
// than letting a single bad event take down the plugin.
}
},
};
}
`;
-124
View File
@@ -1,124 +0,0 @@
// Shared helpers for the OpenCode agent harnesses (`./opencode.ts` v1 and
// `./opencode_v2.ts` v2). Pure config / model-registry / install glue —
// nothing here touches the NDJSON event loop, which differs between v1 and v2.
//
// Once v1 is deleted post-burn-in this module collapses back into v2; until
// then it keeps both runners synchronized so a config drift can't make v1 a
// silently-broken fallback.
import { modelAliases } from "../models.ts";
import { log } from "../utils/cli.ts";
import { installFromNpmTarball } from "../utils/install.ts";
import { getAuthorizedModels } from "../utils/openCodeModels.ts";
import { getDevDependencyVersion } from "../utils/version.ts";
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
import { deriveSubagentModels } from "./subagentModels.ts";
// ── config ─────────────────────────────────────────────────────────────────────
export type OpenCodeConfig = {
mcp?: Record<string, unknown>;
permission?: Record<string, unknown>;
provider?: Record<string, unknown>;
agent?: Record<string, unknown>;
experimental?: Record<string, unknown>;
model?: string;
enabled_providers?: string[];
[key: string]: unknown;
};
/**
* Build the `provider.google.models[id].options` map that pins every direct-Google
* Gemini alias to `thinkingLevel: "high"`. Sourced from the model registry so
* adding/renaming a Google alias in `action/models.ts` flows through automatically.
*/
export function geminiHighThinkingOverrides(): Record<string, { options: object }> {
return Object.fromEntries(
modelAliases
.filter((a) => a.provider === "google")
.map((a) => [
a.resolve.replace(/^google\//, ""),
{ options: { thinkingConfig: { thinkingLevel: "high" } } },
])
);
}
/**
* Read-only `reviewfrog` subagent for lens-based review. Non-mutative +
* non-recursive — enforced by the system prompt in reviewer.ts.
*
* Per-subagent `model:` override is driven by the registry in
* `action/models.ts` via each alias's `subagentModel` field. Currently wired:
* Anthropic opus → sonnet, OpenAI gpt-pro → gpt and gpt → gpt-5.4, Google
* gemini-pro → gemini-flash. Other providers inherit (no override).
*/
export function buildReviewerAgentConfig(
orchestratorModel: string | undefined
): Record<string, unknown> {
const overrides = deriveSubagentModels(orchestratorModel);
return {
[REVIEWER_AGENT_NAME]: {
description:
"Read-only review subagent for lens-based code review (correctness, security, billing-subsystem, etc.). " +
"Reads only — no writes, no state-changing shell or MCP calls, no nested subagent dispatch.",
mode: "subagent",
prompt: REVIEWER_SYSTEM_PROMPT,
...(overrides.reviewer !== undefined ? { model: overrides.reviewer } : {}),
},
};
}
// ── install ────────────────────────────────────────────────────────────────────
/**
* Install the opencode-ai npm tarball and return the path to the executable.
*
* The bin path differs by version: v1.4.x and earlier shipped `bin/opencode`;
* v1.14+ renames the platform-specific binary to `bin/opencode.exe` for every
* OS via the postinstall script. Callers pass the binPath that matches their
* pinned version so a v1↔v2 swap can't silently install the wrong file.
*/
export async function installOpencodeCli(params: { binPath: string }): Promise<string> {
return await installFromNpmTarball({
packageName: "opencode-ai",
version: getDevDependencyVersion("opencode-ai"),
executablePath: params.binPath,
installDependencies: true,
});
}
// ── model auto-select fallback ──────────────────────────────────────────────────
//
// steps 12 of model resolution (PULLFROG_MODEL env, slug resolution) happen
// in resolveModel() in utils/agent.ts before the agent runs. this is step 3:
// auto-select using the authorized model set captured in main.ts via
// `opencode models` introspection.
const AUTO_SELECT_WARNING =
"select a model explicitly in the Pullfrog console (https://pullfrog.com/console) to avoid this.";
export function autoSelectModel(): string | undefined {
const authorized = getAuthorizedModels();
if (authorized.size > 0) {
// skip hidden aliases (internal subagent-tier targets like
// opencode/gpt-5.4) — they should never surface as a user-facing
// orchestrator pick. mirrors the selectable-list filter in
// components/ModelSelector.tsx and action/commands/init.ts.
const match =
modelAliases.find((a) => !a.hidden && a.preferred && authorized.has(a.resolve)) ??
modelAliases.find((a) => !a.hidden && authorized.has(a.resolve));
if (match) {
log.info(
`» model: ${match.resolve} (auto-selected${match.preferred ? " — preferred" : ""} curated match)`
);
log.warning(`» model auto-selected. ${AUTO_SELECT_WARNING}`);
return match.resolve;
}
log.info(
`» opencode has ${authorized.size} models but none match curated aliases — letting OpenCode auto-select`
);
}
log.warning(`» no model resolved. letting OpenCode auto-select. ${AUTO_SELECT_WARNING}`);
return undefined;
}
File diff suppressed because it is too large Load Diff
-64
View File
@@ -1,64 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ToolState } from "../toolState.ts";
import { getUnsubmittedReview } from "./postRun.ts";
function makeToolState(overrides: Partial<ToolState> = {}): ToolState {
return {
progressComment: undefined,
hadProgressComment: true,
prepushFailureCount: 0,
backgroundProcesses: new Map(),
usageEntries: [],
...overrides,
};
}
describe("getUnsubmittedReview", () => {
it("returns null when mode is not a review mode", () => {
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Build" }))).toBeNull();
expect(getUnsubmittedReview(makeToolState())).toBeNull();
});
it("returns null when a review was already submitted", () => {
expect(
getUnsubmittedReview(
makeToolState({
selectedMode: "Review",
review: { id: 1, nodeId: "n", reviewedSha: undefined },
})
)
).toBeNull();
});
it("fires for Review even when report_progress wrote a final summary", () => {
// Review's only valid exit is `create_pull_request_review`. a summary
// comment is not a substitute, and accepting it here previously let
// subagent-flipped `finalSummaryWritten` silence the gate.
expect(
getUnsubmittedReview(makeToolState({ selectedMode: "Review", finalSummaryWritten: true }))
).toBe("Review");
});
it("returns null for IncrementalReview when report_progress wrote a final summary", () => {
// IncrementalReview treats `report_progress` as a legitimate
// "no review warranted" exit, matching the post-failure error message.
expect(
getUnsubmittedReview(
makeToolState({ selectedMode: "IncrementalReview", finalSummaryWritten: true })
)
).toBeNull();
});
it("returns null when there is no progress comment to anchor the failure to", () => {
expect(
getUnsubmittedReview(makeToolState({ selectedMode: "Review", hadProgressComment: false }))
).toBeNull();
});
it("returns the selected mode when the gate should fire", () => {
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Review" }))).toBe("Review");
expect(getUnsubmittedReview(makeToolState({ selectedMode: "IncrementalReview" }))).toBe(
"IncrementalReview"
);
});
});
-499
View File
@@ -1,499 +0,0 @@
import { readFile } from "node:fs/promises";
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
import { NON_COMMITTING_MODES } from "../modes.ts";
import type { ToolState } from "../toolState.ts";
import { log } from "../utils/cli.ts";
import {
SPAWN_ACTIVITY_TIMEOUT_CODE,
SPAWN_TIMEOUT_CODE,
SpawnTimeoutError,
spawn,
} from "../utils/subprocess.ts";
import {
type AgentResult,
type AgentRunContext,
type AgentUsage,
buildCommitPrompt,
getGitStatus,
hasPostRunIssues,
MAX_POST_RUN_RETRIES,
mergeAgentUsage,
type PostRunIssues,
type StopHookFailure,
} from "./shared.ts";
/**
* derive "agent picked a review mode but never produced visible output" from
* the literal facts on `toolState`. returns the selected mode when the gate
* should fire, `null` otherwise — pure read, no side effects, safe to invoke
* after every agent attempt.
*
* the gate is anchored to `hadProgressComment` so silent runs (non-issue
* events, dispatcher skipped seeding) don't fire a nudge there's no UI for.
*
* `Review` and `IncrementalReview` have different valid exits:
* - Review: only `create_pull_request_review` counts. `report_progress` is
* not a substitute — a Review run that exits with just a summary comment
* has produced nothing reviewable on the PR. matches the hard-fail
* message at `expected = "create_pull_request_review"` below.
* - IncrementalReview: `report_progress` is a legitimate "no review
* warranted" exit, so either toolState flag short-circuits.
* splitting per mode also closes the bypass where a subagent (e.g. a
* `task`-dispatched `reviewfrog` lens) calls `report_progress` and silences
* the gate even though the orchestrator never submitted a review.
*/
export function getUnsubmittedReview(toolState: ToolState): "Review" | "IncrementalReview" | null {
const mode = toolState.selectedMode;
if (!toolState.hadProgressComment) return null;
if (mode === "Review") return toolState.review ? null : "Review";
if (mode === "IncrementalReview") {
return toolState.review || toolState.finalSummaryWritten ? null : "IncrementalReview";
}
return null;
}
/**
* hook output can flow into two size-sensitive places: the LLM resume prompt
* (context window) and AgentResult.error (surfaced in GitHub comments capped
* at 65535 chars). truncate the tail to keep both bounded; the tail is
* usually the most actionable part of a failing script's output.
*/
const MAX_HOOK_OUTPUT_CHARS = 4096;
function truncateHookOutput(raw: string): string {
if (raw.length <= MAX_HOOK_OUTPUT_CHARS) return raw;
return `...(truncated, showing last ${MAX_HOOK_OUTPUT_CHARS} chars)\n${raw.slice(-MAX_HOOK_OUTPUT_CHARS)}`;
}
/**
* run the user-configured stop hook.
*
* parallel to `executeLifecycleHook` (which soft-fails with a warning), but
* returns structured output so agent harnesses can feed the failure back into
* the session as a resume prompt.
*
* - non-zero exit → `StopHookFailure`, actionable: the output is fed to the
* agent so it can fix the underlying issue.
* - timeout / spawn error → null, treated as passed: we can't usefully ask the
* agent to fix an infrastructure problem, and retrying would risk infinite
* loops.
*/
export async function executeStopHook(script: string): Promise<StopHookFailure | null> {
log.info("» executing stop hook...");
try {
const result = await spawn({
cmd: "bash",
args: ["-c", script],
env: process.env,
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
activityTimeout: 0,
onStdout: (chunk) => process.stdout.write(chunk),
onStderr: (chunk) => process.stderr.write(chunk),
});
if (result.exitCode === 0) {
log.info("» stop hook passed");
return null;
}
// include both streams — scripts often emit a benign warning to stderr
// and the actionable error to stdout (or vice versa), and picking one
// starves the agent of the diagnostic it needs. stderr-first so stdout
// (typically longer, where truncation is more likely to bite) keeps its
// tail — summaries/totals usually live at the end.
const combined = [result.stderr.trim(), result.stdout.trim()].filter(Boolean).join("\n");
const output = truncateHookOutput(combined);
log.info(`» stop hook failed with exit code ${result.exitCode}`);
return { exitCode: result.exitCode, output };
} catch (err) {
const isTimeout =
err instanceof SpawnTimeoutError &&
(err.code === SPAWN_TIMEOUT_CODE || err.code === SPAWN_ACTIVITY_TIMEOUT_CODE);
const msg = err instanceof Error ? err.message : String(err);
log.warning(
`stop hook ${isTimeout ? "timed out" : "failed to spawn"}: ${msg} — skipping retry`
);
return null;
}
}
export function buildStopHookPrompt(failure: StopHookFailure): string {
return [
`STOP HOOK FAILED — the repo-configured stop hook exited with code ${failure.exitCode}. your work is not done until the hook exits cleanly. address the issue below and push any resulting changes to a pull request.`,
"",
"```",
failure.output || "(no output)",
"```",
].join("\n");
}
/** check whether the seeded summary file is byte-identical to its seed.
* a missing or unreadable file returns false (don't nudge — the agent
* may have legitimately deleted it, or the seed step failed; the read-
* back path in main.ts handles both cases by skipping persist). */
async function isSummaryUnchanged(filePath: string, seed: string): Promise<boolean> {
try {
const current = await readFile(filePath, "utf8");
return current === seed;
} catch {
return false;
}
}
export function buildSummaryStalePrompt(filePath: string): string {
return [
`PR SUMMARY UNTOUCHED — the rolling PR summary file at \`${filePath}\` is byte-identical to its seed; this run did not edit it.`,
"",
"review the diff and update the file in place to reflect what changed in the PR. update intent, key changes, and any risks worth flagging — keep the existing section headings stable so incremental runs produce clean diffs.",
"",
"if the diff is genuinely too small or noisy to warrant rewriting (e.g. a one-line typo fix, a comment tweak, a formatting-only change), it's fine to leave the structure as-is — but at minimum confirm you considered it by appending one line to the appropriate section noting the run. silence is not an option; the snapshot is what the next review run reads as context.",
].join("\n");
}
export function buildUnsubmittedReviewPrompt(mode: "Review" | "IncrementalReview"): string {
// mode-aware: Review mode's contract is "always submit one review" — its
// mode prompt forbids `report_progress`, so the nudge here must not offer
// it as an exit. IncrementalReview legitimately allows a report_progress
// exit when there are no new issues since the last review (mode prompt
// step 8), so the nudge mirrors that contract.
if (mode === "Review") {
return [
`MISSING REVIEW OUTPUT — you selected Review mode but stopped without calling \`create_pull_request_review\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
"",
"call `create_pull_request_review` now with your aggregated review (body + inline comments). pick the tier per the mode prompt — Review mode has no no-submit exit, so even informational `> ✅ No new issues found.` reviews must be submitted (with `approved: true`). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
"",
"do NOT stop again until `create_pull_request_review` has been called successfully.",
].join("\n");
}
return [
`MISSING REVIEW OUTPUT — you selected IncrementalReview mode but stopped without calling \`create_pull_request_review\` or \`report_progress\`. the user has no visible signal that this run produced anything; the progress comment will be deleted on exit and no review will appear on the PR.`,
"",
"do exactly one of:",
"- if you have findings: call `create_pull_request_review` now with your aggregated review (body + inline comments). the first call may error once with a diff-coverage nudge — retry the same call to proceed.",
"- if there are genuinely no actionable findings since the last review (e.g. only formatting / comment / lockfile changes): call `report_progress` with a 1-2 sentence summary explaining that no review was warranted.",
"",
"do NOT stop again until one of those tools has been called successfully.",
].join("\n");
}
/**
* check the post-run gates: did the stop hook pass, is the working tree
* clean, and (when applicable) did the agent touch the rolling PR summary
* snapshot or produce review output? returns everything that still needs
* nudging so the caller can render a single combined resume prompt.
*
* reads run state directly off `ctx.toolState` so each invocation sees the
* latest mutations from MCP tool calls. `skipSummaryStale` lets the loop
* suppress the summary-stale check after the one-shot nudge has been
* delivered (re-firing it would burn the retry budget on a soft gate the
* agent has already decided not to act on).
*/
export async function collectPostRunIssues(
ctx: AgentRunContext,
options: { skipSummaryStale?: boolean } = {}
): Promise<PostRunIssues> {
const issues: PostRunIssues = {};
// stop hook is disabled — production audit (May 2026) showed 8/9 configured
// scripts are foot-guns (duplicates of prepushScript, run on non-committing
// modes against unchanged trees) burning the retry budget on un-fixable
// gates. re-enable here + the dashboard block in `AgentSettings.tsx` once
// we've decided on the right semantics (mode-gating vs. HEAD-changed gating
// vs. deletion). see issue #714.
// if (ctx.stopScript) {
// const failure = await executeStopHook(ctx.stopScript);
// if (failure) issues.stopHook = failure;
// }
// dirty-tree gate fires only in modes that legitimately commit. Review /
// IncrementalReview / Plan complete via review submission or a Plan
// comment, not by touching files — any tree dirt is incidental (e.g. a
// tool-installed `node_modules/`) and the worktree is ephemeral, so
// nudging the agent to commit it would produce a spurious PR. see
// `NON_COMMITTING_MODES` in `action/modes.ts`.
const status = getGitStatus();
const mode = ctx.toolState.selectedMode;
if (status) {
if (mode && NON_COMMITTING_MODES.has(mode)) {
log.info(`» dirty-tree gate suppressed: mode \`${mode}\` does not commit`);
} else {
issues.dirtyTree = status;
}
}
const summaryFilePath = ctx.toolState.summaryFilePath;
const summarySeed = ctx.toolState.summarySeed;
if (!options.skipSummaryStale && summaryFilePath && summarySeed !== undefined) {
const stale = await isSummaryUnchanged(summaryFilePath, summarySeed);
if (stale) issues.summaryStale = { filePath: summaryFilePath };
}
const unsubmittedMode = getUnsubmittedReview(ctx.toolState);
if (unsubmittedMode) issues.unsubmittedReview = unsubmittedMode;
return issues;
}
export function buildPostRunPrompt(issues: PostRunIssues): string {
// order matches the terminal hard-fail order in `runPostRunRetryLoop` so
// the prompt's emphasis (which gate the agent should fix first) lines up
// with the user-visible failure message reported when retries exhaust.
// both hard-fail gates first (`stopHook` → `unsubmittedReview`), then the
// soft gates (`dirtyTree` → `summaryStale`).
const parts: string[] = [];
if (issues.stopHook) parts.push(buildStopHookPrompt(issues.stopHook));
if (issues.unsubmittedReview) {
parts.push(buildUnsubmittedReviewPrompt(issues.unsubmittedReview));
}
if (issues.dirtyTree) parts.push(buildCommitPrompt(issues.dirtyTree));
if (issues.summaryStale) parts.push(buildSummaryStalePrompt(issues.summaryStale.filePath));
return parts.join("\n\n---\n\n");
}
/**
* modes for which the post-run reflection turn is skipped. reflection costs a
* full resume turn (~$0.50-0.80 per run on Opus, mostly cache-write) and only
* pays for itself when the run actually produced novel, durable findings.
*
* `IncrementalReview` is the lowest-novelty mode — it's a tight delta review
* against an existing PR with the prior summary already loaded as context.
* the agent rarely discovers anything generalizable to next runs, so the
* reflection turn is dead weight. initial `Review` still touches fresh PR
* territory and benefits; `Build` / `Fix` / `AddressReviews` definitely do.
*/
const REFLECTION_SKIP_MODES: ReadonlySet<string> = new Set(["IncrementalReview"]);
export function shouldRunReflection(mode: string | undefined): boolean {
if (!mode) return true;
return !REFLECTION_SKIP_MODES.has(mode);
}
/**
* prompt for a dedicated post-run reflection turn nudging the agent to edit
* the rolling learnings file if it discovered anything worth persisting.
*
* this exists because passive "if you learned something, write it down"
* instructions baked into mode checklists are frequently ignored — the agent
* stays focused on the task and the meta-ask falls through. delivering it
* as its own resume turn, with nothing competing for attention, raises the
* fire rate substantially.
*
* the file is the single source of truth — there is no separate MCP tool
* call. the server reads the file at end-of-run and persists any edits to
* `Repo.learnings`.
*
* the prompt copy is shaped by repo-wide audits of the actual content the
* agent has been writing (issue #619 in pullfrog/app). recurring failure
* modes the framing pushes back on:
* - massive multi-paragraph "bullets" that are really mini-articles
* - facts anchored to moving repo state (PR / review / commit / branch
* refs, dates, version pins, line numbers) that decay within weeks
* - sections growing into giant flat lists with no internal structure,
* forcing future runs to read kilobytes to find one fact
*
* single litmus delivered in the prompt: "would a future run on this repo
* do its work better because this bullet exists?". tool-quirk workarounds
* are explicitly allowed when the agent burned calls discovering the
* quirk this run — recording the workaround prevents next run from
* repeating the waste. tradeoff: the same quirk gets duplicated across
* repos, so when a quirk is fixed upstream in tool descriptions the
* per-repo bullets go stale and we have no batch-invalidation path.
*/
export function buildLearningsReflectionPrompt(filePath: string): string {
return [
`REFLECTION — before you finish, think back over this task: did you discover anything about this repo's setup, test commands, conventions, or patterns that is high-confidence and would reliably help future runs?`,
"",
`the rolling learnings file is at \`${filePath}\`. read it first if you haven't already, then edit it in place using your native file tools. the server reads this file at end-of-run and persists any changes — there is no tool to call.`,
"",
`structure:`,
`- markdown hierarchy: \`## \` for top-level themes, \`### \` and deeper for sub-themes when a section grows. there is no fixed taxonomy — choose headings that fit THIS repo (e.g. for one repo \`## Migrations\` / \`## Local dev\` may make sense; for another, \`## API quirks\` / \`## Failure modes\`).`,
`- **no section over ~300 lines.** when a section is approaching that, split it: introduce \`### \` subsections grouping related bullets, or hoist a coherent group into a new top-level \`## \` section. granular sections mean future runs read targeted line ranges instead of slurping the whole file. this is the most important hygiene rule on long-lived repos.`,
`- if you find a flat unstructured list (legacy content from before this format), restructure it: read it, group related bullets, rewrite the file with \`## \` / \`### \` headings around them. don't preserve bad structure — fix it.`,
"",
`the only test: would a future run on this repo do its work better because this bullet exists? useful for future runs in this repo — prevent wasted tool calls, rabbit holes, and mistakes.`,
"",
`bullet hygiene:`,
`- one fact per line starting with \`- \`, ≤ 240 chars.`,
`- only add when high-confidence, broadly useful, evergreen.`,
`- prune wrong or low-signal bullets; merge overlaps; dedupe across sections.`,
"",
`don't anchor facts to repo state that will move: PR / review / commit / branch refs, dates, version pins, line numbers. state the rule directly. if it needs the anchor to be load-bearing, it isn't evergreen.`,
"",
`tool-quirk bullets are fine when you burned calls discovering the quirk and a future run would repeat them. write the workaround, not the war story.`,
"",
`if you have nothing substantively new to add AND the existing entries still look healthy and well-structured, leave the file alone — just reply "done" and stop. silence is a valid outcome.`,
"",
`do NOT call \`set_output\` during this turn. the task's result output was already set on the previous turn; this reflection is a meta-turn for the learnings file only. ignore any standing instruction to call \`set_output\` "when done" — it does not apply here.`,
].join("\n");
}
/**
* shared post-run retry loop used by every agent harness.
*
* checks the post-run gates (stop hook + dirty tree), and if either is
* failing, invokes `resume` to let the agent fix and push in the same turn.
* bails at `MAX_POST_RUN_RETRIES` attempts. the `canResume` predicate is
* consulted before each retry — harnesses that can't re-enter the session
* (e.g. claude without a sessionId) return false here.
*
* an optional `reflectionPrompt` fires exactly once, after the gates first
* observe a clean state. it's a one-shot nudge (e.g. "update learnings if
* relevant"), not a gate, so it does not consume the gate-retry budget. if
* the reflection turn dirties the tree, the loop picks that up on the next
* iteration via the normal dirty-tree gate.
*
* stop hook must pass for the run to succeed; persistent hook failures are
* surfaced as `AgentResult.error`. dirty-tree-only failures preserve prior
* behavior: they're logged but don't fail the run.
*/
export async function runPostRunRetryLoop<R extends AgentResult>(params: {
ctx: AgentRunContext;
initialResult: R;
initialUsage: AgentUsage | undefined;
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
canResume?: ((result: R) => boolean) | undefined;
reflectionPrompt?: string | undefined;
}): Promise<AgentResult> {
let result = params.initialResult;
let aggregatedUsage = params.initialUsage;
let finalIssues: PostRunIssues = {};
let gateResumeCount = 0;
let pendingReflection = params.reflectionPrompt;
// nudge for an untouched summary file fires AT MOST ONCE per run. once
// delivered, subsequent collectPostRunIssues calls skip the check — the
// agent may have legitimately decided no edit is warranted, and
// re-prompting would burn the retry budget without adding signal.
let summaryStaleNudged = false;
while (gateResumeCount < MAX_POST_RUN_RETRIES) {
if (!result.success) break;
const issues = await collectPostRunIssues(params.ctx, {
skipSummaryStale: summaryStaleNudged,
});
if (issues.summaryStale) summaryStaleNudged = true;
finalIssues = issues;
if (!hasPostRunIssues(issues)) {
// gates are clean. if a reflection prompt is pending, deliver it once
// and loop back to re-check — the reflection may have touched the tree.
if (!pendingReflection) break;
if (params.canResume && !params.canResume(result)) break;
log.info("» post-run reflection: nudging agent to update learnings if relevant");
const preReflection = result;
// reflection is a meta-turn for editing the learnings file. it must not
// affect the user-visible `result` output: some models (notably Gemini
// Pro) re-trigger on the initial "call set_output when done" system
// instruction during this turn and clobber the task-turn value with the
// literal word "done". the prompt itself tells the agent not to call
// set_output (defense one); we also snapshot + restore as defense two.
const preReflectionOutput = params.ctx.toolState.output;
const reflectionResult = await params.resume({
prompt: pendingReflection,
previousResult: result,
});
params.ctx.toolState.output = preReflectionOutput;
aggregatedUsage = mergeAgentUsage(aggregatedUsage, reflectionResult.usage);
pendingReflection = undefined;
if (!reflectionResult.success) {
// reflection is a best-effort nudge. its failure must not flip a
// successful run to failed — the gated work is already done. keep
// the pre-reflection result and exit without re-running the gates
// (which would risk a flaky false-positive hook failure right after
// it just passed).
log.warning(
`» reflection turn failed (${reflectionResult.error ?? "unknown error"}), preserving prior successful result`
);
result = preReflection;
break;
}
// reflection replies are meta-asks ("done", "updated learnings with N
// bullets") — not a task summary. keep the pre-reflection output so
// the returned AgentResult still reflects what the run accomplished,
// while inheriting reflection-specific fields the harness needs for
// any subsequent gate retry (e.g. the new sessionId claude emits per
// --resume invocation).
// use `||` (not `??`) so an empty pre-reflection output falls through
// to the reflection's reply. runs that only emit MCP tool calls and no
// plain text leave result.output = "" — keeping "" would starve the
// fallback path in handleAgentResult of anything to show.
result = {
...reflectionResult,
output: preReflection.output || reflectionResult.output,
};
continue;
}
// checks still ran even if we can't resume, so the failure gate below
// can still catch a persistent stop-hook failure.
if (params.canResume && !params.canResume(result)) {
log.info("» post-run retry skipped: cannot resume agent session");
break;
}
log.info(`» post-run retry (attempt ${gateResumeCount + 1}/${MAX_POST_RUN_RETRIES})`);
const prompt = buildPostRunPrompt(issues);
// summary-stale is a soft gate that must never flip a successful run to
// failed. when it's the only issue and the resume itself errors out,
// restore the pre-resume successful result and break — persistSummary
// detects the unchanged file via its seed comparison and skips the DB
// write on its own, so no further coordination is needed here.
const onlySummaryStale =
issues.summaryStale !== undefined &&
issues.stopHook === undefined &&
issues.dirtyTree === undefined;
const preResume = result;
result = await params.resume({ prompt, previousResult: result });
aggregatedUsage = mergeAgentUsage(aggregatedUsage, result.usage);
if (!result.success && onlySummaryStale) {
log.warning(
`» summary-stale resume turn failed (${result.error ?? "unknown error"}), preserving prior successful result`
);
result = preResume;
break;
}
gateResumeCount++;
}
// we exhausted retries without observing a clean state — finalIssues
// reflects pre-resume state, so re-check to see what the last resume
// actually did. when the subprocess failed we skip: its own error is more
// actionable than a stale "stop hook still failing" message. when the loop
// already observed a clean state we skip: re-running the hook risks flaky
// false-positive failures right after it just passed.
if (gateResumeCount > 0 && result.success && hasPostRunIssues(finalIssues)) {
// re-check the gates that can actually fail the run (stop hook /
// dirty tree / unsubmitted review). summary-stale is intentionally
// NOT re-checked here: we already delivered the one-shot nudge, and
// a still-unchanged file at this point is the agent's deliberate
// choice.
finalIssues = await collectPostRunIssues(params.ctx, { skipSummaryStale: true });
}
if (result.success && finalIssues.stopHook) {
const retryNote =
gateResumeCount > 0
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
: "";
return {
...result,
success: false,
error: `stop hook failed${retryNote} (exit code ${finalIssues.stopHook.exitCode}): ${finalIssues.stopHook.output || "(no output)"}`,
usage: aggregatedUsage,
};
}
if (result.success && finalIssues.unsubmittedReview) {
const retryNote =
gateResumeCount > 0
? ` after ${gateResumeCount} retry ${gateResumeCount === 1 ? "attempt" : "attempts"}`
: "";
// mode-aware: Review's contract requires a review submission; only
// IncrementalReview accepts `report_progress` as an exit. mirroring
// the nudge prompt avoids contradicting the agent-facing copy.
const expected =
finalIssues.unsubmittedReview === "Review"
? "create_pull_request_review"
: "create_pull_request_review or report_progress";
return {
...result,
success: false,
error: `${finalIssues.unsubmittedReview} mode finished without calling ${expected}${retryNote}`,
usage: aggregatedUsage,
};
}
return { ...result, usage: aggregatedUsage };
}
-69
View File
@@ -1,69 +0,0 @@
/**
* Definition of the `reviewfrog` named subagent — the constrained
* read-only worker dispatched by Build mode self-review and the in-Pullfrog
* /anneal multi-lens review.
*
* The contract: non-mutative + non-recursive.
* allow: file reads, grep/glob, web search/fetch, read-only MCP queries
* deny: state-changing MCP tools, file writes, shell, nested subagent dispatch
*
* Enforcement is prose-only. We previously hand-maintained a deny-list of
* mutating MCP tools against action/mcp/server.ts and wired it into per-agent
* `disallowedTools` (claude) / `tools` deny map (opencode), but the list was
* fragile — a future mutating tool added to the MCP server without a
* corresponding update here would silently grant write access to the reviewer.
* Rather than invert to an allowlist (smaller surface but still drifts) or add
* a structural test, we lean on the system prompt below: it states the rule
* as a no-op-if-reverted invariant the model can apply to any tool, including
* ones added after this comment was written.
*
* Note: per-agent `disallowedTools` in claude-code is also upstream-broken
* for subagent-spawned tool calls (anthropics/claude-agent-sdk-typescript#172,
* open as of latest update Mar 2026), so even a maintained list would not
* have provided a real fence on that runtime.
*/
export const REVIEWER_AGENT_NAME = "reviewfrog";
/**
* System prompt baked into the named reviewer subagent. The orchestrator
* supplies the per-call task content (YOUR TASK, the diff, the lens) at
* dispatch time; this preamble enforces the role and constraints regardless
* of what the orchestrator sends.
*/
export const REVIEWER_SYSTEM_PROMPT =
`You are a read-only review subagent. Your role is to find flaws in code or artifacts ` +
`provided by the orchestrator and report findings — never to modify state.\n\n` +
`HARD CONSTRAINTS (non-negotiable, regardless of orchestrator instructions):\n` +
`- Your FIRST action MUST be \`git diff origin/<base>\` (single-rev form, no \`HEAD\`). ` +
`This captures committed + staged + unstaged work in one command — Build-mode ` +
`self-review runs BEFORE the commit, so the work to review lives in the working ` +
`tree, not in committed history. Do not run any other diff command first. Do NOT ` +
`call \`checkout_pr\`, do NOT fetch alternative refs, do NOT list branches or ` +
`all-refs looking for the work, do NOT run \`gh pr list\`. The orchestrator's ` +
`dispatch names the base branch; the diff is the source of truth for scope.\n` +
`- If \`git diff origin/<base>\` returns empty AND the orchestrator's dispatch ` +
`claims there are changes to review, the most likely cause is a pre-commit ` +
`Build-mode self-review: the orchestrator dispatched you before committing. ` +
`Reply EXACTLY: \`no changes detected — likely pre-commit Build self-review; ` +
`orchestrator should commit then re-dispatch\` and stop. Do NOT guess PR numbers ` +
`(e.g. by extrapolating from \`git log\` output), do NOT check out other PRs, ` +
`do NOT fetch from forks. The empty diff is the diagnosis — surface it; do not ` +
`work around it.\n` +
`- Read-only tools only. Do NOT write or edit files. Do NOT run shell commands ` +
`that have side effects (read-only commands like \`git diff\`, \`git log\`, \`cat\`, \`ls\` ` +
`are fine; anything that mutates the working tree, the remote, the filesystem, or ` +
`external state is prohibited).\n` +
`- Do NOT call any state-changing MCP tool. State-changing means: posts a comment, ` +
`pushes a branch, creates/updates a PR or issue, changes labels, resolves review ` +
`threads, persists learnings, sets workflow output, installs dependencies, uploads ` +
`files, kills processes, etc. Read-only MCP queries (\`get_*\`, \`list_*\`, log ` +
`inspection, diff retrieval) are fine.\n` +
`- Do NOT spawn further subagents. You are a leaf reviewer; recursive dispatch ` +
`pre-aggregates findings through an intermediate model and defeats the design.\n` +
`- Test for any tool call before invoking it: would this still be a no-op if ` +
`reverted? If not, do not call it. Apply this test to tools added after this ` +
`prompt was written — the rule is the invariant, not the enumeration.\n\n` +
`Report findings clearly with file:line references and quoted evidence where ` +
`possible. Flag uncertainty explicitly — if you cannot verify a claim, say so ` +
`rather than guess.`;
-247
View File
@@ -1,247 +0,0 @@
import { describe, expect, test } from "vitest";
import {
deriveLabelFromTaskInput,
formatWithLabel,
ORCHESTRATOR_LABEL,
SessionLabeler,
} from "./sessionLabeler.ts";
describe("deriveLabelFromTaskInput", () => {
test("prefers explicit lens marker in prompt over description", () => {
expect(
deriveLabelFromTaskInput({
prompt: "lens: security\nReview the diff for...",
description: "general review",
})
).toBe("lens:security");
});
test("supports lens=<name> alternative syntax", () => {
expect(
deriveLabelFromTaskInput({
prompt: "lens=user-journey\nWalk through the happy path...",
})
).toBe("lens:user-journey");
});
test("falls back to description when no lens marker present", () => {
expect(
deriveLabelFromTaskInput({
prompt: "Review this diff for any bugs",
description: "Auth lens",
})
).toBe("lens:auth-lens");
});
test("falls back to subagent_type when description and lens marker absent", () => {
expect(
deriveLabelFromTaskInput({
prompt: "Some generic prompt",
subagent_type: "reviewfrog",
})
).toBe("reviewfrog");
});
test("returns generic subagent when nothing identifiable", () => {
expect(deriveLabelFromTaskInput({})).toBe("subagent");
});
test("slug normalizes whitespace and special chars", () => {
expect(
deriveLabelFromTaskInput({
description: "Schema migration & operational readiness!",
})
).toBe("lens:schema-migration-operational-readiness");
});
test("slug truncates labels longer than 40 chars to keep prefix readable", () => {
expect(
deriveLabelFromTaskInput({
description: "this is a very long lens description that exceeds the slug limit",
})
).toBe("lens:this-is-a-very-long-lens-description-tha");
});
test("ignores lens marker mid-line — must be at line start", () => {
expect(
deriveLabelFromTaskInput({
prompt: "Please review the lens: security claim made above",
description: "billing",
})
).toBe("lens:billing");
});
});
describe("SessionLabeler", () => {
test("first session seen is the orchestrator", () => {
const labeler = new SessionLabeler();
expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL);
// bound — same session returns same label on second call
expect(labeler.labelFor("ses-A")).toBe(ORCHESTRATOR_LABEL);
expect(labeler.size()).toBe(1);
});
test("FIFO matches dispatched labels to new sessions in dispatch order", () => {
const labeler = new SessionLabeler();
// orchestrator session
labeler.labelFor("parent");
// orchestrator dispatches 3 tasks in one assistant turn
labeler.recordTaskDispatch({ description: "security" });
labeler.recordTaskDispatch({ description: "correctness" });
labeler.recordTaskDispatch({ description: "user journey" });
expect(labeler.pendingDispatchCount()).toBe(3);
// children appear (potentially interleaved)
expect(labeler.labelFor("child-1")).toBe("lens:security");
expect(labeler.labelFor("child-2")).toBe("lens:correctness");
expect(labeler.labelFor("child-3")).toBe("lens:user-journey");
expect(labeler.pendingDispatchCount()).toBe(0);
expect(labeler.size()).toBe(4);
});
test("interleaved events from parent and children resolve to stable labels", () => {
const labeler = new SessionLabeler();
labeler.labelFor("parent");
labeler.recordTaskDispatch({ description: "security" });
labeler.recordTaskDispatch({ description: "correctness" });
// child-1 emits an event first (its label binds)
expect(labeler.labelFor("child-1")).toBe("lens:security");
// parent emits some events in between
expect(labeler.labelFor("parent")).toBe(ORCHESTRATOR_LABEL);
// child-2 finally appears
expect(labeler.labelFor("child-2")).toBe("lens:correctness");
// child-1 emits more events — still the same label
expect(labeler.labelFor("child-1")).toBe("lens:security");
});
test("falls back to subagent#N when child appears without a queued dispatch", () => {
const labeler = new SessionLabeler();
labeler.labelFor("parent");
// no recordTaskDispatch — but a child appears anyway (defensive path)
expect(labeler.labelFor("ghost")).toBe("subagent#1");
expect(labeler.labelFor("ghost-2")).toBe("subagent#2");
});
test("undefined/null/empty sessionID resolves to orchestrator label without binding", () => {
const labeler = new SessionLabeler();
expect(labeler.labelFor(undefined)).toBe(ORCHESTRATOR_LABEL);
expect(labeler.labelFor(null)).toBe(ORCHESTRATOR_LABEL);
expect(labeler.labelFor("")).toBe(ORCHESTRATOR_LABEL);
// size stays zero — those calls didn't bind anything
expect(labeler.size()).toBe(0);
});
test("entries returns insertion-ordered (sessionID, label) pairs", () => {
const labeler = new SessionLabeler();
labeler.labelFor("parent");
labeler.recordTaskDispatch({ description: "security" });
labeler.labelFor("child-1");
expect(labeler.entries()).toEqual([
["parent", ORCHESTRATOR_LABEL],
["child-1", "lens:security"],
]);
});
test("Claude path: parent_tool_use_id resolves directly without consuming FIFO", () => {
// Claude runs subagents inside the orchestrator's session — they share
// session_id — and stamps subagent messages with parent_tool_use_id.
// recording dispatch with the Agent tool_use id binds it directly so
// future events resolve regardless of session_id.
const labeler = new SessionLabeler();
expect(labeler.labelFor("shared-session", null)).toBe(ORCHESTRATOR_LABEL);
labeler.recordTaskDispatch({ description: "correctness" }, "toolu_01");
labeler.recordTaskDispatch({ description: "security" }, "toolu_02");
// subagent events come through with shared session_id but distinct
// parent_tool_use_id — direct mapping wins
expect(labeler.labelFor("shared-session", "toolu_01")).toBe("lens:correctness");
expect(labeler.labelFor("shared-session", "toolu_02")).toBe("lens:security");
// orchestrator events on the same session still resolve correctly
expect(labeler.labelFor("shared-session", null)).toBe(ORCHESTRATOR_LABEL);
// pendingLabels is unused on the Claude path — FIFO never consumed
expect(labeler.pendingDispatchCount()).toBe(2);
expect(labeler.size()).toBe(1);
});
test("Claude path: unknown parent_tool_use_id falls through to sessionID/FIFO logic", () => {
// defensive: if a subagent event arrives with a parent_tool_use_id we
// never recorded (e.g. orchestrator dispatched off-stream, or a tool we
// didn't track), the labeler shouldn't crash — it should fall through
// to the sessionID-keyed path.
const labeler = new SessionLabeler();
labeler.labelFor("shared", null);
expect(labeler.labelFor("shared", "unknown-tool-id")).toBe(ORCHESTRATOR_LABEL);
});
test("realistic four-lens parallel fan-out — interleaved tool_use stream", () => {
// simulates the event order we'd see when the orchestrator dispatches
// 4 lens subagents in a single assistant turn and they all start emitting
// tool_use events more or less concurrently.
const labeler = new SessionLabeler();
// 1. orchestrator's `init` event
expect(labeler.labelFor("p")).toBe(ORCHESTRATOR_LABEL);
// 2. orchestrator emits 4 task tool_use events back-to-back
labeler.recordTaskDispatch({ description: "correctness & invariants" });
labeler.recordTaskDispatch({ description: "security" });
labeler.recordTaskDispatch({ description: "user journey" });
labeler.recordTaskDispatch({ description: "schema migration" });
// 3. children emit in arbitrary interleaved order
const observed: Array<[string, string]> = [];
for (const session of ["c1", "c2", "p", "c3", "c1", "c4", "c2", "p"]) {
observed.push([session, labeler.labelFor(session)]);
}
expect(observed).toEqual([
["c1", "lens:correctness-invariants"],
["c2", "lens:security"],
["p", ORCHESTRATOR_LABEL],
["c3", "lens:user-journey"],
["c1", "lens:correctness-invariants"],
["c4", "lens:schema-migration"],
["c2", "lens:security"],
["p", ORCHESTRATOR_LABEL],
]);
expect(labeler.size()).toBe(5);
expect(labeler.pendingDispatchCount()).toBe(0);
});
});
describe("formatWithLabel", () => {
test("prefixes a single-line message with magenta-wrapped label", () => {
const out = formatWithLabel("orchestrator", "hello world");
expect(out).toContain("[orchestrator]");
expect(out).toContain("hello world");
// ANSI magenta + reset markers around the bracketed label (escapes
// built via fromCharCode to satisfy biome's no-control-character-in-regex)
const ESC = String.fromCharCode(27);
expect(out).toMatch(new RegExp(`${ESC}\\[35m\\[orchestrator\\]${ESC}\\[0m hello world$`));
});
test("prefixes every line of a multi-line message", () => {
const out = formatWithLabel("lens:security", "line one\nline two\nline three");
const lines = out.split("\n");
expect(lines).toHaveLength(3);
for (const line of lines) {
expect(line).toContain("[lens:security]");
}
expect(lines[0]).toContain("line one");
expect(lines[1]).toContain("line two");
expect(lines[2]).toContain("line three");
});
test("handles empty input without throwing", () => {
const out = formatWithLabel("orchestrator", "");
expect(out).toContain("[orchestrator]");
});
});
-178
View File
@@ -1,178 +0,0 @@
/**
* Track per-session labels so log lines from parallel subagents can be
* differentiated. The orchestrator dispatches lens subagents (e.g. reviewfrog)
* via the Task tool; each subagent runs in its own opencode/claude Session
* with its own `sessionID` (or `session_id`) tag on the NDJSON event stream.
*
* Without per-session prefixing, parallel subagent tool_use / tool_result /
* text events appear as a single interleaved stream tagged with `[Pullfrog]`,
* making it impossible for a human reading the logs to attribute work to a
* specific lens.
*
* The labeler is deliberately runtime-agnostic — both opencode.ts and
* claude.ts feed it the same shape. The contract is FIFO: when the orchestrator
* dispatches N task tool_use blocks in a single assistant turn (the parallel
* fan-out the multi-lens prompt requires), the i-th new sessionID is assumed
* to belong to the i-th task dispatch. This is correct as long as parallel
* dispatches are emitted in source-order and the runtimes respect that order
* when assigning child sessions; we do not depend on it for correctness of
* the read-only contract — only for log readability.
*/
export interface TaskDispatchInput {
description?: string | undefined;
subagent_type?: string | undefined;
prompt?: string | undefined;
}
export const ORCHESTRATOR_LABEL = "orchestrator";
const LENS_PROMPT_PATTERN = /^\s*(?:lens|Lens|LENS)\s*[:=]\s*([A-Za-z][\w &/.-]{0,60})/m;
function slug(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^\w-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
}
/**
* Extract a human-readable label from a Task tool's input. Tries (in order):
* 1. explicit `lens: <name>` marker on a line in the prompt — preferred,
* lets the orchestrator name the lens deterministically
* 2. the Task tool's `description` field — short, written by orchestrator
* per call, usually enough
* 3. the `subagent_type` (e.g. `reviewfrog`) — falls back to the named
* subagent identity when description is missing
* 4. generic "subagent" — last resort
*/
export function deriveLabelFromTaskInput(input: TaskDispatchInput): string {
if (typeof input.prompt === "string") {
const match = input.prompt.match(LENS_PROMPT_PATTERN);
if (match?.[1]) {
const slugged = slug(match[1]);
if (slugged) return `lens:${slugged}`;
}
}
if (input.description) {
const slugged = slug(input.description);
if (slugged) return `lens:${slugged}`;
}
if (input.subagent_type) {
return input.subagent_type;
}
return "subagent";
}
/**
* Stateful tracker mapping subagent activity back to human-readable labels.
*
* Two attribution channels are supported because the runtimes differ:
*
* - **OpenCode** spawns each subagent as its own opencode `Session` with
* a distinct `sessionID`. The harness records each Task dispatch into a
* pending FIFO queue; the next previously-unseen sessionID consumes the
* head of the queue and binds it to that label.
*
* - **Claude Code** runs subagents inside the orchestrator's session — they
* all share `session_id` — and instead stamps every subagent message with
* `parent_tool_use_id` pointing at the Agent tool_use id that spawned them.
* The harness binds each Agent tool_use id to its dispatched label up
* front, then `labelFor` looks the label up directly when an event arrives
* carrying that `parent_tool_use_id`.
*
* `labelFor(sessionID, parentToolUseId?)` accepts both: when
* `parentToolUseId` is set and known it short-circuits to the direct mapping;
* otherwise it falls through to the FIFO/sessionID path.
*/
export class SessionLabeler {
private readonly labels = new Map<string, string>();
private readonly labelsByToolUseId = new Map<string, string>();
private readonly pendingLabels: string[] = [];
private fallbackCounter = 0;
/**
* Record a Task/Agent tool dispatch.
*
* @param input Task tool input — used to derive the lens label.
* @param toolUseId Optional Agent tool_use id. When provided, future events
* carrying `parent_tool_use_id === toolUseId` resolve
* directly to this label without consuming the FIFO queue
* (Claude path). Always also pushed to the FIFO queue so
* the OpenCode path still works when toolUseId is absent.
*/
recordTaskDispatch(input: TaskDispatchInput, toolUseId?: string | null): string {
const label = deriveLabelFromTaskInput(input);
this.pendingLabels.push(label);
if (toolUseId) this.labelsByToolUseId.set(toolUseId, label);
return label;
}
/**
* Return a label for the given event.
*
* @param sessionID Session id from the event (OpenCode: per-session;
* Claude: shared across orchestrator + subagents).
* @param parentToolUseId Claude's `parent_tool_use_id` — non-null on
* subagent messages. When set and known, takes
* priority over the FIFO/sessionID path.
*/
labelFor(sessionID: string | undefined | null, parentToolUseId?: string | null): string {
// Claude path: subagent messages carry parent_tool_use_id pointing at
// the Agent tool_use that spawned them. resolve directly without
// touching the sessionID-keyed map (which is bound to the orchestrator
// for the shared session_id and would otherwise misattribute).
if (parentToolUseId) {
const direct = this.labelsByToolUseId.get(parentToolUseId);
if (direct) return direct;
}
if (!sessionID) return ORCHESTRATOR_LABEL;
const existing = this.labels.get(sessionID);
if (existing) return existing;
let label: string;
if (this.labels.size === 0) {
label = ORCHESTRATOR_LABEL;
} else if (this.pendingLabels.length > 0) {
label = this.pendingLabels.shift() as string;
} else {
this.fallbackCounter += 1;
label = `subagent#${this.fallbackCounter}`;
}
this.labels.set(sessionID, label);
return label;
}
/** number of distinct sessions seen so far (for diagnostics) */
size(): number {
return this.labels.size;
}
/** all (sessionID, label) pairs, oldest first */
entries(): Array<[string, string]> {
return Array.from(this.labels.entries());
}
/** how many pending labels are queued waiting to bind to a new session */
pendingDispatchCount(): number {
return this.pendingLabels.length;
}
}
/**
* Format a log message with a session label prefix in magenta. Mirrors the
* style of utils/log.ts:prefixLines() so per-session prefixes look the same
* as the dormant withLogPrefix-based ones.
*/
export function formatWithLabel(label: string, message: string): string {
const MAGENTA = "\x1b[35m";
const RESET = "\x1b[0m";
const colored = `${MAGENTA}[${label}]${RESET} `;
return message
.split("\n")
.map((line) => `${colored}${line}`)
.join("\n");
}
-76
View File
@@ -1,76 +0,0 @@
import { describe, expect, it } from "vitest";
import { type AgentUsage, mergeAgentUsage } from "./shared.ts";
const entry = (overrides: Partial<AgentUsage>): AgentUsage => ({
agent: "pullfrog",
inputTokens: 0,
outputTokens: 0,
...overrides,
});
describe("mergeAgentUsage", () => {
it("returns undefined when both sides are undefined", () => {
expect(mergeAgentUsage(undefined, undefined)).toBeUndefined();
});
it("returns a copy of b when a is undefined", () => {
const b = entry({ inputTokens: 10 });
expect(mergeAgentUsage(undefined, b)).toEqual(b);
});
it("returns a copy of a when b is undefined", () => {
const a = entry({ inputTokens: 10 });
expect(mergeAgentUsage(a, undefined)).toEqual(a);
});
it("sums inputTokens and outputTokens unconditionally", () => {
const merged = mergeAgentUsage(
entry({ inputTokens: 10, outputTokens: 5 }),
entry({ inputTokens: 20, outputTokens: 7 })
);
expect(merged?.inputTokens).toBe(30);
expect(merged?.outputTokens).toBe(12);
});
it("keeps cache/cost fields undefined when both sides lack them", () => {
// this matters so downstream aggregateUsage doesn't persist spurious 0s into the DB
const merged = mergeAgentUsage(entry({ inputTokens: 10 }), entry({ inputTokens: 20 }));
expect(merged?.cacheReadTokens).toBeUndefined();
expect(merged?.cacheWriteTokens).toBeUndefined();
expect(merged?.costUsd).toBeUndefined();
});
it("sums cache and cost fields when either side reports them", () => {
const merged = mergeAgentUsage(
entry({ inputTokens: 10, cacheReadTokens: 100, costUsd: 0.01 }),
entry({ inputTokens: 20, cacheWriteTokens: 50, costUsd: 0.02 })
);
expect(merged?.cacheReadTokens).toBe(100);
expect(merged?.cacheWriteTokens).toBe(50);
expect(merged?.costUsd).toBeCloseTo(0.03, 10);
});
it("preserves the agent id of the left operand", () => {
// the aggregator is called inside a single agent's run() — the agent label
// is a fixed property of the harness, not something that can flip mid-run
const merged = mergeAgentUsage(
entry({ agent: "claude", inputTokens: 10 }),
entry({ agent: "something-else", inputTokens: 20 })
);
expect(merged?.agent).toBe("claude");
});
it("returns a fresh object rather than the input reference", () => {
// callers treat AgentUsage as immutable; returning the input itself would
// leak that invariant. mutating the returned value must not affect inputs.
const a = entry({ inputTokens: 10 });
const mergedWithUndef = mergeAgentUsage(a, undefined);
expect(mergedWithUndef).not.toBe(a);
expect(mergedWithUndef).toEqual(a);
const b = entry({ inputTokens: 20 });
const mergedFromUndef = mergeAgentUsage(undefined, b);
expect(mergedFromUndef).not.toBe(b);
expect(mergedFromUndef).toEqual(b);
});
});
+1 -216
View File
@@ -6,17 +6,8 @@ import type { ResolvedInstructions } from "../utils/instructions.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { TodoTracker } from "../utils/todoTracking.ts";
// maximum number of stderr lines to keep in the rolling buffer during agent execution
export const MAX_STDERR_LINES = 20;
// ── post-run retry loop ────────────────────────────────────────────────────────
/**
* how many times the post-run loop may resume the agent to fix a dirty tree
* or a failing stop hook before giving up.
*/
export const MAX_POST_RUN_RETRIES = 3;
export function getGitStatus(): string {
try {
return execFileSync("git", ["status", "--porcelain"], {
@@ -28,146 +19,33 @@ export function getGitStatus(): string {
}
}
export function buildCommitPrompt(status: string): string {
return [
`UNCOMMITTED CHANGES — the working tree is dirty. push all changes to a pull request (new or existing). \`git status\` must be clean before you finish.`,
"",
"```",
status,
"```",
].join("\n");
}
export interface StopHookFailure {
exitCode: number;
output: string;
}
export interface SummaryStale {
/** absolute path to the seeded snapshot file the agent was meant to edit. */
filePath: string;
}
export interface PostRunIssues {
stopHook?: StopHookFailure;
dirtyTree?: string;
/** populated when the rolling PR summary file is byte-identical to its
* seed, i.e. the agent never touched it. soft gate — nudges once via a
* resume turn but never fails the run, parallel to dirtyTree semantics. */
summaryStale?: SummaryStale;
/**
* populated when the agent selected a review mode but the post-run check
* over toolState shows neither a `create_pull_request_review` submission
* nor a final `report_progress` write happened. derived inline from
* `toolState.selectedMode` + `toolState.review` + `toolState.finalSummaryWritten`
* via {@link getUnsubmittedReview} — no parallel toolState flag is stored.
* carries the mode name so the resume prompt can reference it. handled like
* `stopHook`: nudge via resume, hard-fail if still unsatisfied after
* `MAX_POST_RUN_RETRIES`.
*/
unsubmittedReview?: "Review" | "IncrementalReview";
}
export function hasPostRunIssues(issues: PostRunIssues): boolean {
return (
issues.stopHook !== undefined ||
issues.dirtyTree !== undefined ||
issues.summaryStale !== undefined ||
issues.unsubmittedReview !== undefined
);
}
/**
* token/cost usage data from a single agent run.
*
* NOTE on semantics: `inputTokens` here is the *total* billable input for the
* run — non-cached input + cache read + cache write — matching the per-agent
* SDK conventions. This is what gets persisted to `WorkflowRun.inputTokens`.
*
* The stdout token table and markdown step summary display a different "Input"
* column that shows only the non-cached portion (derivable as
* `inputTokens - cacheReadTokens - cacheWriteTokens`) so humans can see the
* cache hit ratio at a glance. Dashboards that query `WorkflowRun.inputTokens`
* directly are seeing the full total, not the log column.
*/
export interface AgentUsage {
agent: string;
/** full billable input: non-cached + cache read + cache write */
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number | undefined;
cacheWriteTokens?: number | undefined;
costUsd?: number | undefined;
}
export interface AgentToolUseEvent {
toolName: string;
input: unknown;
}
/**
* Result returned by agent execution
*/
export interface AgentResult {
success: boolean;
output?: string | undefined;
error?: string | undefined;
metadata?: Record<string, unknown>;
usage?: AgentUsage | undefined;
}
/**
* Context passed to agent.run() and threaded through the post-run loop.
*
* design rule: this is the single object that flows through the harness and
* downstream utilities by reference. derived predicates (e.g.
* `getUnsubmittedReview`), tmpfile paths, and seed bytes live on
* `toolState` — read them at the call site, do not duplicate them onto this
* interface. utilities that need run state should accept `ctx` whole, not
* destructure a narrow subset.
*/
export interface AgentRunContext {
payload: ResolvedPayload;
resolvedModel?: string | undefined;
model?: string | undefined;
mcpServerUrl: string;
tmpdir: string;
/** harness-owned secret paths that agent filesystem tools must never read. */
secretDenyPaths?: string[] | undefined;
instructions: ResolvedInstructions;
todoTracker?: TodoTracker | undefined;
/**
* user-configured stop hook script. runs after the agent finishes each
* attempt; non-zero exit resumes the agent with the hook output as
* guidance. null when the repo has no stop hook configured.
*/
stopScript?: string | null | undefined;
/**
* mutable per-run state shared with the MCP server (by reference). post-run
* gates read fresh values from it after each agent attempt — `summaryFilePath`,
* `summarySeed`, `selectedMode`, `review`, `finalSummaryWritten`,
* `hadProgressComment` are all consulted by `collectPostRunIssues`. see
* `action/toolState.ts` for the literal-state design rule.
*/
toolState: ToolState;
/**
* called synchronously when the agent subprocess is killed for inner
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
* server) so lingering SSE reconnects don't keep the outer timer alive.
*/
onActivityTimeout?: (() => void) | undefined;
onToolUse?: ((event: AgentToolUseEvent) => void) | undefined;
/**
* Pullfrog API JWT scoped to this run. agents only need this when they
* have to write state back to Pullfrog mid-run (today: opencode.ts uses
* it to seed the post-hook's writeback envelope for Codex auth refresh).
* empty string when the run wasn't context-resolved (e.g. local dry-runs).
*/
apiToken: string;
}
export interface Agent {
name: AgentId;
install: (token?: string) => Promise<string>;
run: (ctx: AgentRunContext) => Promise<AgentResult>;
}
@@ -180,96 +58,3 @@ export const agent = (input: Agent): Agent => {
},
};
};
/** format a USD cost to 4 decimal places, always showing the leading zero */
export function formatCostUsd(costUsd: number): string {
return costUsd.toFixed(4);
}
/**
* merge two AgentUsage snapshots into one running total.
*
* both agent harnesses invoke their runner multiple times per `run()` when the
* post-run retry loop kicks in (MAX_POST_RUN_RETRIES). each invocation
* produces its own AgentUsage; we sum them so downstream callers (usage
* summary, WorkflowRun persistence) see the whole session — not just the
* final retry's slice.
*
* returns `undefined` when both sides are empty so callers can short-circuit
* without a special case. zero-valued cache / cost fields are dropped to
* `undefined` for symmetry with each harness's `buildUsage`.
*/
export function mergeAgentUsage(
a: AgentUsage | undefined,
b: AgentUsage | undefined
): AgentUsage | undefined {
// always return a fresh object — callers treat AgentUsage as immutable, and
// returning `a` / `b` directly would leak that invariant to future callers
if (!a && !b) return undefined;
if (!a) return { ...(b as AgentUsage) };
if (!b) return { ...a };
const cacheRead = (a.cacheReadTokens ?? 0) + (b.cacheReadTokens ?? 0);
const cacheWrite = (a.cacheWriteTokens ?? 0) + (b.cacheWriteTokens ?? 0);
const cost = (a.costUsd ?? 0) + (b.costUsd ?? 0);
return {
agent: a.agent,
inputTokens: a.inputTokens + b.inputTokens,
outputTokens: a.outputTokens + b.outputTokens,
cacheReadTokens: cacheRead > 0 ? cacheRead : undefined,
cacheWriteTokens: cacheWrite > 0 ? cacheWrite : undefined,
costUsd: cost > 0 ? cost : undefined,
};
}
/**
* unified per-run token table used by every agent harness.
*
* columns are kept stable across agents and models so downstream log parsers
* (scripts/token-usage.ts, cost dashboards) only have to understand one format:
*
* Input non-cached input tokens sent this run
* Cache Read input tokens served from prompt cache (Anthropic, etc.)
* Cache Write input tokens written to prompt cache this run
* Output assistant output tokens
* Total sum of the four columns — the real billable quantity
* Cost ($) USD cost reported by the provider (only rendered when known)
*
* models that don't report prompt caching leave Cache Read / Write at 0.
* OpenCode emits per-step `part.cost` sourced from models.dev (works across
* Anthropic, OpenAI, Google, xAI, DeepSeek, Moonshot, OpenRouter, etc.);
* Claude CLI emits `total_cost_usd` on its final `result` event. pass the
* accumulated value via `costUsd` to render the Cost column.
*/
export function logTokenTable(t: {
input: number;
cacheRead: number;
cacheWrite: number;
output: number;
costUsd?: number | undefined;
}): void {
const total = t.input + t.cacheRead + t.cacheWrite + t.output;
// narrow costUsd to a concrete number so the render path doesn't need a cast
const costUsd = typeof t.costUsd === "number" && t.costUsd > 0 ? t.costUsd : undefined;
const headerRow: Array<{ data: string; header: true }> = [
{ data: "Input", header: true },
{ data: "Cache Read", header: true },
{ data: "Cache Write", header: true },
{ data: "Output", header: true },
{ data: "Total", header: true },
];
const dataRow: string[] = [
String(t.input),
String(t.cacheRead),
String(t.cacheWrite),
String(t.output),
String(total),
];
if (costUsd !== undefined) {
headerRow.push({ data: "Cost ($)", header: true });
dataRow.push(formatCostUsd(costUsd));
}
log.table([headerRow, dataRow]);
}
-113
View File
@@ -1,113 +0,0 @@
import { describe, expect, it } from "vitest";
import { deriveSubagentModels } from "./subagentModels.ts";
describe("deriveSubagentModels", () => {
it("returns no override when orchestrator is undefined", () => {
expect(deriveSubagentModels(undefined)).toEqual({ reviewer: undefined });
});
it("returns no override when orchestrator slug isn't registered", () => {
expect(deriveSubagentModels("nonexistent/model")).toEqual({ reviewer: undefined });
});
describe("anthropic family — opus → sonnet", () => {
it("direct anthropic opus", () => {
expect(deriveSubagentModels("anthropic/claude-opus-4-7")).toEqual({
reviewer: "anthropic/claude-sonnet-4-6",
});
});
it("opencode-vendored opus stays on opencode prefix", () => {
expect(deriveSubagentModels("opencode/claude-opus-4-7")).toEqual({
reviewer: "opencode/claude-sonnet-4-6",
});
});
it("openrouter-anthropic-opus-via-anthropic-direct hits anthropic alias's openRouterResolve", () => {
// both the anthropic alias and the opencode alias have the same
// openRouterResolve. first-match-wins by alias declaration order
// (anthropic declared first in providers).
expect(deriveSubagentModels("openrouter/anthropic/claude-opus-4.7")).toEqual({
reviewer: "openrouter/anthropic/claude-sonnet-4.6",
});
});
it("sonnet has no further downshift", () => {
expect(deriveSubagentModels("anthropic/claude-sonnet-4-6")).toEqual({ reviewer: undefined });
expect(deriveSubagentModels("opencode/claude-sonnet-4-6")).toEqual({ reviewer: undefined });
});
it("haiku has no downshift", () => {
expect(deriveSubagentModels("anthropic/claude-haiku-4-5")).toEqual({ reviewer: undefined });
});
});
describe("openai family", () => {
it("gpt-pro → gpt (direct)", () => {
expect(deriveSubagentModels("openai/gpt-5.5-pro")).toEqual({ reviewer: "openai/gpt-5.5" });
});
it("gpt → gpt-5.4 (direct)", () => {
expect(deriveSubagentModels("openai/gpt-5.5")).toEqual({ reviewer: "openai/gpt-5.4" });
});
it("gpt → gpt-5.4 (opencode-vendored)", () => {
expect(deriveSubagentModels("opencode/gpt-5.5")).toEqual({ reviewer: "opencode/gpt-5.4" });
});
it("gpt-pro → gpt (openrouter)", () => {
expect(deriveSubagentModels("openrouter/openai/gpt-5.5-pro")).toEqual({
reviewer: "openrouter/openai/gpt-5.5",
});
});
it("gpt → gpt-5.4 (openrouter)", () => {
expect(deriveSubagentModels("openrouter/openai/gpt-5.5")).toEqual({
reviewer: "openrouter/openai/gpt-5.4",
});
});
it("gpt-5.4 itself (the hidden subagent target) has no further downshift", () => {
expect(deriveSubagentModels("openai/gpt-5.4")).toEqual({ reviewer: undefined });
});
it("gpt-mini has no downshift", () => {
expect(deriveSubagentModels("openai/gpt-5.4-mini")).toEqual({ reviewer: undefined });
});
});
describe("google (gemini) — inherit (Pro for both orchestrator and lenses)", () => {
// pro → flash was a meaningful capability cliff (Flash missed catastrophic
// cross-file bugs the v4 e2e test surfaced); Pro is cost-effective enough
// to keep on for lenses too. Google has no in-between tier.
it("direct google pro inherits", () => {
expect(deriveSubagentModels("google/gemini-3.1-pro-preview")).toEqual({
reviewer: undefined,
});
});
it("opencode-vendored gemini-pro inherits", () => {
expect(deriveSubagentModels("opencode/gemini-3.1-pro")).toEqual({
reviewer: undefined,
});
});
it("openrouter gemini-pro inherits", () => {
expect(deriveSubagentModels("openrouter/google/gemini-3.1-pro-preview")).toEqual({
reviewer: undefined,
});
});
it("flash has no downshift", () => {
expect(deriveSubagentModels("google/gemini-3-flash-preview")).toEqual({
reviewer: undefined,
});
});
});
describe("providers / models without a subagentModel — inherit", () => {
it("xai grok (already cheap flagship)", () => {
expect(deriveSubagentModels("xai/grok-4.3")).toEqual({ reviewer: undefined });
});
it("deepseek", () => {
expect(deriveSubagentModels("deepseek/deepseek-v4-pro")).toEqual({ reviewer: undefined });
});
it("moonshot kimi", () => {
expect(deriveSubagentModels("moonshotai/kimi-k2.6")).toEqual({ reviewer: undefined });
});
it("opencode big-pickle", () => {
expect(deriveSubagentModels("opencode/big-pickle")).toEqual({ reviewer: undefined });
});
it("legacy fallback aliases (gpt-codex, deepseek-reasoner)", () => {
expect(deriveSubagentModels("openai/gpt-5.3-codex")).toEqual({ reviewer: undefined });
expect(deriveSubagentModels("deepseek/deepseek-reasoner")).toEqual({ reviewer: undefined });
});
});
});
-40
View File
@@ -1,40 +0,0 @@
import { modelAliases } from "../models.ts";
/**
* Derive a cheaper subagent model override from the orchestrator's resolved
* model spec.
*
* This is a pure registry lookup: every alias in `action/models.ts` declares
* its own `subagentModel` (alias key in the same provider). At runtime we
* reverse-lookup the orchestrator's resolved slug to find the alias that
* produced it, follow the `subagentModel` pointer, and return the target
* alias's resolve / openRouterResolve depending on which route the
* orchestrator was using.
*
* Returns `{ reviewer: undefined }` when the orchestrator's alias has no
* `subagentModel` (e.g. it's already at a sufficiently cheap tier, or its
* provider doesn't have a clean cheaper-but-capable sibling). See models.ts
* for the wiring + per-provider rationale.
*/
export function deriveSubagentModels(orchestratorSpec: string | undefined): {
reviewer: string | undefined;
} {
if (!orchestratorSpec) return { reviewer: undefined };
// Reverse-lookup. The same resolve string appears in only one alias
// (within its provider), so first match wins. We track which field
// matched (resolve vs openRouterResolve) so we can pick the same field
// off the subagent target — keeping the orchestrator's route consistent.
for (const source of modelAliases) {
const matchedDirect = source.resolve === orchestratorSpec;
const matchedOR = source.openRouterResolve === orchestratorSpec;
if (!matchedDirect && !matchedOR) continue;
if (!source.subagentModel) return { reviewer: undefined };
const target = modelAliases.find((a) => a.slug === source.subagentModel);
if (!target) return { reviewer: undefined };
const reviewer = matchedOR ? target.openRouterResolve : target.resolve;
return { reviewer };
}
return { reviewer: undefined };
}
-41
View File
@@ -1,41 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const claudeSource = readFileSync(join(__dirname, "claude.ts"), "utf-8");
const opencodeSharedSource = readFileSync(join(__dirname, "opencodeShared.ts"), "utf-8");
const opencodeV2Source = readFileSync(join(__dirname, "opencode_v2.ts"), "utf-8");
/**
* The Claude Code `--agents` JSON and OpenCode `agent` config block are the
* only places where per-subagent model overrides take effect. They're built
* by string-only helpers we don't export, so this test reads the source and
* asserts the literal model strings + agent names are wired in. A regression
* here means the next review run silently runs lenses on Opus instead of
* Sonnet.
*/
describe("subagent registration source asserts", () => {
describe("claude.ts buildAgentsJson", () => {
it("registers reviewfrog with sonnet model", () => {
expect(claudeSource).toMatch(
/\[REVIEWER_AGENT_NAME\]:\s*\{[^}]*model:\s*"claude-sonnet-4-6"/s
);
});
it("imports the reviewer name constant", () => {
expect(claudeSource).toMatch(/REVIEWER_AGENT_NAME/);
});
});
describe("opencodeShared.ts buildReviewerAgentConfig", () => {
it("registers reviewfrog with mode: subagent", () => {
expect(opencodeSharedSource).toMatch(/\[REVIEWER_AGENT_NAME\]:[^}]*mode:\s*"subagent"/s);
});
it("uses deriveSubagentModels for the reviewer model override", () => {
expect(opencodeSharedSource).toMatch(/deriveSubagentModels\(/);
expect(opencodeSharedSource).toMatch(/overrides\.reviewer/);
});
it("v2 runner passes orchestrator model to buildReviewerAgentConfig", () => {
expect(opencodeV2Source).toMatch(/buildReviewerAgentConfig\(model\)/);
});
});
});