Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d04c1ca3da | |||
| ae976e7159 | |||
| 5aabd1e4a9 | |||
| 60cc8772a6 | |||
| 4260984257 | |||
| d5f881e9fc | |||
| 1dc53043a6 | |||
| 076e5a17b5 | |||
| d5d8a0d7ac | |||
| 159389fad2 | |||
| 43bb14bf87 | |||
| d8f825034f | |||
| f0805b78f5 | |||
| e20b4d5515 | |||
| 8c6cd2bda2 | |||
| e4d0fc7e3d | |||
| a4a5010441 | |||
| cf94773bf0 | |||
| 8e36f76cfa | |||
| dee13b160f | |||
| ef394277c1 | |||
| ee479474ce | |||
| 96910f0f50 | |||
| 4cc6d95a91 | |||
| 10590993f4 | |||
| 10aeaf8c11 | |||
| 85d25a6fe6 | |||
| 653fae47a5 | |||
| 363e4cbed8 | |||
| c8888cecde | |||
| c0de70431e | |||
| b0274e3265 | |||
| 8f36eca62a | |||
| 3c9799adda | |||
| 5f3e46c42d | |||
| 3d393c36a3 | |||
| d6de1c369a | |||
| 2e6c01670e |
@@ -11,6 +11,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test-token:
|
||||
# only run in the upstream publish target. forks inherit this file but
|
||||
# haven't installed the pullfrog github app — running it there 404s our
|
||||
# token endpoint and pollutes our error logs (see #693).
|
||||
if: github.repository == 'pullfrog/pullfrog'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get installation token
|
||||
|
||||
@@ -10,8 +10,10 @@ permissions:
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
# skip if pushed by our bot (breaks the loop)
|
||||
if: github.actor != 'pullfrog[bot]'
|
||||
# only run in the upstream publish target (forks inherit this file but
|
||||
# can't dispatch into pullfrog/app), and skip if pushed by our bot (breaks
|
||||
# the loop).
|
||||
if: github.repository == 'pullfrog/pullfrog' && github.actor != 'pullfrog[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
+219
-44
@@ -18,17 +18,23 @@ import { performance } from "node:perf_hooks";
|
||||
import { pullfrogMcpName } from "../external.ts";
|
||||
|
||||
import { getIdleMs, markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import {
|
||||
DEFAULT_MAX_RETAINED_BYTES,
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
SpawnTimeoutError,
|
||||
spawn,
|
||||
TailBuffer,
|
||||
} from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||||
import { deriveLabelFromTaskInput } from "./sessionLabeler.ts";
|
||||
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentRunContext,
|
||||
@@ -90,10 +96,13 @@ function stripProviderPrefix(specifier: string): string {
|
||||
return slashIndex > 0 ? specifier.slice(slashIndex + 1) : specifier;
|
||||
}
|
||||
|
||||
// `max` effort is supported on Opus 4.6 / 4.7; other models fall back to `high`.
|
||||
// claude-code deny-lists older opus/sonnet generations from `max` at invocation time.
|
||||
function resolveEffort(model: string | undefined): "max" | "high" {
|
||||
if (model?.includes("opus")) return "max";
|
||||
// `high` is the model's tuned default ("equivalent to not setting the parameter"
|
||||
// per Anthropic docs). `max` is "absolute maximum capability with no constraints
|
||||
// on token spending" — meaningfully slower and burns more thinking budget per
|
||||
// turn. We default everyone to `high`; PRs that genuinely need full-send can
|
||||
// opt in via a future per-run override rather than paying the wall-time cost on
|
||||
// every Opus run.
|
||||
function resolveEffort(_model: string | undefined): "high" {
|
||||
return "high";
|
||||
}
|
||||
|
||||
@@ -111,13 +120,21 @@ interface ContentBlock {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// SDK schema (per claude-agent-sdk docs) puts `session_id` and
|
||||
// `parent_tool_use_id` at the top level of every Assistant/User/System/Result
|
||||
// message, not inside `message`. Subagent events carry a non-null
|
||||
// `parent_tool_use_id` pointing at the orchestrator's Task/Agent tool_use id.
|
||||
interface ClaudeSystemEvent {
|
||||
type: "system";
|
||||
session_id?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ClaudeAssistantEvent {
|
||||
type: "assistant";
|
||||
session_id?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: ContentBlock[];
|
||||
@@ -135,6 +152,8 @@ interface ClaudeAssistantEvent {
|
||||
|
||||
interface ClaudeUserEvent {
|
||||
type: "user";
|
||||
session_id?: string;
|
||||
parent_tool_use_id?: string | null;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: ContentBlock[];
|
||||
@@ -146,6 +165,15 @@ interface ClaudeUserEvent {
|
||||
interface ClaudeResultEvent {
|
||||
type: "result";
|
||||
subtype?: string;
|
||||
// claude CLI sets `is_error: true` (alongside `subtype: "success"`) when
|
||||
// an upstream provider fails mid-stream. `api_error_status` carries the
|
||||
// provider HTTP status (e.g. 401 for invalid API key). per the official
|
||||
// SDK types, `api_error_status` is `number | null`, and the `error_*`
|
||||
// subtypes carry their actionable payload in `errors: string[]` instead
|
||||
// of `result`.
|
||||
is_error?: boolean;
|
||||
api_error_status?: number | null;
|
||||
errors?: string[];
|
||||
result?: string;
|
||||
session_id?: string;
|
||||
num_turns?: number;
|
||||
@@ -203,14 +231,78 @@ type RunParams = {
|
||||
|
||||
type ClaudeRunResult = AgentResult & { sessionId?: string | undefined };
|
||||
|
||||
async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
/**
|
||||
* Return the tail of `text` capped at `maxCodeUnits` UTF-16 code units,
|
||||
* dropping any partial first line. used in the exit-non-zero stdout fallback
|
||||
* so we never surface a truncated NDJSON event to operators —
|
||||
* `result.stdout.slice(-2048)` would otherwise cut mid-line and produce a
|
||||
* syntactically broken JSON fragment. code units rather than bytes because
|
||||
* `String.prototype.slice` operates on UTF-16 units; for multi-byte UTF-8
|
||||
* content the effective byte budget can be up to 4× the nominal limit.
|
||||
*/
|
||||
function tailLines(text: string, maxCodeUnits: number): string {
|
||||
if (text.length <= maxCodeUnits) return text;
|
||||
const tail = text.slice(-maxCodeUnits);
|
||||
const firstNewline = tail.indexOf("\n");
|
||||
// if no newline in window or it's at the very start, return as-is;
|
||||
// otherwise drop the partial first line.
|
||||
return firstNewline > 0 && firstNewline < tail.length - 1 ? tail.slice(firstNewline + 1) : tail;
|
||||
}
|
||||
|
||||
export async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
const startTime = performance.now();
|
||||
let eventCount = 0;
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
|
||||
// per-session labeler so parallel subagent log lines can be differentiated.
|
||||
// claude-agent-sdk runs subagents inside the orchestrator's session — they
|
||||
// share `session_id` — and stamps every subagent message with a non-null
|
||||
// `parent_tool_use_id` pointing at the Agent tool_use that spawned them.
|
||||
// we bind each Agent tool_use id to its dispatched label up front, then
|
||||
// labelFor short-circuits to the direct mapping when parent_tool_use_id is
|
||||
// set. orchestrator events (parent_tool_use_id === null) flow through the
|
||||
// sessionID path and bind to ORCHESTRATOR_LABEL on first sighting.
|
||||
const labeler = new SessionLabeler();
|
||||
function eventLabel(event: { session_id?: string; parent_tool_use_id?: string | null }): string {
|
||||
return labeler.labelFor(event.session_id ?? null, event.parent_tool_use_id ?? null);
|
||||
}
|
||||
function withLabel(label: string, message: string): string {
|
||||
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
|
||||
}
|
||||
|
||||
// one ThinkingTimer per session — sharing a single timer across sessions
|
||||
// conflated cross-session interleaving as parent thinking time. each timer
|
||||
// formats its log lines through the session label so attribution is visible.
|
||||
const thinkingTimers = new Map<string, ThinkingTimer>();
|
||||
function timerFor(label: string): ThinkingTimer {
|
||||
let t = thinkingTimers.get(label);
|
||||
if (!t) {
|
||||
const formatLine = (line: string) =>
|
||||
label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
|
||||
t = new ThinkingTimer(formatLine);
|
||||
thinkingTimers.set(label, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
let finalOutput = "";
|
||||
let sessionId: string | undefined;
|
||||
let resultErrorSubtype: string | null = null;
|
||||
// captures the structured error string from a result event with
|
||||
// `is_error: true` (e.g. mid-stream provider auth failures the CLI
|
||||
// surfaces as `subtype: "success"` synthetic-stop events, or the
|
||||
// `errors[]` array from `error_*` subtypes). preferred over raw
|
||||
// stdout/stderr in the exit-non-zero path so the GitHub Actions
|
||||
// `##[error]` line shows the actionable message instead of an 8KB+
|
||||
// NDJSON dump.
|
||||
let lastResultError: string | null = null;
|
||||
// set only for synthetic-stop `subtype: "success"` + `is_error: true`
|
||||
// events, where `accumulatedTokens` from prior `assistant` events is
|
||||
// stale and logging it would mislead operators into thinking billable
|
||||
// tokens were spent on a successful turn. deliberately NOT set for
|
||||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||||
// because those runs genuinely consumed tokens and operators need
|
||||
// billing visibility for them.
|
||||
let syntheticStopFailure = false;
|
||||
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
// Claude CLI reports a single end-of-run `total_cost_usd` on the result
|
||||
// event. per-message events don't carry cost, so there's nothing to sum —
|
||||
@@ -234,18 +326,30 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
system: (_event: ClaudeSystemEvent) => {
|
||||
log.debug(`» ${params.label} system event`);
|
||||
system: (event: ClaudeSystemEvent) => {
|
||||
// claude-agent-sdk only emits system:init for the top-level query, so
|
||||
// this binds the orchestrator label and never appears in subagent flow.
|
||||
// we still route through eventLabel so a subagent system event (if the
|
||||
// SDK ever adds one) wouldn't go silently misattributed.
|
||||
const label = eventLabel(event);
|
||||
log.debug(withLabel(label, `» ${params.label} system event`));
|
||||
},
|
||||
assistant: (event: ClaudeAssistantEvent) => {
|
||||
const content = event.message?.content;
|
||||
if (!content) return;
|
||||
|
||||
const label = eventLabel(event);
|
||||
const boxTitle = label === ORCHESTRATOR_LABEL ? params.label : `${params.label} [${label}]`;
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text?.trim()) {
|
||||
const message = block.text.trim();
|
||||
log.box(message, { title: params.label });
|
||||
finalOutput = message;
|
||||
log.box(message, { title: boxTitle });
|
||||
// only the orchestrator's text becomes the run's "output" — subagent
|
||||
// report-back text would otherwise clobber the parent's final answer.
|
||||
if (label === ORCHESTRATOR_LABEL) {
|
||||
finalOutput = message;
|
||||
}
|
||||
} else if (block.type === "tool_use") {
|
||||
const toolName = block.name || "unknown";
|
||||
if (params.onToolUse) {
|
||||
@@ -254,23 +358,34 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
input: block.input,
|
||||
});
|
||||
}
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({ toolName, input: block.input || {} });
|
||||
timerFor(label).markToolCall();
|
||||
const inputFormatted = formatJsonValue(block.input || {});
|
||||
const toolCallLine =
|
||||
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
||||
log.info(withLabel(label, toolCallLine));
|
||||
|
||||
// surface the subagent identity when the orchestrator dispatches a
|
||||
// Task — claude rolls subagent activity up into a single tool_result
|
||||
// (no per-event session_id in its stream), so this log line is the
|
||||
// only attribution available before the subagent's report-back.
|
||||
if (toolName === "Task" && block.input && typeof block.input === "object") {
|
||||
// when the orchestrator dispatches a subagent, bind the Agent
|
||||
// tool_use id to the dispatched label so future events carrying
|
||||
// `parent_tool_use_id === block.id` resolve directly to the right
|
||||
// lens. v2.1.63+ renamed the tool to "Agent"; older versions
|
||||
// emitted "Task". match both for forward-compat.
|
||||
if (
|
||||
(toolName === "Task" || toolName === "Agent") &&
|
||||
block.input &&
|
||||
typeof block.input === "object"
|
||||
) {
|
||||
const taskInput = block.input as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const label = deriveLabelFromTaskInput(taskInput);
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput, block.id ?? null);
|
||||
log.info(
|
||||
`» dispatching subagent: ${label}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
withLabel(
|
||||
label,
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,8 +395,14 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
params.todoTracker.cancel();
|
||||
}
|
||||
|
||||
// parse TodoWrite events for live progress tracking
|
||||
if (toolName === "TodoWrite" && params.todoTracker?.enabled) {
|
||||
// parse TodoWrite events for live progress tracking. only honor the
|
||||
// orchestrator's todos — subagents emit their own todo lists which
|
||||
// would otherwise clobber the visible progress comment.
|
||||
if (
|
||||
toolName === "TodoWrite" &&
|
||||
params.todoTracker?.enabled &&
|
||||
label === ORCHESTRATOR_LABEL
|
||||
) {
|
||||
params.todoTracker.update(block.input);
|
||||
}
|
||||
}
|
||||
@@ -302,10 +423,12 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
const content = event.message?.content;
|
||||
if (!content) return;
|
||||
|
||||
const label = eventLabel(event);
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === "string") continue;
|
||||
if (block.type === "tool_result") {
|
||||
thinkingTimer.markToolResult();
|
||||
timerFor(label).markToolResult();
|
||||
|
||||
const outputContent =
|
||||
typeof block.content === "string"
|
||||
@@ -323,9 +446,9 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
: String(block.content);
|
||||
|
||||
if (block.is_error) {
|
||||
log.info(`» tool error: ${outputContent}`);
|
||||
log.info(withLabel(label, `» tool error: ${outputContent}`));
|
||||
} else {
|
||||
log.debug(`» tool output: ${outputContent}`);
|
||||
log.debug(withLabel(label, `» tool output: ${outputContent}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -335,6 +458,27 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
const subtype = event.subtype || "unknown";
|
||||
const numTurns = event.num_turns || 0;
|
||||
|
||||
// claude CLI emits synthetic-stop result events with `subtype: "success"`
|
||||
// but `is_error: true` when an upstream provider fails mid-stream (e.g.
|
||||
// 401 from anthropic). short-circuit before the usage/token-table path
|
||||
// so we don't log a usage table for a failed attempt and so downstream
|
||||
// (`resultErrorSubtype` branch) surfaces the structured error. gated on
|
||||
// `subtype === "success"` because the `error_*` subtypes also set
|
||||
// `is_error: true` but carry their payload in `errors: string[]` and
|
||||
// are handled by the dedicated branches below.
|
||||
if (event.is_error === true && subtype === "success") {
|
||||
const apiStatus = event.api_error_status;
|
||||
lastResultError =
|
||||
event.result?.trim() ||
|
||||
`claude reported is_error=true with no result text (api_error_status=${apiStatus ?? "unknown"})`;
|
||||
resultErrorSubtype = subtype;
|
||||
syntheticStopFailure = true;
|
||||
log.info(
|
||||
`» ${params.label} result error: subtype=${subtype}, api_error_status=${apiStatus ?? "unknown"}, message=${lastResultError}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (subtype === "success") {
|
||||
// extract detailed usage from result event (most accurate source).
|
||||
// note: `input` here is non-cached input tokens only, matching the
|
||||
@@ -369,12 +513,15 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
}
|
||||
} else if (subtype === "error_max_turns") {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} max turns reached: ${JSON.stringify(event)}`);
|
||||
} else if (subtype === "error_during_execution") {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} execution error: ${JSON.stringify(event)}`);
|
||||
} else if (subtype.startsWith("error")) {
|
||||
resultErrorSubtype = subtype;
|
||||
lastResultError = event.errors?.join("\n").trim() || null;
|
||||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||||
} else {
|
||||
log.info(`» ${params.label} result: subtype=${subtype}, data=${JSON.stringify(event)}`);
|
||||
@@ -395,7 +542,8 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
let lastProviderError: string | null = null;
|
||||
|
||||
let output = "";
|
||||
// capped accumulator — see opencode.ts for rationale (issue #680).
|
||||
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
@@ -413,9 +561,14 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
// there's no shim-orphan issue like opencode-ai/bin/opencode, but
|
||||
// detached + killGroup is the right default for any agent runtime.
|
||||
killGroup: true,
|
||||
// claude already drains every chunk via onStdout (NDJSON parsing) and
|
||||
// onStderr (recentStderr ring buffer). retaining a second copy in the
|
||||
// spawn wrapper would grow unbounded for long sessions and previously
|
||||
// crashed the wrapper with RangeError. see issue #680.
|
||||
retain: "none",
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
output.append(text);
|
||||
markActivity();
|
||||
|
||||
stdoutBuffer += text;
|
||||
@@ -496,8 +649,16 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (stderrContext) log.info(`» last stderr output:\n${stderrContext}`);
|
||||
}
|
||||
|
||||
// skip the fallback token table only for the synthetic-stop
|
||||
// `subtype: "success"` + `is_error: true` case: `accumulatedTokens` from
|
||||
// prior `assistant` events is stale there and logging it would mislead
|
||||
// operators into thinking billable tokens were spent on a successful turn.
|
||||
// `error_max_turns` / `error_during_execution` / `error_*` subtypes
|
||||
// represent runs that genuinely consumed tokens, so they still get the
|
||||
// table for billing visibility.
|
||||
if (
|
||||
!tokensLogged &&
|
||||
!syntheticStopFailure &&
|
||||
(accumulatedTokens.input > 0 ||
|
||||
accumulatedTokens.output > 0 ||
|
||||
accumulatedTokens.cacheRead > 0 ||
|
||||
@@ -511,18 +672,32 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
// prefer the structured `lastResultError` (parsed from a result event
|
||||
// with `is_error: true`) over raw stdout. raw stdout is the full NDJSON
|
||||
// event stream — dumping it into a GitHub Actions `##[error]` line both
|
||||
// hides the actionable provider message and pollutes the run log. cap
|
||||
// the stdout fallback to the last 2KB so it stays readable when neither
|
||||
// a structured error nor stderr is available.
|
||||
//
|
||||
// result.stdout / result.stderr are empty because we pass retain:"none"
|
||||
// to spawn (see issue #680); the agent layer keeps its own bounded
|
||||
// mirrors via `output` (TailBuffer) and `recentStderr` (ring buffer).
|
||||
const stdoutSnapshot = output.toString();
|
||||
const stderrSnapshot = recentStderr.join("\n");
|
||||
const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : "";
|
||||
const errorMessage =
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
lastResultError ||
|
||||
stderrSnapshot ||
|
||||
truncatedStdout ||
|
||||
`unknown error - no output from Claude CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
);
|
||||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||||
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || stdoutSnapshot,
|
||||
error: errorMessage,
|
||||
usage,
|
||||
sessionId,
|
||||
@@ -532,7 +707,7 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (eventCount === 0 && lastProviderError) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `provider error: ${lastProviderError}`,
|
||||
usage,
|
||||
sessionId,
|
||||
@@ -542,14 +717,14 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
if (resultErrorSubtype) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `result subtype: ${resultErrorSubtype}`,
|
||||
output: finalOutput || output.toString(),
|
||||
error: lastResultError || `result subtype: ${resultErrorSubtype}`,
|
||||
usage,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage, sessionId };
|
||||
return { success: true, output: finalOutput || output.toString(), usage, sessionId };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
const duration = performance.now() - startTime;
|
||||
@@ -575,7 +750,7 @@ async function runClaude(params: RunParams): Promise<ClaudeRunResult> {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildUsage(),
|
||||
sessionId,
|
||||
@@ -722,12 +897,12 @@ export const claude = agent({
|
||||
// the run). the reflection prompt fires once after gates go clean, as a
|
||||
// dedicated turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
ctx,
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
summaryFilePath: ctx.summaryFilePath,
|
||||
summarySeed: ctx.summarySeed,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("claude"),
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
canResume: (r) => Boolean(r.sessionId),
|
||||
resume: async (c) => {
|
||||
const sessionId = c.previousResult.sessionId;
|
||||
|
||||
+296
-66
@@ -12,7 +12,7 @@
|
||||
* security is enforced at the tool layer, not the process layer.
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { pullfrogMcpName } from "../external.ts";
|
||||
@@ -22,10 +22,21 @@ import { formatJsonValue, log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { detectProviderError } from "../utils/providerErrors.ts";
|
||||
import { addSkill, installBundledSkills } from "../utils/skills.ts";
|
||||
import { SPAWN_ACTIVITY_TIMEOUT_CODE, SpawnTimeoutError, spawn } from "../utils/subprocess.ts";
|
||||
import {
|
||||
DEFAULT_MAX_RETAINED_BYTES,
|
||||
SPAWN_ACTIVITY_TIMEOUT_CODE,
|
||||
SpawnTimeoutError,
|
||||
spawn,
|
||||
TailBuffer,
|
||||
} from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
import {
|
||||
PULLFROG_BUS_EVENT_TYPE,
|
||||
PULLFROG_OPENCODE_PLUGIN_FILENAME,
|
||||
PULLFROG_OPENCODE_PLUGIN_SOURCE,
|
||||
} from "./opencodePlugin.ts";
|
||||
import { buildLearningsReflectionPrompt, runPostRunRetryLoop } from "./postRun.ts";
|
||||
import { REVIEWER_AGENT_NAME, REVIEWER_SYSTEM_PROMPT } from "./reviewer.ts";
|
||||
import { formatWithLabel, ORCHESTRATOR_LABEL, SessionLabeler } from "./sessionLabeler.ts";
|
||||
@@ -54,6 +65,7 @@ type OpenCodeConfig = {
|
||||
permission?: Record<string, unknown>;
|
||||
provider?: Record<string, unknown>;
|
||||
agent?: Record<string, unknown>;
|
||||
experimental?: Record<string, unknown>;
|
||||
model?: string;
|
||||
enabled_providers?: string[];
|
||||
[key: string]: unknown;
|
||||
@@ -75,6 +87,26 @@ type OpenCodeConfig = {
|
||||
*/
|
||||
const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000;
|
||||
|
||||
/**
|
||||
* upstream opencode hardcodes `thinkingLevel: "high"` as the default for every
|
||||
* gemini-3 model on the direct google SDK (`provider/transform.ts` `options()`).
|
||||
* that adds 30-60s of pre-tool-call TTFT and 5-46s of post-tool jabber per turn,
|
||||
* which is overkill for agentic loops where most steps are tool-routing
|
||||
* decisions. we override to "medium" for the curated slugs we ship in
|
||||
* `action/models.ts`; users who want max quality can still pick the `-high`
|
||||
* variant explicitly. flash stays at "medium" too — low-effort flash is
|
||||
* visibly worse on harder tasks and the latency savings aren't meaningful
|
||||
* (flash is already fast). other gemini-3 ids that exist in models.dev but
|
||||
* aren't in our curated alias map keep the upstream `"high"` default.
|
||||
*
|
||||
* keyed by upstream api id (matches the slugs in `action/models.ts`). the
|
||||
* merge order in opencode `session/llm.ts` is `base ← model.options ← agent.options ← variant`,
|
||||
* deep-merged — so an explicit `--variant high` still wins, and explicit
|
||||
* model.options in a user-provided opencode config would also win.
|
||||
*/
|
||||
const GEMINI_3_DIRECT_THINKING_LEVEL = "medium";
|
||||
const GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"];
|
||||
|
||||
function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string {
|
||||
const config: OpenCodeConfig = {
|
||||
permission: {
|
||||
@@ -89,6 +121,29 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s
|
||||
[pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl },
|
||||
},
|
||||
agent: buildReviewerAgentConfig(),
|
||||
// opt into opencode's experimental `batch` tool (added in
|
||||
// anomalyco/opencode PR #2983, opt-in via `experimental.batch_tool`). it
|
||||
// exposes a single `batch` tool that runs 1-25 independent tool calls
|
||||
// (read/grep/glob/bash/etc.) concurrently in one assistant turn, which
|
||||
// collapses the dominant grep→20×read pattern into a single round trip.
|
||||
// edits are explicitly disallowed inside the batch upstream. paired with
|
||||
// the "Parallel tool execution" guidance in utils/instructions.ts so the
|
||||
// model actually reaches for it. see wiki/prompt.md.
|
||||
experimental: { batch_tool: true },
|
||||
provider: {
|
||||
google: {
|
||||
models: Object.fromEntries(
|
||||
GEMINI_3_DIRECT_API_IDS.map((id) => [
|
||||
id,
|
||||
{
|
||||
options: {
|
||||
thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL },
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (model) {
|
||||
@@ -280,6 +335,36 @@ interface OpenCodeErrorEvent {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope event emitted by our `.opencode/plugin/pullfrog-events.ts` (the
|
||||
* source lives in `opencodePlugin.ts`). The plugin subscribes to opencode's
|
||||
* bus via `bus.subscribeAll()` and re-emits non-orchestrator
|
||||
* `message.part.updated` events on stdout so subagent activity surfaces here.
|
||||
*
|
||||
* `bus_event.properties.part` matches the same `Part` shape that opencode's
|
||||
* `cli/cmd/run.ts` uses to drive its own emit() calls, so we can route the
|
||||
* inner part through the existing `tool_use` / `step_start` / `step_finish`
|
||||
* / `text` handlers by synthesizing the equivalent OpenCode-style event.
|
||||
*/
|
||||
interface OpenCodeBusEnvelopeEvent {
|
||||
type: "pullfrog_bus_event";
|
||||
bus_event?: {
|
||||
type?: string;
|
||||
properties?: {
|
||||
part?: {
|
||||
sessionID?: string;
|
||||
type?: string;
|
||||
time?: { end?: number | string };
|
||||
state?: { status?: string };
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
type OpenCodeEvent =
|
||||
| OpenCodeInitEvent
|
||||
| OpenCodeMessageEvent
|
||||
@@ -289,7 +374,8 @@ type OpenCodeEvent =
|
||||
| OpenCodeToolUseEvent
|
||||
| OpenCodeToolResultEvent
|
||||
| OpenCodeResultEvent
|
||||
| OpenCodeErrorEvent;
|
||||
| OpenCodeErrorEvent
|
||||
| OpenCodeBusEnvelopeEvent;
|
||||
|
||||
// ── runner ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -307,7 +393,6 @@ type RunParams = {
|
||||
async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
const startTime = performance.now();
|
||||
let eventCount = 0;
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
|
||||
let finalOutput = "";
|
||||
let accumulatedTokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
||||
@@ -324,15 +409,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// per-session labeler so parallel subagent log lines can be differentiated.
|
||||
// the orchestrator's task tool_use events seed the labeler; the next
|
||||
// previously-unseen sessionID consumes the head of the pending-label queue.
|
||||
// NB: opencode's runtime currently encapsulates subagent execution inside
|
||||
// the `task` tool — subagent-internal tool_use/tool_result events do not
|
||||
// surface on the parent's NDJSON stream. The labeler is therefore mostly
|
||||
// dormant in practice for opencode (no per-event session differentiation
|
||||
// is needed because there are no per-subagent events). The orchestrator's
|
||||
// `task` dispatch log (with `description: <lens>`) and the per-task
|
||||
// duration log below are the actual attribution surface available today.
|
||||
// The labeler is kept in place defensively so that if/when opencode begins
|
||||
// streaming subagent sessions, attribution flips on with no further work.
|
||||
// upstream opencode's `cli/cmd/run.ts` filters subagent events out of its
|
||||
// NDJSON stream (`part.sessionID !== sessionID`), so we ship a per-run
|
||||
// plugin (`action/agents/opencodePlugin.ts`, written into the tmpdir at
|
||||
// setup) that re-emits non-orchestrator `message.part.updated` events. those
|
||||
// arrive here as `pullfrog_bus_event` envelopes and feed the labeler with
|
||||
// real data per subagent session.
|
||||
const labeler = new SessionLabeler();
|
||||
function eventLabel(event: Record<string, unknown>): string {
|
||||
const sid = event.sessionID ?? event.session_id;
|
||||
@@ -342,6 +424,23 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
return label === ORCHESTRATOR_LABEL ? message : formatWithLabel(label, message);
|
||||
}
|
||||
|
||||
// one ThinkingTimer per session — sharing a single timer across sessions
|
||||
// conflated cross-session interleaving (parent thinks → child tool_call,
|
||||
// or child returns → parent dispatches next) as parent thinking time. each
|
||||
// timer formats its log lines through the session label so the "thought
|
||||
// for X" attribution is visible in the merged stream.
|
||||
const thinkingTimers = new Map<string, ThinkingTimer>();
|
||||
function timerFor(label: string): ThinkingTimer {
|
||||
let t = thinkingTimers.get(label);
|
||||
if (!t) {
|
||||
const formatLine = (line: string) =>
|
||||
label === ORCHESTRATOR_LABEL ? line : formatWithLabel(label, line);
|
||||
t = new ThinkingTimer(formatLine);
|
||||
thinkingTimers.set(label, t);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// tracks per-task dispatch metadata so the matching tool_result can log a
|
||||
// labeled "» subagent finished: lens=X duration=Ys" line. this is the most
|
||||
// useful per-lens observability available given that subagent-internal
|
||||
@@ -368,16 +467,6 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// mismatched callID" signal).
|
||||
const knownNonTaskCallIDs = new Set<string>();
|
||||
|
||||
// a subagent is "in flight" while at least one `task` tool_use is awaiting
|
||||
// its tool_result. used to suspend spawn's activity timer (which only sees
|
||||
// opencode's stdout) so a long-running subagent — whose internal events
|
||||
// don't surface on the parent NDJSON stream — can't trip the 5min timeout.
|
||||
// boolean state instead of a heartbeat so there's no race window between a
|
||||
// periodic tick and a subagent that finishes between ticks.
|
||||
function isSubagentInFlight(): boolean {
|
||||
return taskDispatchByCallID.size > 0 || pendingTaskDispatches.length > 0;
|
||||
}
|
||||
|
||||
function emitSubagentFinished(
|
||||
dispatch: TaskDispatch,
|
||||
status: string,
|
||||
@@ -532,25 +621,31 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// label is already bound); the dispatch label is for the next new
|
||||
// sessionID that appears.
|
||||
if (toolName === "task") {
|
||||
const taskInput = (event.part?.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
// dual-index by callID (fast path) AND in a FIFO queue (fallback path
|
||||
// for when opencode's task tool_result carries a different callID).
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: toolId,
|
||||
};
|
||||
taskDispatchByCallID.set(toolId, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
// may have been pre-registered via the plugin's early task-dispatch
|
||||
// announcement (`pullfrog_bus_event` handler). dedupe on callID so
|
||||
// we don't record the same dispatch twice (which would corrupt the
|
||||
// FIFO label queue).
|
||||
if (!taskDispatchByCallID.has(toolId)) {
|
||||
const taskInput = (event.part?.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
// dual-index by callID (fast path) AND in a FIFO queue (fallback path
|
||||
// for when opencode's task tool_result carries a different callID).
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: toolId,
|
||||
};
|
||||
taskDispatchByCallID.set(toolId, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// remember non-task callIDs so a later tool_result with that callID
|
||||
// is correctly identified as not-a-task (and we don't FIFO-pop a
|
||||
@@ -571,7 +666,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
});
|
||||
}
|
||||
|
||||
thinkingTimer.markToolCall();
|
||||
timerFor(label).markToolCall();
|
||||
const inputFormatted = formatJsonValue(event.part?.state?.input || {});
|
||||
const toolCallLine =
|
||||
inputFormatted !== "{}" ? `» ${toolName}(${inputFormatted})` : `» ${toolName}()`;
|
||||
@@ -580,6 +675,16 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
if (event.part?.state?.status === "completed" && event.part.state.output) {
|
||||
log.debug(withLabel(label, ` output: ${event.part.state.output}`));
|
||||
}
|
||||
// surface tool errors at info level. opencode emits tool parts at
|
||||
// status="error" through the same `tool_use` event the CLI's run-loop
|
||||
// (and our injected plugin for subagent parts) emits — without this
|
||||
// branch the only signal in the user's logs is `» <tool>(...)` with
|
||||
// no indication the call failed. error info lives in `state.output`
|
||||
// (an error string set by the tool layer).
|
||||
if (event.part?.state?.status === "error") {
|
||||
const errorMsg = event.part.state.output ?? "(no error message)";
|
||||
log.info(withLabel(label, `» tool call failed: ${errorMsg}`));
|
||||
}
|
||||
|
||||
// agent's explicit MCP report_progress takes priority over todo tracking
|
||||
if (toolName.includes("report_progress") && params.todoTracker) {
|
||||
@@ -598,7 +703,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
const output = event.part?.state?.output || event.output;
|
||||
const label = eventLabel(event);
|
||||
|
||||
thinkingTimer.markToolResult();
|
||||
timerFor(label).markToolResult();
|
||||
|
||||
// surface subagent completion at info level — opencode otherwise hides
|
||||
// per-task timing in debug-only logs, so a parallel multi-lens fan-out
|
||||
@@ -700,6 +805,98 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
}
|
||||
}
|
||||
},
|
||||
[PULLFROG_BUS_EVENT_TYPE]: async (event: OpenCodeBusEnvelopeEvent) => {
|
||||
// surface subagent activity that opencode's CLI run-loop discards (it
|
||||
// filters `part.sessionID !== sessionID`). our injected plugin
|
||||
// (action/agents/opencodePlugin.ts) re-emits non-orchestrator
|
||||
// `message.part.updated` bus events; here we synthesize the equivalent
|
||||
// CLI-style event for each known part type and dispatch through the
|
||||
// existing handlers so labeling, attribution, and logging all reuse the
|
||||
// same code path as the orchestrator's events. mirrors the dispatch
|
||||
// logic in opencode-ai's `cli/cmd/run.ts` `loop()` function.
|
||||
const busEvent = event.bus_event;
|
||||
if (!busEvent || busEvent.type !== "message.part.updated") return;
|
||||
const part = busEvent.properties?.part;
|
||||
if (!part || typeof part.sessionID !== "string") return;
|
||||
const sessionID = part.sessionID;
|
||||
const partType = part.type;
|
||||
|
||||
// early task dispatch: the orchestrator's task tool fires bus events at
|
||||
// status=running BEFORE the subagent's first message.part.updated, but
|
||||
// the CLI's run-loop only emits the matching tool_use NDJSON event at
|
||||
// status=completed (after the subagent finishes). without
|
||||
// pre-registering the dispatch label here, the labeler binds the
|
||||
// subagent's sessionID to a generic `subagent#N` fallback before the
|
||||
// CLI's tool_use ever fires recordTaskDispatch. dedupe against
|
||||
// taskDispatchByCallID so the late tool_use handler doesn't double-add.
|
||||
if (partType === "tool") {
|
||||
const status = part.state?.status;
|
||||
const partWithToolFields = part as {
|
||||
tool?: string;
|
||||
callID?: string;
|
||||
state?: { status?: string; input?: unknown };
|
||||
};
|
||||
// only running (not pending) — at pending state.input is still {}.
|
||||
// by running, the LLM has filled in description/subagent_type/prompt.
|
||||
// mirrors the same check in the plugin source.
|
||||
const isOrchestratorTaskDispatch =
|
||||
partWithToolFields.tool === "task" && status === "running";
|
||||
if (isOrchestratorTaskDispatch) {
|
||||
const callID = partWithToolFields.callID;
|
||||
if (typeof callID === "string" && !taskDispatchByCallID.has(callID)) {
|
||||
const taskInput = (partWithToolFields.state?.input ?? {}) as {
|
||||
description?: string;
|
||||
subagent_type?: string;
|
||||
prompt?: string;
|
||||
};
|
||||
const dispatchedLabel = labeler.recordTaskDispatch(taskInput);
|
||||
const dispatch: TaskDispatch = {
|
||||
label: dispatchedLabel,
|
||||
startedAt: performance.now(),
|
||||
toolUseCallID: callID,
|
||||
};
|
||||
taskDispatchByCallID.set(callID, dispatch);
|
||||
pendingTaskDispatches.push(dispatch);
|
||||
log.info(
|
||||
`» dispatching subagent: ${dispatchedLabel}` +
|
||||
(taskInput.subagent_type ? ` (subagent_type=${taskInput.subagent_type})` : "")
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status !== "completed" && status !== "error") return;
|
||||
await handlers.tool_use({
|
||||
type: "tool_use",
|
||||
sessionID,
|
||||
part,
|
||||
} as OpenCodeToolUseEvent);
|
||||
return;
|
||||
}
|
||||
// intentionally NOT routing subagent step_start / step_finish through
|
||||
// the orchestrator's handlers:
|
||||
// - step_finish carries `tokens` and `cost` and the handler folds
|
||||
// them into the run-wide accumulators. surfacing subagent steps
|
||||
// here would inflate the orchestrator's usage telemetry — and
|
||||
// either double-count (if opencode also bills child tokens back
|
||||
// up to the parent session) or just over-report. the existing
|
||||
// init/message/text handlers all gate on ORCHESTRATOR_LABEL for
|
||||
// the same reason.
|
||||
// - step_start mutates `currentStepId` / `currentStepType` /
|
||||
// `stepHistory`, which are orchestrator-scoped — using them to
|
||||
// attribute subagent activity in the orchestrator's tool-use
|
||||
// timing log would be wrong.
|
||||
// the subagent's tool calls and text still surface (handled below)
|
||||
// — that's the user-visible activity.
|
||||
if (partType === "step-start" || partType === "step-finish") return;
|
||||
if (partType === "text" && part.time?.end !== undefined) {
|
||||
await handlers.text({
|
||||
type: "text",
|
||||
sessionID,
|
||||
part,
|
||||
} as OpenCodeTextEvent);
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const recentStderr: string[] = [];
|
||||
@@ -707,7 +904,12 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
let lastProviderError: string | null = null;
|
||||
let agentErrorEvent: OpenCodeErrorEvent | null = null;
|
||||
|
||||
let output = "";
|
||||
// capped accumulator for the agent's narration. used as a post-run fallback
|
||||
// when `finalOutput` (the orchestrator's final assistant message) is empty.
|
||||
// unbounded `output += text` previously grew to ~1 GiB on multi-lens Reviews
|
||||
// and contributed to the wrapper-level RangeError. retain:"none" on spawn
|
||||
// skips the duplicate buffer there; this TailBuffer caps the agent layer.
|
||||
const output = new TailBuffer(DEFAULT_MAX_RETAINED_BYTES);
|
||||
let stdoutBuffer = "";
|
||||
|
||||
try {
|
||||
@@ -726,16 +928,21 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
// never fires — producing zombie runs. detached + killGroup nukes the
|
||||
// whole tree.
|
||||
killGroup: true,
|
||||
// suspend the inner activity timer while a `task` subagent is in flight.
|
||||
// opencode's task tool encapsulates subagent execution in-process — the
|
||||
// subagent's internal events don't surface on the parent NDJSON stream,
|
||||
// so without this the 5min timeout would falsely fire mid-subagent.
|
||||
// suspend/resume is preferable to a heartbeat because there's no race
|
||||
// between a periodic tick and a subagent finishing between ticks.
|
||||
isPausedExternally: isSubagentInFlight,
|
||||
// we already drain every chunk via onStdout/onStderr (NDJSON parsing
|
||||
// + recentStderr ring buffer). retaining a second copy in the spawn
|
||||
// wrapper would grow unbounded for multi-lens Reviews and previously
|
||||
// crashed the wrapper with RangeError at ~1 GiB. see issue #680.
|
||||
retain: "none",
|
||||
// NB: we used to pass `isPausedExternally: isSubagentInFlight` to suspend
|
||||
// the activity timer during subagent dispatches. unnecessary now that
|
||||
// our injected plugin (action/agents/opencodePlugin.ts) re-emits
|
||||
// subagent `message.part.updated` events on opencode's stdout — those
|
||||
// arrive at child.stdout here, fire updateActivity(), and reset
|
||||
// lastActivityTime naturally. verified empirically in PR #634
|
||||
// (~3.3 plugin events/sec during a typical subagent run).
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
output += text;
|
||||
output.append(text);
|
||||
markActivity();
|
||||
|
||||
stdoutBuffer += text;
|
||||
@@ -860,22 +1067,31 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
// result.stdout / result.stderr are empty because we pass retain:"none"
|
||||
// to spawn (see issue #680); use the agent's bounded mirrors instead.
|
||||
const stdoutSnapshot = output.toString();
|
||||
const stderrSnapshot = recentStderr.join("\n");
|
||||
const errorMessage =
|
||||
result.stderr ||
|
||||
result.stdout ||
|
||||
stderrSnapshot ||
|
||||
stdoutSnapshot ||
|
||||
`unknown error - no output from OpenCode CLI${errorContext}`;
|
||||
log.error(
|
||||
`${params.label} exited with code ${result.exitCode}${errorContext}: ${errorMessage}`
|
||||
);
|
||||
log.debug(`stdout: ${result.stdout?.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${result.stderr?.substring(0, 500)}`);
|
||||
return { success: false, output: finalOutput || output, error: errorMessage, usage };
|
||||
log.debug(`stdout: ${stdoutSnapshot.substring(0, 500)}`);
|
||||
log.debug(`stderr: ${stderrSnapshot.substring(0, 500)}`);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || stdoutSnapshot,
|
||||
error: errorMessage,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventCount === 0 && lastProviderError) {
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `provider error: ${lastProviderError}`,
|
||||
usage,
|
||||
};
|
||||
@@ -888,13 +1104,13 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
errorEvent.error?.data?.message || errorEvent.error?.name || JSON.stringify(errorEvent);
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorName}: ${errorMessage}`,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true, output: finalOutput || output, usage };
|
||||
return { success: true, output: finalOutput || output.toString(), usage };
|
||||
} catch (error) {
|
||||
params.todoTracker?.cancel();
|
||||
const duration = performance.now() - startTime;
|
||||
@@ -920,7 +1136,7 @@ async function runOpenCode(params: RunParams): Promise<AgentResult> {
|
||||
|
||||
return {
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
output: finalOutput || output.toString(),
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildUsage(),
|
||||
};
|
||||
@@ -944,6 +1160,20 @@ export const opencode = agent({
|
||||
|
||||
mkdirSync(join(homeEnv.XDG_CONFIG_HOME, "opencode"), { recursive: true });
|
||||
|
||||
// drop our bus-event surfacing plugin into opencode's global config dir
|
||||
// (which we've redirected to the per-run tmpdir via XDG_CONFIG_HOME).
|
||||
// opencode auto-discovers plugins from `<Global.Path.config>/{plugin,plugins}/*.{ts,js}`
|
||||
// (see `packages/opencode/src/config/config.ts:633` calling
|
||||
// `ConfigPlugin.load(dir)`), so this lands in the loader without any
|
||||
// config wiring. critically: this MUST be inside the tmpdir, never the
|
||||
// user's repo working tree — see AGENTS.md.
|
||||
const opencodePluginDir = join(homeEnv.XDG_CONFIG_HOME, "opencode", "plugin");
|
||||
mkdirSync(opencodePluginDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(opencodePluginDir, PULLFROG_OPENCODE_PLUGIN_FILENAME),
|
||||
PULLFROG_OPENCODE_PLUGIN_SOURCE
|
||||
);
|
||||
|
||||
const agentBrowserVersion = getDevDependencyVersion("agent-browser");
|
||||
addSkill({
|
||||
ref: `vercel-labs/agent-browser@v${agentBrowserVersion}`,
|
||||
@@ -1000,12 +1230,12 @@ export const opencode = agent({
|
||||
// the reflection prompt fires once after gates go clean, as a dedicated
|
||||
// turn that nudges the agent to persist learnings.
|
||||
return runPostRunRetryLoop({
|
||||
ctx,
|
||||
initialResult: result,
|
||||
initialUsage: result.usage,
|
||||
stopScript: ctx.stopScript,
|
||||
summaryFilePath: ctx.summaryFilePath,
|
||||
summarySeed: ctx.summarySeed,
|
||||
reflectionPrompt: buildLearningsReflectionPrompt("opencode"),
|
||||
reflectionPrompt: ctx.toolState.learningsFilePath
|
||||
? buildLearningsReflectionPrompt(ctx.toolState.learningsFilePath)
|
||||
: undefined,
|
||||
resume: async (c) =>
|
||||
runOpenCode({
|
||||
...runParams,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
`;
|
||||
+35
-414
@@ -1,429 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SPAWN_TIMEOUT_CODE, SpawnTimeoutError } from "../utils/subprocess.ts";
|
||||
import type { AgentResult } from "./shared.ts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { getUnsubmittedReview } from "./postRun.ts";
|
||||
|
||||
vi.mock("./shared.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./shared.ts")>();
|
||||
function makeToolState(overrides: Partial<ToolState> = {}): ToolState {
|
||||
return {
|
||||
...actual,
|
||||
getGitStatus: vi.fn(() => ""),
|
||||
progressComment: undefined,
|
||||
hadProgressComment: true,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("../utils/subprocess.ts", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../utils/subprocess.ts")>();
|
||||
return {
|
||||
...actual,
|
||||
spawn: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const { runPostRunRetryLoop, executeStopHook } = await import("./postRun.ts");
|
||||
const { getGitStatus } = await import("./shared.ts");
|
||||
const { spawn } = await import("../utils/subprocess.ts");
|
||||
const mockedGetGitStatus = vi.mocked(getGitStatus);
|
||||
const mockedSpawn = vi.mocked(spawn);
|
||||
|
||||
const successResult = (overrides: Partial<AgentResult> = {}): AgentResult => ({
|
||||
success: true,
|
||||
output: "ok",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("runPostRunRetryLoop — reflection turn", () => {
|
||||
beforeEach(() => {
|
||||
mockedGetGitStatus.mockReset();
|
||||
mockedGetGitStatus.mockReturnValue("");
|
||||
mockedSpawn.mockReset();
|
||||
describe("getUnsubmittedReview", () => {
|
||||
it("returns null when mode is not a review mode", () => {
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Build" }))).toBeNull();
|
||||
expect(getUnsubmittedReview(makeToolState())).toBeNull();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
it("returns null when a review was already submitted", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(
|
||||
makeToolState({
|
||||
selectedMode: "Review",
|
||||
review: { id: 1, nodeId: "n", reviewedSha: undefined },
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("does not flip a successful run to failed when reflection returns success:false", async () => {
|
||||
// the reflection turn is a best-effort nudge (update_learnings). if it
|
||||
// fails — e.g. the model API errors mid-turn — the underlying task has
|
||||
// already completed and been gated cleanly, so the run as a whole must
|
||||
// still be reported as successful.
|
||||
const initial = successResult({ output: "task done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({ success: false, error: "model API transient failure" });
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: call update_learnings if anything is worth saving",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("task done");
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(resume).toHaveBeenCalledTimes(1);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toMatch(/REFLECTION/);
|
||||
it("returns null when report_progress wrote a final summary", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(makeToolState({ selectedMode: "Review", finalSummaryWritten: true }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("still aggregates usage from a failed reflection turn", async () => {
|
||||
// the reflection consumed tokens even if it didn't produce useful output;
|
||||
// the run total must reflect that so billing/reporting stays accurate.
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: false,
|
||||
error: "model API transient failure",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.usage?.inputTokens).toBe(110);
|
||||
expect(result.usage?.outputTokens).toBe(55);
|
||||
it("returns null when there is no progress comment to anchor the failure to", () => {
|
||||
expect(
|
||||
getUnsubmittedReview(makeToolState({ selectedMode: "Review", hadProgressComment: false }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the reflection's output when the pre-reflection output is empty", async () => {
|
||||
// the preservation fix must only kick in when the task actually produced
|
||||
// meaningful output. runs that communicate exclusively through MCP tools
|
||||
// (e.g. report_progress) leave result.output = "" — using `??` here kept
|
||||
// the empty string and dropped the reflection's reply, leaving the
|
||||
// fallback `handleAgentResult` path with nothing to show. prefer the
|
||||
// reflection's output (even a trivial "done") over no output at all.
|
||||
const initial = successResult({ output: "" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("done");
|
||||
});
|
||||
|
||||
it("preserves the pre-reflection task output when a trivial reflection ('done') succeeds", async () => {
|
||||
// the reflection turn is a meta-ask — its literal reply ("done" or a
|
||||
// short "updated learnings with N bullets") is not the task summary the
|
||||
// caller wants to see. before this fix, `result = reflectionResult`
|
||||
// clobbered the task's output on the returned AgentResult, so downstream
|
||||
// consumers (handleAgentResult's fallback path when toolState is empty,
|
||||
// programmatic callers of main()) saw "done" instead of the real
|
||||
// summary. assert the task's output survives a successful reflection.
|
||||
const initial = successResult({ output: "Implemented feature X; tests pass; pushed PR #42" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.output).toBe("Implemented feature X; tests pass; pushed PR #42");
|
||||
});
|
||||
|
||||
it("skips reflection entirely when canResume returns false", async () => {
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
canResume: () => false,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("catches a reflection turn that dirties the tree via the dirty-tree gate on the next iteration", async () => {
|
||||
// PR claims: "if the reflection turn dirties the tree, the loop picks
|
||||
// that up on the next iteration via the normal dirty-tree gate." lock
|
||||
// it in — without this invariant the reflection prompt could bypass the
|
||||
// commit-before-you-finish contract whenever the agent misbehaves.
|
||||
//
|
||||
// three getGitStatus calls in sequence:
|
||||
// 1. clean (triggers reflection)
|
||||
// 2. reflection left the tree dirty
|
||||
// 3. retry committed the changes — now clean, loop exits
|
||||
mockedGetGitStatus
|
||||
.mockReturnValueOnce("")
|
||||
.mockReturnValueOnce(" M scratch/notes.md")
|
||||
.mockReturnValueOnce("");
|
||||
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "resumed" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
reflectionPrompt: "REFLECTION: consider update_learnings",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// call 0: reflection; call 1: dirty-tree retry
|
||||
expect(resume).toHaveBeenCalledTimes(2);
|
||||
expect(resume.mock.calls[0]?.[0].prompt).toContain("REFLECTION");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
expect(resume.mock.calls[1]?.[0].prompt).toContain("scratch/notes.md");
|
||||
});
|
||||
|
||||
it("surfaces a persistent stop hook failure as AgentResult.error after MAX_POST_RUN_RETRIES", async () => {
|
||||
// PR test plan item #1: "confirm the agent is resumed with the hook
|
||||
// output and the run fails after 3 attempts if never resolved."
|
||||
//
|
||||
// stop the hook from passing on every invocation, have `resume` always
|
||||
// return success (the agent tried but couldn't fix the issue), and
|
||||
// verify: (a) the loop exhausts all retries, (b) the final result is
|
||||
// success=false, (c) the error mentions the retry count and the hook
|
||||
// output verbatim so the GitHub comment surfaces what actually failed.
|
||||
const hookFailure = {
|
||||
stdout: "lint: 3 issues in src/foo.ts",
|
||||
stderr: "",
|
||||
exitCode: 7,
|
||||
durationMs: 5,
|
||||
};
|
||||
mockedSpawn.mockResolvedValue(hookFailure);
|
||||
|
||||
const initial = successResult({ output: "agent done" });
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "retry done" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: undefined,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("exit code 7");
|
||||
expect(result.error).toContain("3 retry attempts");
|
||||
expect(result.error).toContain("lint: 3 issues in src/foo.ts");
|
||||
// each retry feeds the hook output back into the agent as the resume prompt
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("STOP HOOK FAILED");
|
||||
expect(call[0].prompt).toContain("lint: 3 issues in src/foo.ts");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats a persistently dirty tree (no stop hook failure) as a soft-fail", async () => {
|
||||
// the PR documents: "dirty-tree-only failures preserve prior behavior:
|
||||
// they're logged but don't fail the run." a regression that started
|
||||
// surfacing dirty-tree as AgentResult.error would make every run that
|
||||
// leaves untracked test fixtures around fail spuriously. guard it.
|
||||
mockedGetGitStatus.mockReturnValue(" M src/foo.ts");
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult({ output: "tried but tree still dirty" }));
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: null,
|
||||
resume,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.error).toBeUndefined();
|
||||
// retries were attempted (the loop fed the dirty-tree prompt back to the agent)
|
||||
expect(resume).toHaveBeenCalledTimes(3);
|
||||
for (const call of resume.mock.calls) {
|
||||
expect(call[0].prompt).toContain("UNCOMMITTED CHANGES");
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces a stop hook failure even when canResume is false (no retry budget, still fails the run)", async () => {
|
||||
// the retry loop is best-effort. when canResume says no (e.g. claude
|
||||
// without a sessionId), we still need the failure gate to fire so the
|
||||
// user sees WHY the run failed instead of an opaque success. covers the
|
||||
// "checks still ran even if we can't resume" comment in postRun.ts.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "typecheck: 2 errors",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult();
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm typecheck",
|
||||
resume,
|
||||
canResume: () => false,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("stop hook failed");
|
||||
expect(result.error).toContain("typecheck: 2 errors");
|
||||
// no retries were attempted because canResume said no — error lists no
|
||||
// retry count (that would be a lie).
|
||||
expect(result.error).not.toContain("retry attempt");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("short-circuits the loop when the initial result is already failed", async () => {
|
||||
// if the agent already failed (timeout, model error) there's no point
|
||||
// running gates or a reflection — the run is toast. preserve the original
|
||||
// error verbatim so triage is straightforward.
|
||||
const initial: AgentResult = {
|
||||
success: false,
|
||||
error: "agent died mid-turn",
|
||||
output: "partial",
|
||||
};
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue(successResult());
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: undefined,
|
||||
stopScript: "pnpm lint",
|
||||
resume,
|
||||
reflectionPrompt: "reflect",
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("agent died mid-turn");
|
||||
expect(resume).not.toHaveBeenCalled();
|
||||
expect(mockedSpawn).not.toHaveBeenCalled();
|
||||
expect(mockedGetGitStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aggregates usage across every gate retry", async () => {
|
||||
// billing/reporting rely on the usage summary reflecting the full run,
|
||||
// not just the final retry's slice. regression gate.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "fail",
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const initial = successResult({
|
||||
usage: { agent: "claude", inputTokens: 100, outputTokens: 50 },
|
||||
});
|
||||
const resume = vi
|
||||
.fn<(ctx: { prompt: string; previousResult: AgentResult }) => Promise<AgentResult>>()
|
||||
.mockResolvedValue({
|
||||
success: true,
|
||||
output: "retry",
|
||||
usage: { agent: "claude", inputTokens: 10, outputTokens: 5 },
|
||||
});
|
||||
|
||||
const result = await runPostRunRetryLoop({
|
||||
initialResult: initial,
|
||||
initialUsage: initial.usage,
|
||||
stopScript: "flaky",
|
||||
resume,
|
||||
});
|
||||
|
||||
// 100 initial + 10 * 3 retries = 130
|
||||
expect(result.usage?.inputTokens).toBe(130);
|
||||
expect(result.usage?.outputTokens).toBe(65);
|
||||
});
|
||||
});
|
||||
|
||||
describe("executeStopHook — output capture", () => {
|
||||
beforeEach(() => {
|
||||
mockedSpawn.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("includes both stdout and stderr in the failure output when both are populated", async () => {
|
||||
// hooks that wrap other tools commonly emit a benign warning to stderr
|
||||
// (e.g. "config file not found, using defaults") and the actionable error
|
||||
// to stdout. a `(stderr || stdout)` heuristic drops stdout entirely
|
||||
// whenever stderr is non-empty, starving the agent of the information it
|
||||
// needs to fix the issue.
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: "ERROR: lint check failed at path/to/file.ts:42",
|
||||
stderr: "Warning: config file not found, using defaults",
|
||||
exitCode: 1,
|
||||
durationMs: 5,
|
||||
});
|
||||
const failure = await executeStopHook("run-lint");
|
||||
expect(failure).not.toBeNull();
|
||||
expect(failure?.output).toContain("ERROR: lint check failed at path/to/file.ts:42");
|
||||
expect(failure?.output).toContain("Warning: config file not found, using defaults");
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) when spawn throws a timeout", async () => {
|
||||
// infra-level failures can't be fixed by the agent. surfacing them as a
|
||||
// hook failure would put the loop into a retry cycle that never
|
||||
// terminates. soft-fail and let the run succeed.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
new SpawnTimeoutError("hook exceeded 10 minutes", SPAWN_TIMEOUT_CODE)
|
||||
it("returns the selected mode when the gate should fire", () => {
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "Review" }))).toBe("Review");
|
||||
expect(getUnsubmittedReview(makeToolState({ selectedMode: "IncrementalReview" }))).toBe(
|
||||
"IncrementalReview"
|
||||
);
|
||||
const failure = await executeStopHook("slow-hook");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null (treated as passed) on spawn ENOENT (command not found)", async () => {
|
||||
// if the user misconfigures the hook (wrong binary, typo), the spawn
|
||||
// itself throws. same rationale as timeouts: soft-fail, don't retry.
|
||||
mockedSpawn.mockRejectedValue(
|
||||
Object.assign(new Error("spawn nosuchbin ENOENT"), { code: "ENOENT" })
|
||||
);
|
||||
const failure = await executeStopHook("nosuchbin");
|
||||
expect(failure).toBeNull();
|
||||
});
|
||||
|
||||
it("truncates oversize output, keeping the tail", async () => {
|
||||
// the error is embedded in AgentResult.error and flows into GitHub
|
||||
// comments (65535-char cap). the 4096-char truncation is our guardrail;
|
||||
// lock it in so a well-meaning refactor can't blow the comment budget.
|
||||
const longTail = "LAST_LINE_IS_ACTIONABLE";
|
||||
const longOutput = "x".repeat(10_000) + longTail;
|
||||
mockedSpawn.mockResolvedValue({
|
||||
stdout: longOutput,
|
||||
stderr: "",
|
||||
exitCode: 1,
|
||||
durationMs: 1,
|
||||
});
|
||||
const failure = await executeStopHook("noisy");
|
||||
expect(failure?.output).toContain(longTail);
|
||||
expect(failure?.output).toContain("truncated");
|
||||
expect(failure?.output.length).toBeLessThan(longOutput.length);
|
||||
});
|
||||
});
|
||||
|
||||
+139
-57
@@ -1,6 +1,7 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { type AgentId, formatMcpToolRef } from "../external.ts";
|
||||
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,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
} from "../utils/subprocess.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
type AgentRunContext,
|
||||
type AgentUsage,
|
||||
buildCommitPrompt,
|
||||
getGitStatus,
|
||||
@@ -20,6 +22,23 @@ import {
|
||||
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.
|
||||
*/
|
||||
export function getUnsubmittedReview(toolState: ToolState): "Review" | "IncrementalReview" | null {
|
||||
const mode = toolState.selectedMode;
|
||||
if (mode !== "Review" && mode !== "IncrementalReview") return null;
|
||||
if (toolState.review || toolState.finalSummaryWritten) return null;
|
||||
if (!toolState.hadProgressComment) return null;
|
||||
return mode;
|
||||
}
|
||||
|
||||
/**
|
||||
* hook output can flow into two size-sensitive places: the LLM resume prompt
|
||||
* (context window) and AgentResult.error (surfaced in GitHub comments capped
|
||||
@@ -116,67 +135,120 @@ export function buildSummaryStalePrompt(filePath: string): string {
|
||||
].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 `> [!NOTE]` reviews and `No new issues found.` reviews must be submitted (both use `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? returns everything that still needs nudging so the caller can
|
||||
* render a single combined resume prompt.
|
||||
* snapshot or produce review output? returns everything that still needs
|
||||
* nudging so the caller can render a single combined resume prompt.
|
||||
*
|
||||
* the summary-stale check is skipped when `summaryFilePath` / `summarySeed`
|
||||
* are not provided; this is the common case (non-PR runs, runs where the
|
||||
* dispatcher didn't request snapshot generation, runs where the seed step
|
||||
* failed). loop callers also pass these as undefined after the agent has
|
||||
* already been nudged once, to avoid burning the retry budget on a soft
|
||||
* non-blocking gate.
|
||||
* 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(params: {
|
||||
stopScript: string | null | undefined;
|
||||
summaryFilePath?: string | undefined;
|
||||
summarySeed?: string | undefined;
|
||||
}): Promise<PostRunIssues> {
|
||||
export async function collectPostRunIssues(
|
||||
ctx: AgentRunContext,
|
||||
options: { skipSummaryStale?: boolean } = {}
|
||||
): Promise<PostRunIssues> {
|
||||
const issues: PostRunIssues = {};
|
||||
if (params.stopScript) {
|
||||
const failure = await executeStopHook(params.stopScript);
|
||||
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();
|
||||
if (status) issues.dirtyTree = status;
|
||||
if (params.summaryFilePath && params.summarySeed !== undefined) {
|
||||
const stale = await isSummaryUnchanged(params.summaryFilePath, params.summarySeed);
|
||||
if (stale) issues.summaryStale = { filePath: params.summaryFilePath };
|
||||
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");
|
||||
}
|
||||
|
||||
/**
|
||||
* prompt for a dedicated post-run reflection turn nudging the agent to call
|
||||
* `update_learnings` if it discovered anything worth persisting.
|
||||
* 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 the learnings step baked into mode checklists is
|
||||
* 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.
|
||||
* 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`.
|
||||
*/
|
||||
export function buildLearningsReflectionPrompt(agentId: AgentId): string {
|
||||
const t = (name: string) => formatMcpToolRef(agentId, name);
|
||||
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 you are confident is correct and would reliably help future runs?`,
|
||||
`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?`,
|
||||
"",
|
||||
`if so, call \`${t("update_learnings")}\` to persist it.`,
|
||||
`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.`,
|
||||
"",
|
||||
`rules:`,
|
||||
`- only call \`${t("update_learnings")}\` when the finding is high-confidence and broadly useful. skip if unsure, speculative, or one-off.`,
|
||||
`- pass the FULL merged list: existing learnings from the original prompt + your new discoveries. one fact per bullet, lines starting with \`- \`.`,
|
||||
`- deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`,
|
||||
`- if you already called \`${t("update_learnings")}\` earlier in this run, or nothing new is worth capturing, just reply "done" and stop — do not edit the repo for this reflection.`,
|
||||
`keep the file healthy:`,
|
||||
`- only add bullets when the finding is high-confidence AND broadly useful. skip speculative, one-off, or "maybe" findings.`,
|
||||
`- prune bullets that are clearly wrong, no longer relevant, or low-signal (rarely useful). a focused, accurate file beats a long stale one.`,
|
||||
`- format: flat bullet list, one fact per line starting with \`- \`. deduplicate against existing entries — if a bullet covers the same fact, update it in place instead of adding a duplicate.`,
|
||||
`- leave the file alone if you have nothing substantively new to add and the existing entries still look healthy. silence is a valid outcome — just reply "done" and stop.`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -200,17 +272,9 @@ export function buildLearningsReflectionPrompt(agentId: AgentId): string {
|
||||
* 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;
|
||||
stopScript: string | null | undefined;
|
||||
/** absolute path to the seeded PR summary file. when set together with
|
||||
* `summarySeed`, the loop checks after each agent attempt whether the
|
||||
* file has been edited; if not, it nudges the agent ONCE via a resume
|
||||
* turn (subsequent iterations skip the check so we don't keep burning
|
||||
* retries on a soft gate when the agent has decided no edit is warranted). */
|
||||
summaryFilePath?: string | undefined;
|
||||
/** exact bytes of the seeded summary file used for the unchanged-check. */
|
||||
summarySeed?: string | undefined;
|
||||
resume: (context: { prompt: string; previousResult: R }) => Promise<R>;
|
||||
canResume?: ((result: R) => boolean) | undefined;
|
||||
reflectionPrompt?: string | undefined;
|
||||
@@ -220,19 +284,16 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
let finalIssues: PostRunIssues = {};
|
||||
let gateResumeCount = 0;
|
||||
let pendingReflection = params.reflectionPrompt;
|
||||
// nudge for an untouched summary file fires AT MOST ONCE per run. after
|
||||
// we've delivered the prompt, subsequent gate checks pass undefined so
|
||||
// the loop doesn't keep flagging the same condition — the agent may have
|
||||
// legitimately decided no edit is warranted, and re-prompting would
|
||||
// burn the retry budget without adding signal.
|
||||
// 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({
|
||||
stopScript: params.stopScript,
|
||||
summaryFilePath: summaryStaleNudged ? undefined : params.summaryFilePath,
|
||||
summarySeed: summaryStaleNudged ? undefined : params.summarySeed,
|
||||
const issues = await collectPostRunIssues(params.ctx, {
|
||||
skipSummaryStale: summaryStaleNudged,
|
||||
});
|
||||
if (issues.summaryStale) summaryStaleNudged = true;
|
||||
finalIssues = issues;
|
||||
@@ -318,10 +379,11 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
// 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). 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({ stopScript: params.stopScript });
|
||||
// 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) {
|
||||
@@ -337,5 +399,25 @@ export async function runPostRunRetryLoop<R extends AgentResult>(params: {
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -146,6 +146,40 @@ describe("SessionLabeler", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
+48
-18
@@ -67,38 +67,68 @@ export function deriveLabelFromTaskInput(input: TaskDispatchInput): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful tracker mapping sessionIDs to human labels.
|
||||
* Stateful tracker mapping subagent activity back to human-readable labels.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - First call to `labelFor()` returns ORCHESTRATOR_LABEL and binds that
|
||||
* sessionID to it. Every subsequent event from that session gets the
|
||||
* same label.
|
||||
* - When the orchestrator emits a Task tool_use, the harness calls
|
||||
* `recordTaskDispatch()` to push the dispatch's derived label onto a
|
||||
* pending FIFO queue.
|
||||
* - The next previously-unseen sessionID consumes the head of the queue.
|
||||
* - If `labelFor()` is called for a new session with an empty queue
|
||||
* (e.g. a subagent emitted events before the parent's tool_use was
|
||||
* parsed, or the runtime spawned a session we didn't expect), the
|
||||
* labeler falls back to `subagent#N` so log lines remain attributable.
|
||||
* 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;
|
||||
|
||||
recordTaskDispatch(input: TaskDispatchInput): string {
|
||||
/**
|
||||
* 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 sessionID. Binds on first call.
|
||||
* Pass undefined/empty for events that lack a session id — the caller
|
||||
* gets ORCHESTRATOR_LABEL so the line is still attributable.
|
||||
* 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): string {
|
||||
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;
|
||||
|
||||
+28
-14
@@ -1,5 +1,6 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { AgentId } from "../external.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
@@ -54,13 +55,25 @@ export interface PostRunIssues {
|
||||
* 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.summaryStale !== undefined ||
|
||||
issues.unsubmittedReview !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -104,7 +117,14 @@ export interface AgentResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal context passed to agent.run()
|
||||
* 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;
|
||||
@@ -120,19 +140,13 @@ export interface AgentRunContext {
|
||||
*/
|
||||
stopScript?: string | null | undefined;
|
||||
/**
|
||||
* absolute path to the rolling PR summary tmpfile, when one was seeded
|
||||
* for this run (Review / IncrementalReview / pr-summary Task). enables
|
||||
* a post-run sanity nudge that prompts the agent if the file is still
|
||||
* byte-identical to its seed.
|
||||
* 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.
|
||||
*/
|
||||
summaryFilePath?: string | undefined;
|
||||
/**
|
||||
* exact bytes of the seeded summary file. compared against the current
|
||||
* file content after each agent attempt to detect "agent forgot to edit
|
||||
* the summary" — particularly common with smaller models that lose
|
||||
* track of multi-step instructions.
|
||||
*/
|
||||
summarySeed?: string | undefined;
|
||||
toolState: ToolState;
|
||||
/**
|
||||
* called synchronously when the agent subprocess is killed for inner
|
||||
* activity timeout. lets main.ts tear down shared resources (MCP HTTP
|
||||
|
||||
@@ -273,6 +273,13 @@ export interface WriteablePayload {
|
||||
triggerer?: string | undefined;
|
||||
/** event-level instructions for this trigger type (flag-expanded server-side) */
|
||||
eventInstructions?: string | undefined;
|
||||
/**
|
||||
* system-injected note about prior superseded runs (e.g. when the
|
||||
* triggering @pullfrog comment is edited). rendered alongside the user's
|
||||
* prompt rather than via eventInstructions so it survives user-prompt
|
||||
* precedence.
|
||||
*/
|
||||
previousRunsNote?: string | undefined;
|
||||
/** event data from webhook payload - discriminated union based on trigger field */
|
||||
event: PayloadEvent;
|
||||
/** timeout for agent run (e.g., "10m", "1h30m") - defaults to "1h" */
|
||||
|
||||
@@ -6,13 +6,9 @@ import { join } from "node:path";
|
||||
import * as core from "@actions/core";
|
||||
import { deleteProgressComment, reportProgress } from "./mcp/comment.ts";
|
||||
import { startInstallation } from "./mcp/dependencies.ts";
|
||||
import {
|
||||
initToolState,
|
||||
startMcpHttpServer,
|
||||
type ToolContext,
|
||||
type ToolState,
|
||||
} from "./mcp/server.ts";
|
||||
import { startMcpHttpServer, type ToolContext } from "./mcp/server.ts";
|
||||
import { computeModes } from "./modes.ts";
|
||||
import { initToolState, type ToolState } from "./toolState.ts";
|
||||
import {
|
||||
type ActivityTimeout,
|
||||
createProcessOutputActivityTimeout,
|
||||
@@ -22,6 +18,7 @@ import {
|
||||
import { resolveAgent, resolveModel } from "./utils/agent.ts";
|
||||
import { apiFetch } from "./utils/apiFetch.ts";
|
||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||
import { isLocalApiUrl } from "./utils/apiUrl.ts";
|
||||
import { resolveBody } from "./utils/body.ts";
|
||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
|
||||
@@ -31,8 +28,9 @@ import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
|
||||
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
|
||||
import { createOctokit, writeGitHubUsageSummaryToFile } from "./utils/github.ts";
|
||||
import { resolveInstructions } from "./utils/instructions.ts";
|
||||
import { readLearningsFile, seedLearningsFile } from "./utils/learnings.ts";
|
||||
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
||||
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||
import { normalizeEnv, sanitizeSecret } from "./utils/normalizeEnv.ts";
|
||||
import { aggregateUsage, patchWorkflowRunFields } from "./utils/patchWorkflowRunFields.ts";
|
||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
||||
import { isRouterKeylimitExhaustedError } from "./utils/providerErrors.ts";
|
||||
@@ -185,8 +183,9 @@ function billingConsoleUrl(owner: string, anchor: "billing" | "model-access"): s
|
||||
*
|
||||
* Branches:
|
||||
* - `router_requires_card`: user is on Router mode with no card AND no
|
||||
* wallet balance. Lead with the carrot ($20 free credit), link to
|
||||
* `#model-access` where the Add Card flow lives.
|
||||
* wallet balance (signup credit exhausted or not granted). Frame as
|
||||
* "add a card to continue", link to `#model-access` where the Add
|
||||
* Card flow lives.
|
||||
* - `router_balance_exhausted`: user has a card on file but auto-reload is
|
||||
* disabled and they've spent past their $5 overdraft buffer. Frame as
|
||||
* "balance ran out" and surface both remediation paths (top up, or flip
|
||||
@@ -208,7 +207,7 @@ function formatBillingErrorSummary(error: BillingError, owner: string): string {
|
||||
return [
|
||||
"**Add a card to start using Pullfrog Router.**",
|
||||
"",
|
||||
"Router proxies OpenRouter at raw cost — no platform markup, and your first $20 of usage is on us.",
|
||||
"Router proxies OpenRouter at raw cost — no platform markup. Add a card and we'll auto-reload your wallet so runs keep flowing.",
|
||||
"",
|
||||
`[Add a card →](${billingConsoleUrl(owner, "model-access")})`,
|
||||
].join("\n");
|
||||
@@ -279,18 +278,18 @@ function formatTransientErrorSummary(error: TransientError, owner: string): stri
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<string | null> {
|
||||
async function mintProxyKey(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
const headers = await buildProxyTokenHeaders(ctx);
|
||||
if (!headers) return null;
|
||||
|
||||
const response = await apiFetch({
|
||||
path: "/api/proxy-token",
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${oidcToken}` },
|
||||
headers,
|
||||
});
|
||||
|
||||
if (response.status === 402) {
|
||||
@@ -336,12 +335,44 @@ async function mintProxyKey(ctx: { oidcCredentials: OidcCredentials }): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* choose how to authenticate the `/api/proxy-token` request:
|
||||
*
|
||||
* - production: mint a fresh OIDC token via `core.getIDToken` and send as
|
||||
* `Authorization: Bearer …` (the server verifies it cryptographically).
|
||||
* - local dev (no OIDC + `API_URL` is localhost): send `x-dev-repo:
|
||||
* owner/repo` instead. the server-side route only honors this header
|
||||
* when `NODE_ENV === "development"`, so prod is never reachable through
|
||||
* this branch even if the action is misconfigured.
|
||||
*
|
||||
* returns null when neither path is available — caller treats as soft skip.
|
||||
*/
|
||||
async function buildProxyTokenHeaders(ctx: {
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<Record<string, string> | null> {
|
||||
if (ctx.oidcCredentials) {
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = ctx.oidcCredentials.requestUrl;
|
||||
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = ctx.oidcCredentials.requestToken;
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
|
||||
delete process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
|
||||
return { Authorization: `Bearer ${oidcToken}` };
|
||||
}
|
||||
if (isLocalApiUrl()) {
|
||||
log.info(`» proxy: dev bypass (x-dev-repo) for ${ctx.repo.owner}/${ctx.repo.name}`);
|
||||
return { "x-dev-repo": `${ctx.repo.owner}/${ctx.repo.name}` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveProxyModel(ctx: {
|
||||
payload: ResolvedPayload;
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
proxyModel?: string | undefined;
|
||||
oidcCredentials: OidcCredentials | null;
|
||||
repo: { owner: string; name: string };
|
||||
}): Promise<void> {
|
||||
// env override = BYOK escape hatch, don't proxy
|
||||
if (process.env.PULLFROG_MODEL?.trim()) return;
|
||||
@@ -349,12 +380,15 @@ async function resolveProxyModel(ctx: {
|
||||
const needsProxy = isInfraCovered({ isOss: ctx.oss, plan: ctx.plan }) && ctx.proxyModel;
|
||||
if (!needsProxy) return;
|
||||
|
||||
if (!ctx.oidcCredentials) {
|
||||
// dev affordance: when talking to a localhost API, the server-side
|
||||
// x-dev-repo bypass replaces OIDC verification, so a play run can
|
||||
// exercise the proxy/router/oss path without GitHub Actions OIDC.
|
||||
if (!ctx.oidcCredentials && !isLocalApiUrl()) {
|
||||
log.warning("» proxy requested but no OIDC credentials available — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials });
|
||||
const key = await mintProxyKey({ oidcCredentials: ctx.oidcCredentials, repo: ctx.repo });
|
||||
if (!key) return;
|
||||
|
||||
process.env.OPENROUTER_API_KEY = key;
|
||||
@@ -395,6 +429,62 @@ async function fetchPreviousSnapshot(ctx: ToolContext, prNumber: number): Promis
|
||||
* (on incremental runs) or serialize the placeholder scaffold (on first
|
||||
* runs), neither of which is useful.
|
||||
*/
|
||||
/**
|
||||
* Read the agent-edited repo-level learnings tmpfile and PATCH it to
|
||||
* `Repo.learnings`.
|
||||
*
|
||||
* Best-effort: any failure is logged and does not affect the run's success
|
||||
* status. Skips the PATCH when the file is byte-trim-identical to its seed —
|
||||
* the agent didn't touch it, so writing the same content back would just
|
||||
* burn a `LearningsRevision` row and an API round-trip.
|
||||
*
|
||||
* `model` is forwarded so `LearningsRevision.model` keeps populating; it
|
||||
* powers the per-revision attribution badge in the UI history view.
|
||||
*/
|
||||
async function persistLearnings(ctx: ToolContext): Promise<void> {
|
||||
const filePath = ctx.toolState.learningsFilePath;
|
||||
if (!filePath) return;
|
||||
if (ctx.toolState.learningsPersistAttempted) return;
|
||||
ctx.toolState.learningsPersistAttempted = true;
|
||||
const current = await readLearningsFile(filePath);
|
||||
if (current === null) {
|
||||
log.debug(`learnings tmpfile missing or unreadable at ${filePath} — skipping persist`);
|
||||
return;
|
||||
}
|
||||
const seed = ctx.toolState.learningsSeed?.trim() ?? "";
|
||||
if (current === seed) {
|
||||
log.debug("learnings tmpfile unchanged from seed — skipping persist");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
learnings: current,
|
||||
model: ctx.toolState.model,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.text().catch(() => "(no body)");
|
||||
// promoted from debug → warning: this path means the agent edited the
|
||||
// file (we already short-circuited the unchanged-from-seed case above)
|
||||
// but the PATCH dropped it on the floor. silently losing real work is
|
||||
// worse than the noise of a CI warning.
|
||||
log.warning(`learnings persist failed (${response.status}): ${error}`);
|
||||
return;
|
||||
}
|
||||
log.info("» learnings updated");
|
||||
} catch (err) {
|
||||
log.warning(`learnings persist failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function persistSummary(ctx: ToolContext): Promise<void> {
|
||||
const filePath = ctx.toolState.summaryFilePath;
|
||||
if (!filePath) return;
|
||||
@@ -425,9 +515,14 @@ async function persistSummary(ctx: ToolContext): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
// fall back to the agent's final assistant message when the agent never
|
||||
// called report_progress (e.g. schedule/workflow_dispatch runs that have no
|
||||
// PR/issue context to comment on). lastProgressBody wins when present so we
|
||||
// don't double up the progress comment body in the job summary.
|
||||
async function writeJobSummary(toolState: ToolState, finalOutput?: string): Promise<void> {
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
const body = toolState.lastProgressBody || finalOutput;
|
||||
const summaryParts = [body, usageSummary].filter(Boolean);
|
||||
if (summaryParts.length > 0) {
|
||||
await writeSummary(summaryParts.join("\n\n"));
|
||||
}
|
||||
@@ -464,12 +559,15 @@ export async function main(): Promise<MainResult> {
|
||||
const runContext = await resolveRunContextData({ octokit: initialOctokit, token: jobToken });
|
||||
timer.checkpoint("runContextData");
|
||||
|
||||
// inject account-level secrets into process.env (YAML secrets take precedence)
|
||||
// inject account-level secrets into process.env (YAML secrets take precedence).
|
||||
// sanitizeSecret trims + masks so accidental trailing whitespace doesn't leak
|
||||
// through GitHub Actions' line-based log masking. whitespace-only values
|
||||
// return null and skip injection so the user sees a clear missing-key error.
|
||||
if (runContext.dbSecrets) {
|
||||
for (const [key, value] of Object.entries(runContext.dbSecrets)) {
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = value;
|
||||
core.setSecret(value);
|
||||
const sanitized = sanitizeSecret(key, value);
|
||||
if (sanitized !== null) process.env[key] = sanitized;
|
||||
}
|
||||
}
|
||||
const count = Object.keys(runContext.dbSecrets).length;
|
||||
@@ -520,6 +618,7 @@ export async function main(): Promise<MainResult> {
|
||||
plan: runContext.plan,
|
||||
proxyModel: runContext.proxyModel,
|
||||
oidcCredentials,
|
||||
repo: runContext.repo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof BillingError) {
|
||||
@@ -647,6 +746,47 @@ export async function main(): Promise<MainResult> {
|
||||
log.info(`» MCP server started at ${mcpHttpServer.url}`);
|
||||
timer.checkpoint("mcpServer");
|
||||
|
||||
// seed the rolling repo-level learnings tmpfile for every run. the
|
||||
// agent reads the file at startup (path is surfaced in the LEARNINGS
|
||||
// section of the prompt) and may edit it during the post-run
|
||||
// reflection turn. persistLearnings reads it back at end-of-run and
|
||||
// PATCHes any changes to Repo.learnings, byte-trim equality against
|
||||
// the seed gates the API call. always-seed (vs gated): learnings are
|
||||
// universal — any run can produce them, and gating just hides the
|
||||
// affordance.
|
||||
//
|
||||
// wrapped in best-effort try/catch: this block runs unconditionally,
|
||||
// and an unwrapped filesystem failure (ENOSPC, EACCES, hostile sandbox)
|
||||
// would unwind into the outer main() catch and flip an otherwise-
|
||||
// successful run to "❌ Pullfrog failed" before the agent even starts.
|
||||
// matches `persistLearnings`'s own best-effort contract — learnings
|
||||
// are a peripheral artifact, not a load-bearing capability. on failure
|
||||
// toolState.learningsFilePath stays unset, and downstream consumers
|
||||
// (`persistLearnings`, agent harnesses, `resolveInstructions`) all
|
||||
// treat undefined as "no learnings affordance this run".
|
||||
try {
|
||||
const learningsPath = await seedLearningsFile({
|
||||
tmpdir,
|
||||
current: runContext.repoSettings.learnings,
|
||||
});
|
||||
toolState.learningsFilePath = learningsPath;
|
||||
try {
|
||||
toolState.learningsSeed = await readFile(learningsPath, "utf8");
|
||||
} catch {
|
||||
// intentionally empty — learningsSeed stays undefined, persistLearnings
|
||||
// will treat seed as "" and persist any non-empty content
|
||||
}
|
||||
log.info(
|
||||
`» learnings seeded at ${learningsPath} (existing=${runContext.repoSettings.learnings ? "yes" : "no"})`
|
||||
);
|
||||
const ctxForExit = toolContext;
|
||||
onExitSignal(() => persistLearnings(ctxForExit));
|
||||
} catch (err) {
|
||||
log.warning(
|
||||
`» learnings seed failed: ${err instanceof Error ? err.message : String(err)} — continuing without learnings file`
|
||||
);
|
||||
}
|
||||
|
||||
// seed the rolling PR summary tmpfile when the dispatcher requested it.
|
||||
// gated on event being a PR — issue/workflow_dispatch runs have no
|
||||
// summarySnapshot to maintain. file path is exposed to the agent via
|
||||
@@ -698,7 +838,7 @@ export async function main(): Promise<MainResult> {
|
||||
modes,
|
||||
agentId,
|
||||
outputSchema,
|
||||
learnings: runContext.repoSettings.learnings,
|
||||
learningsFilePath: toolState.learningsFilePath ?? null,
|
||||
});
|
||||
const logParts = [
|
||||
instructions.eventInstructions
|
||||
@@ -793,8 +933,7 @@ export async function main(): Promise<MainResult> {
|
||||
instructions,
|
||||
todoTracker,
|
||||
stopScript: runContext.repoSettings.stopScript,
|
||||
summaryFilePath: toolState.summaryFilePath,
|
||||
summarySeed: toolState.summarySeed,
|
||||
toolState,
|
||||
onActivityTimeout: onInnerActivityTimeout,
|
||||
onToolUse: (event) => {
|
||||
const wasTracked = recordDiffReadFromToolUse({
|
||||
@@ -882,6 +1021,29 @@ export async function main(): Promise<MainResult> {
|
||||
await persistSummary(toolContext);
|
||||
}
|
||||
|
||||
// same for the rolling repo-level learnings tmpfile. always seeded, so
|
||||
// always read back; persistLearnings short-circuits when the file is
|
||||
// unchanged from its seed.
|
||||
if (toolContext) {
|
||||
await persistLearnings(toolContext);
|
||||
}
|
||||
|
||||
// when the agent harness returns success=false (e.g. unsubmitted-review
|
||||
// gate exhausted retries, stop-hook persistently failing), surface the
|
||||
// error in the progress comment so the user sees it instead of a
|
||||
// deleted-comment void. mirrors the catch-block error reporting for
|
||||
// thrown errors. runs before the stranded-comment cleanup below so
|
||||
// the comment is still around to update; reportErrorToComment sets
|
||||
// wasUpdated=true and the !result.success guard skips deletion.
|
||||
if (!result.success && toolContext && toolState.progressComment) {
|
||||
await reportErrorToComment({
|
||||
toolState,
|
||||
error: result.error || "agent run failed",
|
||||
}).catch((error) => {
|
||||
log.debug(`failure error report failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
// clean up stranded progress comments. the comment is stale unless
|
||||
// report_progress wrote a final summary to it — three sub-cases all reduce
|
||||
// to !finalSummaryWritten:
|
||||
@@ -895,14 +1057,34 @@ export async function main(): Promise<MainResult> {
|
||||
// so progressComment is already null by the time we get here for that path.
|
||||
// uses finalSummaryWritten (not todoTracker.enabled or wasUpdated) so cleanup
|
||||
// survives API failures in report_progress where cancel() ran but the write
|
||||
// didn't succeed, and isn't fooled by writes to *other* artifacts.
|
||||
if (toolContext && toolState.progressComment && !toolState.finalSummaryWritten) {
|
||||
// didn't succeed, and isn't fooled by writes to *other* artifacts. skipped
|
||||
// entirely on result.success===false: the error message just written above
|
||||
// is the user's only signal that the run happened — deleting it would
|
||||
// restore the same empty-void UX this commit fixes.
|
||||
if (
|
||||
toolContext &&
|
||||
result.success &&
|
||||
toolState.progressComment &&
|
||||
!toolState.finalSummaryWritten
|
||||
) {
|
||||
await deleteProgressComment(toolContext).catch((error) => {
|
||||
log.debug(`stranded progress comment cleanup failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
await writeJobSummary(toolState);
|
||||
// best-effort: failures writing the actions step summary must not throw
|
||||
// past this point. on the result.success===false branch above we already
|
||||
// wrote `result.error` to the progress comment, and a throw here would
|
||||
// jump to the outer catch which calls reportErrorToComment again with
|
||||
// the (less actionable) writeJobSummary error — silently overwriting the
|
||||
// gate's failure message in the progress comment. the step-summary write
|
||||
// is informational; let it fail silently rather than corrupt user-facing
|
||||
// output.
|
||||
try {
|
||||
await writeJobSummary(toolState, result.output);
|
||||
} catch (error) {
|
||||
log.debug(`job summary write failed: ${error}`);
|
||||
}
|
||||
|
||||
// emit structured output marker for test validation
|
||||
if (toolState.output) {
|
||||
@@ -966,6 +1148,12 @@ export async function main(): Promise<MainResult> {
|
||||
await persistSummary(toolContext);
|
||||
}
|
||||
|
||||
// same rationale for learnings: a partial edit before a crash is still
|
||||
// worth keeping. persistLearnings is idempotent via learningsPersistAttempted.
|
||||
if (toolContext) {
|
||||
await persistLearnings(toolContext);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
|
||||
+88
-3
@@ -8,6 +8,7 @@ import { countLines, createDiffCoverageState } from "../utils/diffCoverage.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { computeIncrementalDiff } from "../utils/rangeDiff.ts";
|
||||
import { retry } from "../utils/retry.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import { rejectIfLeadingDash } from "./git.ts";
|
||||
import { commentableLinesForFile } from "./review.ts";
|
||||
@@ -281,6 +282,12 @@ type CheckoutPrBranchParams = GitContext & {
|
||||
// legitimate git op that's holding the lock.
|
||||
const STALE_LOCK_AGE_MS = 30_000;
|
||||
|
||||
// PR head refs (refs/pull/N/head) sometimes lag the pull_request.opened
|
||||
// webhook by a few seconds. retry the missing-ref case with backoff
|
||||
// before giving up — see issue #591.
|
||||
const PULL_REF_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
|
||||
const PULL_REF_MISSING_PATTERN = /couldn't find remote ref pull\/\d+\/head/i;
|
||||
|
||||
const GIT_LOCK_PATHS = [
|
||||
".git/shallow.lock",
|
||||
".git/index.lock",
|
||||
@@ -308,6 +315,62 @@ function cleanupStaleGitLocks(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns false when a PR's current state diverges from what we dispatched
|
||||
* on (closed/merged, or head SHA differs from pr.headSha). Used to short-
|
||||
* circuit the pull/N/head retry loop when the ref is missing because the
|
||||
* PR has moved on, not because of a webhook race.
|
||||
*
|
||||
* Network failures here are treated as "still valid" — we'd rather burn the
|
||||
* retry budget than wrongly abort on a transient API blip.
|
||||
*
|
||||
* Note: this answers "should we keep trying?", NOT "will the next fetch
|
||||
* succeed?". `pulls.get` (REST API) and `pull/N/head` (git ref) are served
|
||||
* by independent GitHub replicas with their own propagation lag, so
|
||||
* `pulls.get` reporting an open PR with a matching head SHA does not
|
||||
* guarantee the git ref is yet visible — and vice versa (see issue #591
|
||||
* for the original webhook-vs-ref replication-lag context).
|
||||
*/
|
||||
async function isPullRequestStillDispatchable(args: {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
pr: PrData;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { data } = await args.octokit.rest.pulls.get({
|
||||
owner: args.owner,
|
||||
repo: args.repo,
|
||||
pull_number: args.pr.number,
|
||||
});
|
||||
if (data.state !== "open") return false;
|
||||
if (data.head.sha !== args.pr.headSha) return false;
|
||||
return true;
|
||||
} catch {
|
||||
// lenient — don't abort on API hiccups
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws the friendly clean-abort error when the PR has moved on since
|
||||
* dispatch. Wraps `isPullRequestStillDispatchable` so the abort message
|
||||
* lives in one place and is invoked from the inner `catch` around the
|
||||
* `pull/N/head` fetch on every missing-ref failure.
|
||||
*/
|
||||
async function abortIfPullRequestMoved(args: {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
repo: string;
|
||||
pr: PrData;
|
||||
}): Promise<void> {
|
||||
const stillValid = await isPullRequestStillDispatchable(args);
|
||||
if (stillValid) return;
|
||||
throw new Error(
|
||||
`PR #${args.pr.number} is no longer in the state it was at dispatch (likely closed, merged, or force-pushed between webhook fire and run start). aborting checkout — re-trigger the run if this PR is still active.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared helper to checkout a PR branch and configure fork remotes.
|
||||
* Assumes origin remote is already configured with authentication.
|
||||
@@ -365,9 +428,31 @@ export async function checkoutPrBranch(
|
||||
|
||||
// fetch PR branch using pull/{n}/head refspec (works for both fork and same-repo PRs)
|
||||
log.debug(`» fetching PR #${pr.number} (${localBranch})...`);
|
||||
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
});
|
||||
await retry(
|
||||
async () => {
|
||||
try {
|
||||
await $git("fetch", ["--no-tags", "origin", `+pull/${pr.number}/head:${localBranch}`], {
|
||||
token: gitToken,
|
||||
});
|
||||
} catch (e) {
|
||||
// on the webhook race, check whether the PR still matches what we
|
||||
// dispatched on. if it's been closed/merged or the head SHA moved,
|
||||
// no amount of retrying will populate the expected ref — surface a
|
||||
// clean abort error instead of burning the full retry budget.
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (PULL_REF_MISSING_PATTERN.test(msg)) {
|
||||
await abortIfPullRequestMoved({ octokit, owner, repo: name, pr });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
{
|
||||
delaysMs: PULL_REF_RETRY_DELAYS_MS,
|
||||
label: `pull/${pr.number}/head fetch`,
|
||||
shouldRetry: (e) =>
|
||||
PULL_REF_MISSING_PATTERN.test(e instanceof Error ? e.message : String(e)),
|
||||
}
|
||||
);
|
||||
|
||||
// checkout the branch
|
||||
$("git", ["checkout", localBranch], { log: false });
|
||||
|
||||
+77
-1
@@ -78,6 +78,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
});
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
log.info(`» created comment ${result.data.id}`);
|
||||
|
||||
if (commentType === "Plan") {
|
||||
if (result.data.node_id) {
|
||||
@@ -94,6 +95,7 @@ export function CreateCommentTool(ctx: ToolContext) {
|
||||
comment_id: result.data.id,
|
||||
body: bodyWithPlanLink,
|
||||
});
|
||||
log.info(`» updated comment ${updateResult.data.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -132,6 +134,7 @@ export function EditCommentTool(ctx: ToolContext) {
|
||||
comment_id: commentId,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» updated comment ${result.data.id}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -339,6 +342,10 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (result.commentId !== undefined) {
|
||||
log.info(`» ${result.action} comment ${result.commentId}`);
|
||||
}
|
||||
|
||||
if (!params.target_plan_comment) {
|
||||
ctx.toolState.finalSummaryWritten = true;
|
||||
}
|
||||
@@ -391,15 +398,75 @@ export const ReplyToReviewComment = type({
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* decision returned by `duplicateReplyDecision` when a session has already
|
||||
* posted an identical reply to the same parent review comment.
|
||||
*/
|
||||
export interface DuplicateReplyDecision {
|
||||
kind: "already-replied";
|
||||
commentId: number;
|
||||
url: string | undefined;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* decide whether a second reply_to_review_comment call in the same session
|
||||
* is a duplicate of an earlier reply to the same parent comment.
|
||||
*
|
||||
* the agent is instructed to call reply_to_review_comment exactly once per
|
||||
* parent comment per AddressReviews session, but in practice it sometimes
|
||||
* emits the same call twice. PR #610 reproduced this with Kimi K2:
|
||||
* identical body posted 3 seconds apart, only one tool_use event in the
|
||||
* agent log. the second post is always redundant and clutters the PR thread.
|
||||
*
|
||||
* we key on (comment_id, bodyWithFooter) so a legitimate follow-up reply
|
||||
* with different content still goes through. within a single run the
|
||||
* footer is constant (workflow run + model + jobId), so byte-equal bodies
|
||||
* catch the stutter without blocking real follow-ups.
|
||||
*
|
||||
* mirrors the shape of `duplicateReviewDecision` in mcp/review.ts.
|
||||
*/
|
||||
export function duplicateReplyDecision(params: {
|
||||
existing: { commentId: number; url: string | undefined; bodyWithFooter: string } | undefined;
|
||||
bodyWithFooter: string;
|
||||
}): DuplicateReplyDecision | null {
|
||||
const existing = params.existing;
|
||||
if (!existing) return null;
|
||||
if (existing.bodyWithFooter !== params.bodyWithFooter) return null;
|
||||
return {
|
||||
kind: "already-replied",
|
||||
commentId: existing.commentId,
|
||||
url: existing.url,
|
||||
reason: `reply ${existing.commentId} with identical body was already posted in this session; ignoring duplicate call`,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "reply_to_review_comment",
|
||||
description:
|
||||
"Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call this for EACH comment you address in AddressReviews mode. Keep replies extremely brief (1 sentence max).",
|
||||
"Reply to a PR review comment thread (NOT issue comments — this only works for inline review comments on PR diffs). Call exactly ONCE per parent comment you address in AddressReviews mode — duplicate calls with the same body are a no-op. Keep replies extremely brief (1 sentence max).",
|
||||
parameters: ReplyToReviewComment,
|
||||
execute: execute(async ({ pull_number, comment_id, body }) => {
|
||||
const bodyWithFooter = addFooter(ctx, body);
|
||||
|
||||
// guard against duplicate reply submissions in the same session.
|
||||
// see duplicateReplyDecision for the rationale.
|
||||
const dup = duplicateReplyDecision({
|
||||
existing: ctx.toolState.reviewReplies?.get(comment_id),
|
||||
bodyWithFooter,
|
||||
});
|
||||
if (dup) {
|
||||
log.info(`skipping duplicate review reply: ${dup.reason}`);
|
||||
return {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: dup.reason,
|
||||
commentId: dup.commentId,
|
||||
url: dup.url,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.createReplyForReviewComment({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
@@ -407,11 +474,20 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
|
||||
comment_id,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» created review comment ${result.data.id} (in reply to ${comment_id})`);
|
||||
|
||||
// mark progress as updated so error reporting + run-result handling know
|
||||
// a substantive write happened (used by reportErrorToComment / handleAgentResult)
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
// record this reply for in-session dedupe of subsequent identical calls.
|
||||
ctx.toolState.reviewReplies ??= new Map();
|
||||
ctx.toolState.reviewReplies.set(comment_id, {
|
||||
commentId: result.data.id,
|
||||
url: result.data.html_url,
|
||||
bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commentId: result.data.id,
|
||||
|
||||
+25
-6
@@ -176,13 +176,32 @@ export function sanitizeToolForGemini<T extends Tool<any, any>>(tool: T): T {
|
||||
}
|
||||
|
||||
/**
|
||||
* true when the effective upstream model is served by google's generative
|
||||
* language API — directly (`google/*`), via opencode (`opencode/gemini-*`),
|
||||
* or via openrouter (`openrouter/google/gemini-*`). slug-substring match
|
||||
* works because every gemini route's model id contains "gemini".
|
||||
* true when the effective upstream model is — or might become — google
|
||||
* generative language API traffic. matches:
|
||||
* - direct `google/*`, opencode `opencode/gemini-*`, openrouter
|
||||
* `openrouter/google/gemini-*` (slug substring "gemini" wins).
|
||||
* - any unresolved specifier: `undefined`, `"auto"`, or a slug that
|
||||
* didn't map through the alias registry (no `provider/` prefix).
|
||||
* these flow through the agent's own auto-select, which may land
|
||||
* on gemini *after* the MCP server has already registered tools —
|
||||
* at which point sanitization is too late to apply. erring on the
|
||||
* side of sanitizing is safe: cases 1 + 2 are universally
|
||||
* compatible JSON-Schema normalizations (enum-only → typed string,
|
||||
* collapsible const-unions → string enum); case 3 is gemini-
|
||||
* specific but only fires on non-collapsible unions, which arktype
|
||||
* does not emit for our current tool schemas. see issue #676 for
|
||||
* the prod failure that motivated this widening.
|
||||
*/
|
||||
export function isGeminiRouted(ctx: ToolContext): boolean {
|
||||
const effective = ctx.payload.proxyModel ?? ctx.resolvedModel ?? ctx.payload.model;
|
||||
if (!effective) return false;
|
||||
return effective.toLowerCase().includes("gemini");
|
||||
if (!effective) return true;
|
||||
const normalized = effective.toLowerCase();
|
||||
if (normalized.includes("gemini")) return true;
|
||||
// every concrete model resolved through the registry carries a
|
||||
// `provider/` prefix (e.g. "anthropic/claude-opus-4-7"). anything
|
||||
// without a slash is either the literal `"auto"` alias or an
|
||||
// unrecognized slug that resolveModel logged a warning for — both
|
||||
// route through the agent's late auto-select, which may pick gemini.
|
||||
if (!normalized.includes("/")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
+9
-1
@@ -1,10 +1,11 @@
|
||||
import { regex } from "arkregex";
|
||||
import { type } from "arktype";
|
||||
import type { StoredPushDest } from "../toolState.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { StoredPushDest, ToolContext } from "./server.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PushDestination = {
|
||||
@@ -351,6 +352,11 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
||||
}
|
||||
|
||||
const pushedSha = $("git", ["rev-parse", "HEAD"], { log: false }).trim();
|
||||
log.info(
|
||||
`» pushed branch ${branch} to ${pushDest.remoteName}/${pushDest.remoteBranch} (sha ${pushedSha})`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch,
|
||||
@@ -595,6 +601,7 @@ export function DeleteBranchTool(ctx: ToolContext) {
|
||||
await $git("push", ["origin", "--delete", `refs/heads/${params.branchName}`], {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
log.info(`» deleted branch ${params.branchName}`);
|
||||
return { success: true, deleted: params.branchName };
|
||||
}),
|
||||
});
|
||||
@@ -625,6 +632,7 @@ export function PushTagsTool(ctx: ToolContext) {
|
||||
await $git("push", pushArgs, {
|
||||
token: ctx.gitToken,
|
||||
});
|
||||
log.info(`» pushed tag ${params.tag}`);
|
||||
return { success: true, tag: params.tag };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { fixDoubleEscapedString } from "../utils/fixDoubleEscapedString.ts";
|
||||
import { patchWorkflowRunFields } from "../utils/patchWorkflowRunFields.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
@@ -32,6 +33,8 @@ export function IssueTool(ctx: ToolContext) {
|
||||
assignees: params.assignees ?? [],
|
||||
});
|
||||
|
||||
log.info(`» created issue #${result.data.number} (id ${result.data.id})`);
|
||||
|
||||
const nodeId = result.data.node_id;
|
||||
if (typeof nodeId === "string" && nodeId.length > 0) {
|
||||
await patchWorkflowRunFields(ctx, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -20,6 +21,7 @@ export function AddLabelsTool(ctx: ToolContext) {
|
||||
issue_number,
|
||||
labels,
|
||||
});
|
||||
log.info(`» added labels [${labels.join(", ")}] to issue #${issue_number}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { type } from "arktype";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
const UpdateLearningsParams = type({
|
||||
learnings: type.string.describe(
|
||||
"the FULL merged learnings as a flat bullet list. each line starts with `- `. one discrete, actionable fact per bullet. combine existing bullets from the prompt with your new discoveries. deduplicate — if an existing bullet covers the same fact, update it in place rather than adding a new one. drop bullets that are clearly wrong or no longer relevant to the current codebase. keep the list focused and concise."
|
||||
),
|
||||
});
|
||||
|
||||
export function UpdateLearningsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "update_learnings",
|
||||
description:
|
||||
"persist operational learnings about this repository (setup steps, test commands, key conventions, patterns). ONLY call this when you have high confidence the information is correct and broadly useful for future runs — not for one-off findings or uncertain observations. format: flat bullet list (`- ` per line, one fact per bullet). pass the FULL merged list — combine existing learnings from the prompt with new discoveries. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.",
|
||||
parameters: UpdateLearningsParams,
|
||||
execute: execute(async (params) => {
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${ctx.repo.owner}/${ctx.repo.name}/learnings`,
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${ctx.apiToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
learnings: params.learnings,
|
||||
model: ctx.toolState.model,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`failed to update learnings: ${error}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -48,6 +48,7 @@ export function UpdatePullRequestBodyTool(ctx: ToolContext) {
|
||||
pull_number: params.pull_number,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
log.info(`» updated pull request #${result.data.number}`);
|
||||
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
@@ -80,6 +81,7 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
base: params.base,
|
||||
draft: params.draft ?? false,
|
||||
});
|
||||
log.info(`» created pull request #${result.data.number} (id ${result.data.id})`);
|
||||
|
||||
// best-effort: request review from the user who triggered the workflow
|
||||
const reviewer = ctx.payload.triggerer;
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
import type { RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { formatMcpToolRef } from "../external.ts";
|
||||
import type { CommentableLines } from "../toolState.ts";
|
||||
import { getApiUrl } from "../utils/apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "../utils/buildPullfrogFooter.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
@@ -16,6 +17,8 @@ import { deleteProgressComment } from "./comment.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export type { CommentableLines };
|
||||
|
||||
function getHttpStatus(err: unknown): number | undefined {
|
||||
if (typeof err !== "object" || err === null) return undefined;
|
||||
const status = (err as Record<string, unknown>).status;
|
||||
@@ -46,7 +49,6 @@ export function isTransientReviewError(err: unknown): boolean {
|
||||
export const TRANSIENT_REVIEW_RETRY_DELAYS_MS = [1_000, 3_000];
|
||||
|
||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
|
||||
|
||||
/**
|
||||
* parse a PR file's patch to determine which line numbers on each side are
|
||||
@@ -314,7 +316,7 @@ export const CreatePullRequestReview = type({
|
||||
.optional(),
|
||||
approved: type.boolean
|
||||
.describe(
|
||||
"Set to true to submit as an approval. ONLY when the review contains no actionable feedback — neither inline comments nor actionable content in the body. Defaults to false (comment-only review). Rejections are not supported."
|
||||
"Set to true to submit as an approval. Use for both 'no issues found' and informational `> [!NOTE]` reviews where the PR is mergeable as-is and nothing in the body warrants code changes — approving also suppresses the Fix-button footer affordance so users don't dispatch a fix run on non-actionable feedback. Reserve approved: false for `> [!IMPORTANT]` (recommended changes) and `> [!CAUTION]` (critical) reviews. Defaults to false (comment-only review). Rejections are not supported."
|
||||
)
|
||||
.optional(),
|
||||
commit_id: type.string
|
||||
@@ -584,6 +586,7 @@ export function CreatePullRequestReviewTool(ctx: ToolContext) {
|
||||
}
|
||||
const reviewId = result.data.id;
|
||||
const reviewNodeId = result.data.node_id;
|
||||
log.info(`» created review ${reviewId} on pull request #${pull_number}`);
|
||||
|
||||
// reviewedSha = what the agent actually reviewed (checkout SHA), not the
|
||||
// submission anchor (current HEAD). this ensures postReviewCleanup dispatches
|
||||
@@ -841,6 +844,9 @@ async function createAndSubmitWithFooter(
|
||||
// API_URL is misconfigured, and future footer-building changes could
|
||||
// introduce new throw paths. keep the whole body wrapped.
|
||||
try {
|
||||
// Fix buttons are suppressed on approving reviews — those are mergeable
|
||||
// by definition (either "no issues found" or `> [!NOTE]` informational
|
||||
// observations), so dispatching a fix run would be a UX trap.
|
||||
const customParts: string[] = [];
|
||||
if (!opts.approved) {
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
@@ -735,7 +735,7 @@ export function ResolveReviewThreadTool(ctx: ToolContext) {
|
||||
});
|
||||
|
||||
const thread = response.resolveReviewThread.thread;
|
||||
log.debug(`resolved thread ${thread.id}, isResolved=${thread.isResolved}`);
|
||||
log.info(`» resolved review thread ${thread.id}`);
|
||||
|
||||
return {
|
||||
thread_id: thread.id,
|
||||
|
||||
+5
-4
@@ -24,13 +24,14 @@ function buildModeOverrides(t: (name: string) => string): Record<string, string>
|
||||
|
||||
An existing plan comment was found for this issue. Update that comment with the revised plan — do not create a new plan comment.
|
||||
|
||||
1. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
|
||||
2. Revise the plan based on the user's request:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
2. Use \`previousPlanBody\` from this response as the plan to revise; do not call \`get_issue\` or \`get_issue_comments\`.
|
||||
3. Revise the plan based on the user's request:
|
||||
- incorporate the current plan (\`previousPlanBody\`) and the user's revision request
|
||||
- gather relevant codebase context (file paths, architecture notes from AGENTS.md)
|
||||
- produce a structured plan with clear milestones
|
||||
3. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
|
||||
4. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`,
|
||||
4. Call \`${t("report_progress")}\` with the full revised plan text and \`{ target_plan_comment: true }\` so it updates the existing plan comment (not the progress comment).
|
||||
5. Then post a short note to the progress comment (e.g. "Plan has been updated in the comment above.") via \`${t("report_progress")}\` so it is not left as "Leaping...".`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-129
@@ -3,23 +3,14 @@ import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import type { AgentUsage } from "../agents/index.ts";
|
||||
import { type AgentId, pullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { PrepResult } from "../prep/index.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { closeBrowserDaemon } from "../utils/browser.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { DiffCoverageState } from "../utils/diffCoverage.ts";
|
||||
import type { OctokitWithPlugins } from "../utils/github.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
import {
|
||||
type ProgressComment,
|
||||
type ProgressCommentType,
|
||||
parseProgressComment,
|
||||
} from "../utils/progressComment.ts";
|
||||
import type { AccountPlan } from "../utils/runContext.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import type { TodoTracker } from "../utils/todoTracking.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
import {
|
||||
@@ -39,11 +30,9 @@ import { GetIssueCommentsTool } from "./issueComments.ts";
|
||||
import { GetIssueEventsTool } from "./issueEvents.ts";
|
||||
import { IssueInfoTool } from "./issueInfo.ts";
|
||||
import { AddLabelsTool } from "./labels.ts";
|
||||
import { UpdateLearningsTool } from "./learnings.ts";
|
||||
import { SetOutputTool } from "./output.ts";
|
||||
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import type { CommentableLines } from "./review.ts";
|
||||
import { CreatePullRequestReviewTool } from "./review.ts";
|
||||
import {
|
||||
GetReviewCommentsTool,
|
||||
@@ -55,120 +44,6 @@ import { addTools } from "./shared.ts";
|
||||
import { KillBackgroundTool, ShellTool } from "./shell.ts";
|
||||
import { UploadFileTool } from "./upload.ts";
|
||||
|
||||
export type BackgroundProcess = {
|
||||
pid: number;
|
||||
outputPath: string;
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
||||
checkoutSha?: string;
|
||||
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
||||
// review-time inline-comment validation matches the diff GitHub will anchor
|
||||
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
||||
// review would make listFiles (latest HEAD) disagree with the anchor,
|
||||
// silently dropping valid comments or letting invalid ones through.
|
||||
//
|
||||
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
||||
// the agent checks out PR B and then reviews PR A in the same session, the
|
||||
// cached snapshot for B would silently mis-validate A's comments — keying
|
||||
// by PR number forces a re-fetch when the target changes.
|
||||
//
|
||||
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
||||
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
||||
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
||||
// stale snapshot would silently mis-validate comments against the new SHA.
|
||||
// comparing both fields forces a re-fetch when either moves.
|
||||
commentableLinesByFile?: Map<string, CommentableLines>;
|
||||
commentableLinesPullNumber?: number;
|
||||
commentableLinesCheckoutSha?: string | undefined;
|
||||
// SHA to diff incrementally against — set from event payload on first checkout,
|
||||
// then from checkoutSha when review.ts detects new commits mid-review
|
||||
beforeSha?: string;
|
||||
selectedMode?: string;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
browserDaemon?: BrowserDaemon | undefined;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
reviewedSha: string | undefined;
|
||||
};
|
||||
dependencyInstallation?: {
|
||||
status: "not_started" | "in_progress" | "completed" | "failed";
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
||||
progressComment: ProgressComment | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
// set after a non-plan report_progress successfully writes the final summary.
|
||||
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
||||
finalSummaryWritten?: boolean;
|
||||
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||
existingPlanCommentId?: number;
|
||||
previousPlanBody?: string;
|
||||
// absolute path to the PR summary markdown file the agent edits in place.
|
||||
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
||||
// read back at end-of-run to persist to DB.
|
||||
summaryFilePath?: string;
|
||||
// exact bytes of the seeded snapshot file at run start. compared against
|
||||
// the file content at end-of-run to detect "agent never touched it" — in
|
||||
// that case persistSummary skips the DB write (saving the seed verbatim
|
||||
// would either re-write what the DB already has, on incremental runs, or
|
||||
// serialize the placeholder scaffold, on first runs).
|
||||
summarySeed?: string;
|
||||
// set to true after persistSummary completes once. prevents the error-path
|
||||
// call (which exists so a successful agent edit before a crash still gets
|
||||
// persisted) from redundantly re-running the DB PATCH on the
|
||||
// success-then-late-throw path.
|
||||
summaryPersistAttempted?: boolean;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
diffCoverage?: DiffCoverageState | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const resolved = parseProgressComment(params.progressComment);
|
||||
|
||||
if (resolved) {
|
||||
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressComment: resolved,
|
||||
hadProgressComment: !!resolved,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
agentId: AgentId;
|
||||
repo: RunContextData["repo"];
|
||||
@@ -189,8 +64,8 @@ export interface ToolContext {
|
||||
tmpdir: string;
|
||||
// repo-level OSS flag + account-level billing plan. together they decide
|
||||
// whether pullfrog is paying for marginal infra — see isInfraCovered in
|
||||
// utils/runContext.ts. plan gating for things like update_learnings is
|
||||
// enforced server-side via 402, so we pass plan along mostly for future
|
||||
// utils/runContext.ts. plan gating for endpoints like the learnings PATCH
|
||||
// is enforced server-side via 402, so we pass plan along mostly for future
|
||||
// use / observability. see wiki/pricing.md.
|
||||
oss: boolean;
|
||||
plan: AccountPlan;
|
||||
@@ -288,7 +163,6 @@ function buildOrchestratorTools(ctx: ToolContext, outputSchema?: JsonSchema): To
|
||||
DeleteBranchTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
UpdatePullRequestBodyTool(ctx),
|
||||
UpdateLearningsTool(ctx),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as path from "node:path";
|
||||
import { type } from "arktype";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -65,6 +66,8 @@ export function UploadFileTool(ctx: ToolContext) {
|
||||
throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
|
||||
}
|
||||
|
||||
log.info(`» uploaded file ${publicUrl}`);
|
||||
|
||||
return { success: true, publicUrl, filename, contentLength, contentType };
|
||||
}),
|
||||
});
|
||||
|
||||
+1
-1
@@ -55,13 +55,13 @@ describe("getModelEnvVars", () => {
|
||||
|
||||
it("returns empty env vars for free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/big-pickle")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/mimo-v2-pro-free")).toEqual([]);
|
||||
expect(getModelEnvVars("opencode/minimax-m2.5-free")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still requires OPENCODE_API_KEY for non-free opencode models", () => {
|
||||
expect(getModelEnvVars("opencode/claude-opus")).toEqual(["OPENCODE_API_KEY"]);
|
||||
expect(getModelEnvVars("opencode/gpt-5-nano")).toEqual(["OPENCODE_API_KEY"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -271,8 +271,7 @@ export const providers = {
|
||||
"gpt-5-nano": {
|
||||
displayName: "GPT Nano",
|
||||
resolve: "opencode/gpt-5-nano",
|
||||
envVars: [],
|
||||
isFree: true,
|
||||
openRouterResolve: "openrouter/openai/gpt-5-nano",
|
||||
},
|
||||
"mimo-v2-pro-free": {
|
||||
displayName: "MiMo V2 Pro",
|
||||
|
||||
@@ -65,10 +65,6 @@ Rules:
|
||||
- Focus on *intent*, not *what* — the diff already shows what changed
|
||||
- Get the file count and commit count from the checkout_pr metadata, not by counting manually`;
|
||||
|
||||
function learningsStep(t: (toolName: string) => string, n: number): string {
|
||||
return `${n}. **learnings** (only if high confidence): if you discovered something about repo setup, test commands, conventions, or patterns that you are confident is correct and would reliably help future runs, call \`${t("update_learnings")}\` to persist it. skip this step if you are unsure or the finding is speculative/one-off. format as a flat bullet list (\`- \` per line, one fact per bullet). merge with existing learnings from the prompt — pass the FULL merged list. deduplicate, and drop bullets that are clearly wrong or no longer relevant to the current codebase.`;
|
||||
}
|
||||
|
||||
export function computeModes(agentId: AgentId): Mode[] {
|
||||
const t = (toolName: string) => formatMcpToolRef(agentId, toolName);
|
||||
return [
|
||||
@@ -78,18 +74,20 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
"Implement, build, create, or develop code changes; make specific changes to files or features; execute a plan; or handle tasks with specific implementation details",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **setup**: checkout or create the branch:
|
||||
2. **plan** (optional, for complex tasks): analyze requirements, read AGENTS.md and relevant code, produce a step-by-step implementation plan.
|
||||
|
||||
3. **setup**: checkout or create the branch:
|
||||
- **PR event, modifying the existing PR**: call \`${t("checkout_pr")}\`
|
||||
- **new branch**: use \`${t("git")}\` to create a branch (\`git checkout -b pullfrog/branch-name\`)
|
||||
|
||||
3. **build**: implement changes using your native file and shell tools:
|
||||
4. **build**: implement changes using your native file and shell tools:
|
||||
- follow the plan (if you ran a plan phase)
|
||||
- plan your approach before writing code: identify which files need to change, key design decisions, and edge cases. for non-trivial changes, consider whether there's a more elegant approach.
|
||||
- run relevant tests/lints before committing
|
||||
|
||||
4. **self-review**: judgment call — does YOUR diff warrant a fresh-eyes pass?
|
||||
5. **self-review**: judgment call — does YOUR diff warrant a fresh-eyes pass?
|
||||
|
||||
Skip self-review (commit directly) when the diff is **genuinely trivial**:
|
||||
- doc typos, comment-only edits, whitespace/format-only, import reordering
|
||||
@@ -120,13 +118,11 @@ export function computeModes(agentId: AgentId): Mode[] {
|
||||
|
||||
Review the findings, address valid points, and discard nitpicks or false positives. The reviewer is fallible — it biases toward *recommending additions* (defensive checks for impossible cases, extra logging, new abstractions used once, comments restating code, tests asserting tautologies, "just-in-case" guards). For each finding, ask: would applying it leave the code more sound, correct, AND elegant? Two-out-of-three is usually a signal to look harder for a fix that gets all three before settling for one that trades elegance for correctness. Reject bloat-shaped findings without applying them, and after applying the rest re-read your diff and be discerning about what *you just changed*: if any fix turned out to be bloat in context, revert it. The goal is code that is sound and correct *while remaining elegant*; the smallest diff that fixes the real defect almost always wins. Then verify only intended changes are present, no debug artifacts or commented-out code remain, no unrelated files were modified. Commit locally via shell (\`git add . && git commit -m "..."\`).
|
||||
|
||||
5. **finalize**:
|
||||
6. **finalize**:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (see *SYSTEM* Git rules if this fails — prepush errors are usually the repo's tests/lint, not infra timeouts)
|
||||
- create a PR via \`${t("create_pull_request")}\`
|
||||
- call \`${t("report_progress")}\` with the PR link or the exact error if push/PR failed
|
||||
|
||||
${learningsStep(t, 6)}
|
||||
|
||||
### Notes
|
||||
|
||||
For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
@@ -137,27 +133,27 @@ For simple, well-defined tasks, skip the plan phase and go straight to build.`,
|
||||
"Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Fetch review comments via \`${t("get_review_comments")}\`.
|
||||
2. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
|
||||
3. For each comment:
|
||||
3. Fetch review comments via \`${t("get_review_comments")}\`.
|
||||
|
||||
4. For each comment:
|
||||
- understand the feedback
|
||||
- evaluate whether applying it would leave the code more **sound, correct, AND elegant**. reviewers are fallible and bias toward *recommending additions* (defensive checks for impossible cases, extra abstractions, comments restating obvious code, tests asserting tautologies, "just-in-case" guards). if a request would add bloat — ceremony without commensurate correctness benefit — push back in your reply rather than mechanically applying it. two-out-of-three is usually a signal to look harder for a fix that gets all three before settling.
|
||||
- if the request stands, make the code change using your native tools; otherwise reply explaining why
|
||||
- record what was done (or why nothing was done)
|
||||
|
||||
4. Quality check:
|
||||
5. Quality check:
|
||||
- test changes, then review the diff before committing — verify only intended changes are present, no debug artifacts remain, no fix turned out to be bloat in context (revert any that did), and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
|
||||
5. Finalize:
|
||||
6. Finalize:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
- reply to each comment using \`${t("reply_to_review_comment")}\`
|
||||
- reply to each comment **exactly once** using \`${t("reply_to_review_comment")}\` — do not re-emit the same call (the runtime dedupes identical bodies and the second call is wasted)
|
||||
- resolve addressed threads via \`${t("resolve_review_thread")}\`
|
||||
- call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)
|
||||
|
||||
${learningsStep(t, 6)}`,
|
||||
- call \`${t("report_progress")}\` with a brief summary (or the exact push error if push failed)`,
|
||||
},
|
||||
// Review and IncrementalReview use the multi-lens orchestrator pattern
|
||||
// (canonical source: .claude/commands/anneal.md). The orchestrator does
|
||||
@@ -177,11 +173,13 @@ ${learningsStep(t, 6)}`,
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **triage**: orient yourself on the PR — identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). orientation only — defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
|
||||
2. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata and a \`diffPath\`. read the diff TOC end-to-end and treat its file line ranges as your coverage checklist.
|
||||
|
||||
if the PR is **genuinely trivial**, skip steps 3–4 entirely and submit a \`No new issues found.\` review per step 5. there's no value in dispatching even one lens for a typo.
|
||||
3. **triage**: orient yourself on the PR — identify *what kind of thing this is* (domain it touches, seams it crosses, external contracts it depends on, user-facing surfaces it changes). orientation only — defer specific defect-hunting to the subagents; pre-reviewing biases the lenses you pick. use \`${t("get_pull_request")}\` and other read-only GitHub tools for additional context if needed.
|
||||
|
||||
if the PR is **genuinely trivial**, skip steps 4–5 entirely and submit a \`No new issues found.\` review per step 6. there's no value in dispatching even one lens for a typo.
|
||||
|
||||
"Genuinely trivial" (skip):
|
||||
- single-word doc typo, whitespace/format-only, comment-only across any number of files
|
||||
@@ -209,6 +207,8 @@ ${learningsStep(t, 6)}`,
|
||||
- **4–5 lenses (high-stakes subsystem touches)** — any billing/payments change (billing-subsystem + correctness + security + operational-readiness); new auth flow (auth-subsystem + correctness + security + test-integrity); schema migration (schema-migration-subsystem + correctness + operational-readiness + impact); cross-subsystem PR that touches billing AND auth AND schema (one subsystem lens per domain + correctness)
|
||||
- **6+ lenses** — almost always a smell; you're either covering overlapping ground or this PR should have been split. push back via the review body rather than expanding lens count.
|
||||
|
||||
**lens-add discipline.** Each lens needs to clear a specific bar before you dispatch it: name the concrete failure mode this lens would catch *that the diff plausibly introduces*, in one sentence. "Could apply", "good to have", "for completeness" do not qualify. If you can't name what the lens is going to find, drop it. The "when unsure, treat as non-trivial" rule above is for the trivial-vs-non-trivial gate at step 3 — it does not license expanding lens count without articulated risk. Every extra lens adds wall-time, log noise, and pulls subagent attention onto speculative angles, which biases the final review toward bloat-shaped findings.
|
||||
|
||||
lenses come in two flavors, and you can mix them:
|
||||
- **themed lenses** — a perspective applied across the whole diff (correctness, security, user-journey, performance, etc.).
|
||||
- **subsystem lenses** — a domain-scoped frame for high-stakes subsystems the PR touches (e.g. "the auth lens", "the billing lens", "the schema-migration lens"). a subsystem lens is "review the PR specifically for what could go wrong in this subsystem" and naturally combines theme + scope. **for high-stakes domains, lead with the subsystem lens rather than the generic themed equivalent** — "billing-subsystem" outperforms "correctness on billing code" because the framing primes the subagent to remember domain-specific failure modes (double-charges, refund races, currency rounding, dispute flows) the generic lens misses.
|
||||
@@ -216,7 +216,7 @@ ${learningsStep(t, 6)}`,
|
||||
starter menu (combine, omit, or invent your own):
|
||||
- **correctness & invariants** — bugs, races, error handling, edge cases, state-machine boundaries
|
||||
- **impact** — when the PR removes features, deletes exports, renames identifiers, or changes architectural patterns: stale references in code, tests, docs (\`docs/\`, \`wiki/\`), comments, configs, UI
|
||||
- **research-validated assumptions** — third-party API contracts, SDK semantics, framework directives, version-gated behavior. the subagent must verify load-bearing claims via web search and quote source URLs.
|
||||
- **research-validated assumptions** — third-party API contracts, SDK semantics, framework directives, version-gated behavior. **only pick when the PR's correctness depends on the contract behaving a specific way** — not when the API is merely used. An idempotency key as a backstop, a timeout as a hint, a retry as belt-and-suspenders: not load-bearing, skip this lens. The bar is "if the third-party contract differs from what the diff assumes, the PR is incorrect." When dispatched, the subagent must verify load-bearing claims via web search and quote source URLs.
|
||||
- **security** — new endpoints, authZ, input validation, secrets handling, replay/CSRF/injection, cross-tenant isolation
|
||||
- **user-journey** — UX-touching flows: walk through happy path and failure modes as a user
|
||||
- **operational readiness** — observability, alerting, migrations (forward + rollback), feature flags, on-call burden
|
||||
@@ -226,7 +226,7 @@ ${learningsStep(t, 6)}`,
|
||||
- **holistic** — does the PR make sense as a whole? symmetric flows (delete for every create, rollback for every migration)?
|
||||
- **subsystem lenses** (invent as the PR demands) — auth, billing, payments, schema migration, webhooks, secrets, RBAC, multi-tenant isolation, cron/scheduling, etc.
|
||||
|
||||
3. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 3 entirely on a single subagent failure. each subagent gets:
|
||||
4. **fan out**: dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). when picking 2+ lenses, dispatch them in a **single assistant turn with multiple parallel subagent calls**; issuing one and awaiting reply before the next collapses the fan-out into a serial review. if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 4 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff path / target — reading the diff and the codebase is its job
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
@@ -241,20 +241,33 @@ ${learningsStep(t, 6)}`,
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point — overlapping findings are a strong signal)
|
||||
|
||||
4. **aggregate & draft**: merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
|
||||
5. **aggregate & draft**: merge findings; de-dup overlaps (two lenses catching the same issue = higher-confidence signal); trace each finding yourself before accepting it. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the PR (heuristic: if the finding's root cause lives in lines this PR added or modified, it's in scope; otherwise drop unless the PR plausibly introduced or amplified the regression), and anything not actionable. also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or worse, degrades elegance to nominally improve correctness) makes the codebase worse, not better.
|
||||
|
||||
for surviving findings, draft inline comments with NEW line numbers from the diff. every comment must be actionable, 2-3 sentences max. use GitHub permalink format for code references. for impact-analysis findings (stale references after rename/remove), report them in the review body ordered by severity (runtime breakage > incorrect docs > stale comments) rather than as inline comments unless they're anchored to a specific line.
|
||||
|
||||
5. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` — the review is the final record and the progress comment will be cleaned up automatically.
|
||||
6. **submit**: ALWAYS submit exactly one review via \`${t("create_pull_request_review")}\`. Do NOT call \`report_progress\` — the review is the final record and the progress comment will be cleaned up automatically.
|
||||
|
||||
note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
|
||||
|
||||
The review body is structured as: \`[optional alert blockquote]\` → \`[PR summary using the default format below]\`. Inline comments are passed via the \`comments\` parameter, not in the body.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss):
|
||||
GitHub alert blockquotes render at four visual intensities — the callout is what the author sees first, so pick the one that matches what you want them to do:
|
||||
|
||||
- \`[!CAUTION]\` — large red banner. Reads as "this will break something."
|
||||
- \`[!IMPORTANT]\` — large purple banner. Reads as "you need to look at this before merging."
|
||||
- \`[!NOTE]\` — small blue inline callout. Reads as "FYI, here's something worth noting."
|
||||
- no callout — plain text. Reads as routine review output.
|
||||
|
||||
Two reinforcing levers: callout intensity (above) and \`approved\` (which gates the footer Fix-button affordance — Fix renders on every non-approving review, so \`approved: true\` suppresses it). Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing. Pick the tier the author's actual next action justifies.
|
||||
|
||||
- **critical issues** (blocks merge — bugs, security, data loss, broken core flows):
|
||||
\`approved: false\`. Body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
|
||||
- **recommended changes** (non-critical):
|
||||
\`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> Consider ...\`, followed by the PR summary. Include all inline comments via \`comments\`.
|
||||
- **must-address non-critical findings** (real consequences if shipped — incorrect behavior in non-critical paths, missing validation on user input, regressions the author should fix before merge):
|
||||
\`approved: false\`. Body opens with \`> [!IMPORTANT]\\n> ...\`, followed by the PR summary. Reserve this tier for findings with concrete fallout — do NOT use \`[!IMPORTANT]\` for nits, style preferences, or "consider also" suggestions. Include all inline comments via \`comments\`.
|
||||
- **minor suggestions only** (single-line nits, doc/comment polish, defer-able observations, "rough edges"):
|
||||
\`approved: false\`. NO alert blockquote. Body opens directly with the PR summary. Include all inline comments via \`comments\`.
|
||||
- **informational observations** (mergeable as-is, nothing actionable — e.g. prior feedback addressed cleanly, surfacing a minor stale doc reference, calling out something noteworthy without recommending a change):
|
||||
\`approved: true\`. Body opens with \`> [!NOTE]\\n> ...\`, followed by the PR summary. Do NOT include inline \`comments\` — \`[!NOTE]\` signals "no action needed", which contradicts an actionable anchor; if a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead.
|
||||
- **no actionable issues**:
|
||||
\`approved: true\`. Body opens with \`No new issues found.\` followed by the PR summary.
|
||||
|
||||
@@ -263,7 +276,7 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
// IncrementalReview shares Review's multi-lens orchestrator pattern but
|
||||
// scopes the target to the incremental diff. The "issues must be NEW
|
||||
// since the last Pullfrog review" filter lives at aggregation time
|
||||
// (step 5), NOT in the subagent prompt — pushing the filter into
|
||||
// (step 6), NOT in the subagent prompt — pushing the filter into
|
||||
// subagents matches the canonical anneal anti-pattern of "list known
|
||||
// pre-existing failures — don't flag these" and suppresses signal on
|
||||
// regressions the new commits amplified. The review body is just
|
||||
@@ -277,24 +290,26 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"Re-review a PR after new commits are pushed; focus on new changes since the last review",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
|
||||
2. **checkout**: call \`${t("checkout_pr")}\` — this returns PR metadata, \`diffPath\` (full diff), and \`incrementalDiffPath\` (changes since last reviewed version, if available). read the diff TOC first and use its line ranges as your coverage checklist.
|
||||
|
||||
3. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 5 — anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
|
||||
3. **incremental scope**: if \`incrementalDiffPath\` is present, read it to see what changed since the last review. this is a range-diff that isolates the net changes, filtering out base branch noise. if not present, fall back to reviewing the full PR diff and determine what changed since Pullfrog's most recent review.
|
||||
|
||||
4. **triage & fan out**: orient on the *incremental* changes — domain, seams, external contracts, user-facing surfaces.
|
||||
4. **prior feedback**: fetch previous reviews via \`${t("list_pull_request_reviews")}\`. for the most recent Pullfrog review, call \`${t("get_review_comments")}\` with the review ID to retrieve specific prior line-level feedback. you'll use this to filter your aggregation in step 6 — anything already flagged in a prior review and not changed by the new commits should not be re-raised. you do NOT need to render this in the review body; the rolling PR summary snapshot is the durable record of what's been addressed.
|
||||
|
||||
if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 7's non-substantive path (do NOT submit a review).
|
||||
5. **triage & fan out**: orient on the *incremental* changes — domain, seams, external contracts, user-facing surfaces.
|
||||
|
||||
if the incremental changes are **genuinely trivial**, skip the fan-out entirely and jump to step 8's non-substantive path (do NOT submit a review).
|
||||
|
||||
"Genuinely trivial" (skip): formatting/comment tweaks, import reordering, lockfile regen, mechanical rename of import paths, whitespace-only.
|
||||
"Looks trivial but isn't" (do NOT skip — same anti-patterns as Review mode): 1-line changes to SQL/regex/auth/billing/permissions/signature-verification code; flipping feature-flag defaults or retry/timeout constants; money/tax/HTTP-method/redirect changes; tightening or loosening a comparison operator; mixed diffs with a semantic line buried in formatting.
|
||||
When unsure, treat as non-trivial.
|
||||
|
||||
otherwise pick lenses by where the new commits concentrate risk — **there's no fixed count**, same calibration as Review mode (1 lens for pure refactor / isolated fix; 2–3 for typical features; 4–5 for high-stakes subsystem touches; 6+ is a smell). lens framing follows Review mode: themed lenses (correctness & invariants, impact when new commits remove/rename/deprecate things, research-validated assumptions, security, user-journey, operational readiness, integration & cross-cutting, test integrity, performance, holistic) and subsystem lenses (auth, billing, schema migration, etc.) — for high-stakes domains lead with the subsystem lens rather than the generic themed equivalent.
|
||||
otherwise pick lenses by where the new commits concentrate risk — **there's no fixed count**, same calibration as Review mode (1 lens for pure refactor / isolated fix; 2–3 for typical features; 4–5 for high-stakes subsystem touches; 6+ is a smell). same **lens-add discipline** as Review mode applies: each lens needs to name the concrete failure mode it would catch *that the new commits plausibly introduce* — "could apply" doesn't qualify, drop it. **research-validated assumptions** specifically: only pick when the new commits' correctness depends on a third-party contract behaving a specific way; merely using an API doesn't qualify. lens framing follows Review mode: themed lenses (correctness & invariants, impact when new commits remove/rename/deprecate things, research-validated assumptions, security, user-journey, operational readiness, integration & cross-cutting, test integrity, performance, holistic) and subsystem lenses (auth, billing, schema migration, etc.) — for high-stakes domains lead with the subsystem lens rather than the generic themed equivalent.
|
||||
|
||||
dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 4 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff scope (incremental diff path if available, full diff otherwise). do NOT tell them to skip pre-existing issues — that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 5), not in the subagent prompt
|
||||
dispatch one \`${REVIEWER_AGENT_NAME}\` subagent per lens — its baked-in system prompt enforces the non-mutative + non-recursive contract (read-only file/search/web tools and read-only MCP queries; no writes, shell side effects, state-changing MCP calls, or nested subagent dispatch). dispatch them in a **single assistant turn with multiple parallel subagent calls** (serial dispatch collapses the fan-out). if a subagent errors out, times out, or returns nothing usable, retry once with the same lens; if it still fails, proceed with partial coverage and note the missing lens in the review body — do not skip step 5 entirely on a single subagent failure. each subagent gets:
|
||||
- the diff scope (incremental diff path if available, full diff otherwise). do NOT tell them to skip pre-existing issues — that suppresses regressions the new commits amplified; the "issues must be NEW" filter lives at aggregation time (step 6), not in the subagent prompt
|
||||
- **only one lens** — never a multi-section "review for X, Y, and Z" prompt
|
||||
- **a Task \`description\` set to the lens name** (e.g. \`"security"\`, \`"correctness"\`, \`"billing-subsystem"\`) — the harness reads this field to label the subagent's log lines so parallel runs can be told apart in CI output. without it, every subagent shows up as \`subagent#N\`.
|
||||
- the read-only contract restated in your dispatch instructions so the rule is present twice (the subagent's system prompt also enforces it). The test: would this call still be a no-op if reverted? If not (PR comments, branch pushes, issue updates, set_output, label changes, dependency installs, etc.), don't make it.
|
||||
@@ -308,15 +323,21 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
- do NOT pre-shape their output with a finding schema
|
||||
- do NOT mention the other lenses (independence is the point)
|
||||
|
||||
5. **aggregate, draft, self-critique**: merge findings; de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 1 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 3) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable, 2-3 sentences max.
|
||||
6. **aggregate, draft, self-critique**: merge findings; de-dup overlaps; trace each finding yourself. drop praise, style preferences, speculative/unverified claims, findings about pre-existing code unrelated to the new commits, anything not actionable, and anything that re-states prior review feedback (heuristic: if the finding's root cause lives in lines the *new commits* added or modified, it's in scope; otherwise drop). also drop **bloat-shaped findings** — proposed fixes that would add defensive checks for cases that can't happen, abstractions used once, comments restating obvious code, tests asserting tautologies, or "just-in-case" guards. subagents are fallible and bias toward recommending changes; the bar for an actionable inline comment is sound + correct + elegant. recommending a change that improves only one of the three (or degrades elegance to nominally improve correctness) makes the codebase worse, not better. To compute "lines the new commits added or modified": if \`incrementalDiffPath\` from step 2 is present, use it directly. Otherwise, take the prior Pullfrog review's \`commit_id\` (returned alongside each entry from \`${t("list_pull_request_reviews")}\` in step 4) and run \`git diff <prior-review-sha>..HEAD\` to isolate the lines added since that review. draft inline comments with NEW line numbers from the full PR diff — every comment must be actionable, 2-3 sentences max.
|
||||
|
||||
6. **build the review body** — a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one — when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
|
||||
7. **build the review body** — a single "Reviewed changes" section: summarize at the logical-change level, not per-file. each bullet starts with a past-tense verb (e.g. \`- Extracted shared CLI runtime into a single module\`, \`- Renamed package to pullfrog\`). avoid file paths unless they add clarity. if the changes can be described in one sentence, use one sentence — no bullets needed. do NOT include a separate "Prior review feedback" checklist; that's tracked in the rolling PR summary snapshot for the next agent run, and surfacing it in the user-facing body is noise (changes that addressed prior feedback are already covered by the Reviewed-changes bullets). in some cases you may receive a complete diff for the whole pull request instead of an incremental one — when this happens, you will need to determine what changes have happened since Pullfrog's most recent review.
|
||||
|
||||
7. Submit — Do NOT call \`report_progress\` or \`create_issue_comment\` — the review is the final record and the progress comment will be cleaned up automatically. Follow these rules:
|
||||
8. Submit — every run must end with EXACTLY ONE of \`${t("create_pull_request_review")}\` (substantive review) or \`${t("report_progress")}\` (no-review acknowledgement). do NOT call \`create_issue_comment\` for review output.
|
||||
|
||||
Same callout-intensity ladder as Review mode — \`[!CAUTION]\` (large red, "will break") → \`[!IMPORTANT]\` (large purple, "must address before merging") → \`[!NOTE]\` (small blue, "FYI") → no callout (plain text). And the same Fix-button lever: the footer renders a Fix button on every non-approving review, so \`approved: true\` suppresses it. Wrapping mergeable feedback in \`[!IMPORTANT]\` trains users to click Fix on reviews that don't need fixing — pick the tier the author's actual next action justifies.
|
||||
|
||||
Follow these rules:
|
||||
- note: the first create_pull_request_review submission may error with a one-time diff-coverage nudge listing unread TOC regions. retry the same call to proceed — optionally after reading the listed ranges. the pre-flight will not block again this session.
|
||||
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Do NOT call \`report_progress\`. Exit — the progress comment will be cleaned up automatically.
|
||||
- ELSE IF NEW CRITICAL ISSUES (blocks merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with a GitHub alert blockquote (e.g. \`> [!CAUTION]\\n> This PR introduces ...\`), then the Reviewed-changes summary.
|
||||
- ELSE IF NEW RECOMMENDED CHANGES (non-critical): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\` alert, then the Reviewed-changes summary.
|
||||
- IF NO NEW ISSUES, NON-SUBSTANTIVE CHANGES ONLY (trivial formatting, import reordering, comment tweaks): do NOT submit a review. Instead call \`${t("report_progress")}\` with a 1-2 sentence note explaining no review was warranted (e.g. "No new issues. Changes since last review are formatting-only."). this leaves a visible signal that the run completed.
|
||||
- ELSE IF NEW CRITICAL ISSUES (blocks merge — bugs, security, data loss, broken core flows): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!CAUTION]\\n> This PR introduces ...\`, then the Reviewed-changes summary.
|
||||
- ELSE IF NEW MUST-ADDRESS NON-CRITICAL FINDINGS (real consequences if shipped — incorrect behavior, missing validation, regressions the author should fix before merge): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens with \`> [!IMPORTANT]\\n> ...\`, then the Reviewed-changes summary. Do NOT use this tier for nits, style preferences, or "consider also" suggestions.
|
||||
- ELSE IF NEW MINOR SUGGESTIONS ONLY (single-line nits, doc/comment polish, defer-able observations, "rough edges"): call \`${t("create_pull_request_review")}\` with \`approved: false\`, all comments, and the review body. body opens directly with \`Reviewed the following changes:\\n\` (NO alert blockquote), then the Reviewed-changes summary.
|
||||
- ELSE IF INFORMATIONAL OBSERVATIONS (mergeable as-is, but worth surfacing — e.g. prior feedback addressed cleanly with one minor stale doc reference, or a noteworthy positive observation): call \`${t("create_pull_request_review")}\` with \`approved: true\`, NO inline comments, and the review body. body opens with \`> [!NOTE]\\n> ...\` alert, then the Reviewed-changes summary. If a point is concrete enough to anchor to a line, downgrade the whole review to "minor suggestions only" (\`approved: false\`) instead — \`[!NOTE]\` and inline comments don't mix.
|
||||
- ELSE IF NO NEW ISSUES, SUBSTANTIVE CHANGES (new functionality, behavior changes, or fixes to prior review feedback): call \`${t("create_pull_request_review")}\` to create a PR review. If all previous reviews have been properly addressed and no new issues were discovered, you can set \`approved: true\`. body opens with \`No new issues. Reviewed the following changes:\\n\`, then the Reviewed-changes summary.`,
|
||||
},
|
||||
{
|
||||
@@ -325,15 +346,15 @@ ${PR_SUMMARY_FORMAT}`,
|
||||
"Create plans, break down tasks, outline steps, analyze requirements, understand scope of work, or provide task breakdowns",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Analyze the task and gather context:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Analyze the task and gather context:
|
||||
- read AGENTS.md and relevant codebase files
|
||||
- understand the architecture and constraints
|
||||
|
||||
2. Produce a structured, actionable plan with clear milestones.
|
||||
3. Produce a structured, actionable plan with clear milestones.
|
||||
|
||||
3. Call \`${t("report_progress")}\` with the plan.
|
||||
|
||||
${learningsStep(t, 4)}`,
|
||||
4. Call \`${t("report_progress")}\` with the plan.`,
|
||||
},
|
||||
{
|
||||
name: "Fix",
|
||||
@@ -341,46 +362,48 @@ ${learningsStep(t, 4)}`,
|
||||
"Fix CI failures; debug failing tests or builds; investigate and resolve check suite failures",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
|
||||
2. Checkout the PR branch via \`${t("checkout_pr")}\`.
|
||||
|
||||
3. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||
3. Fetch check suite logs via \`${t("get_check_suite_logs")}\`.
|
||||
|
||||
4. Diagnose and fix:
|
||||
4. **CRITICAL**: verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||
|
||||
5. Diagnose and fix:
|
||||
- read the workflow file, reproduce locally with the EXACT same commands CI runs
|
||||
- fix the issue using your native file and shell tools
|
||||
- verify the fix by re-running the exact CI command
|
||||
- review the diff before committing — verify only the fix is present, no debug artifacts, no unrelated changes. the fix should be clean enough that a senior engineer would approve without hesitation.
|
||||
- commit locally via shell (\`git add . && git commit -m "..."\`)
|
||||
|
||||
5. Finalize:
|
||||
6. Finalize:
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
- call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)
|
||||
|
||||
${learningsStep(t, 6)}`,
|
||||
- call \`${t("report_progress")}\` with the diagnosis and fix summary (or the exact push error if push failed)`,
|
||||
},
|
||||
{
|
||||
name: "ResolveConflicts",
|
||||
description: "Resolve merge conflicts in a PR branch against the base branch",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. **Setup**:
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. **Setup**:
|
||||
- Call \`${t("checkout_pr")}\` to get the PR branch.
|
||||
- Call \`${t("get_pull_request")}\` to identify the base branch (e.g., 'main').
|
||||
- Call \`${t("git_fetch")}\` to fetch the base branch.
|
||||
|
||||
2. **Merge Attempt**:
|
||||
3. **Merge Attempt**:
|
||||
- Run \`git merge origin/<base_branch>\` via shell.
|
||||
- If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed — **then stop; do not run steps 3–4.**
|
||||
- If it fails (conflicts), resolve them manually (continue to steps 3–4).
|
||||
- If it succeeds automatically, confirm a clean working tree, push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*), and call \`${t("report_progress")}\` with a brief success note or the exact push error if push failed — **then stop; do not run steps 4–5.**
|
||||
- If it fails (conflicts), resolve them manually (continue to steps 4–5).
|
||||
|
||||
3. **Resolve Conflicts**:
|
||||
4. **Resolve Conflicts**:
|
||||
- Run \`git status\` or parse the merge output to find the list of conflicting files.
|
||||
- For each conflicting file: read it, find the conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`), understand the code context, and rewrite the file with the correct resolution. Remove all markers.
|
||||
- Verify the file syntax is correct after resolution.
|
||||
|
||||
4. **Finalize**:
|
||||
5. **Finalize**:
|
||||
- Run a final verification (build/test) to ensure the resolution works.
|
||||
- \`git add . && git commit -m "resolve merge conflicts"\`
|
||||
- confirm a clean working tree, then push via \`${t("push_branch")}\` (same push/prepush guidance as Build mode in *SYSTEM*)
|
||||
@@ -392,23 +415,38 @@ ${learningsStep(t, 6)}`,
|
||||
"General-purpose tasks that don't fit other modes: answering questions, adding comments, labeling, running ad-hoc commands, or any direct request",
|
||||
prompt: `### Checklist
|
||||
|
||||
1. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
|
||||
1. **task list**: create your task list for this run as your first action.
|
||||
|
||||
2. For substantial work — code changes across multiple files, multi-step investigations:
|
||||
2. Analyze the task. For simple operations (labeling, commenting, answering questions, running a single command), handle directly.
|
||||
|
||||
3. For substantial work — code changes across multiple files, multi-step investigations:
|
||||
- plan your approach before starting
|
||||
- use native file and shell tools for local operations
|
||||
- use ${pullfrogMcpName} MCP tools for GitHub/git operations
|
||||
- if code changes are needed: review your own diff before committing — verify only intended changes are present, no debug artifacts remain, and the changes are clean enough that a senior engineer would approve without hesitation
|
||||
|
||||
3. Finalize:
|
||||
4. Finalize:
|
||||
- if code changes were made, push to a pull request (new or existing) using \`${t("push_branch")}\` and \`${t("create_pull_request")}\` as needed. \`git status\` must be clean before you finish (see *SYSTEM* Git rules if push fails).
|
||||
- call \`${t("report_progress")}\` once with results — include exact tool errors if push or PR creation failed
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly
|
||||
|
||||
${learningsStep(t, 4)}`,
|
||||
- if the task involved labeling, commenting, or other GitHub operations, perform those directly`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// static export for UI display — uses opencode format as the readable default
|
||||
export const modes: Mode[] = computeModes("opencode");
|
||||
|
||||
/**
|
||||
* modes that legitimately never modify the working tree. used by the post-run
|
||||
* dirty-tree gate to suppress the "commit and push" nudge — those modes
|
||||
* complete by submitting a review (`Review` / `IncrementalReview`) or by
|
||||
* posting a Plan comment (`Plan`), not by touching files. any leftover in the
|
||||
* tree at end-of-run is incidental tool noise (e.g. a `node_modules/` from a
|
||||
* stray install attempt) on an ephemeral worktree; nudging the agent to
|
||||
* commit it would produce a spurious PR.
|
||||
*/
|
||||
export const NON_COMMITTING_MODES: ReadonlySet<string> = new Set([
|
||||
"Review",
|
||||
"IncrementalReview",
|
||||
"Plan",
|
||||
]);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pullfrog",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"pullfrog": "dist/cli.mjs",
|
||||
|
||||
@@ -135,14 +135,21 @@ export const installNodeDependencies: PrepDefinition = {
|
||||
}
|
||||
}
|
||||
|
||||
// get the frozen install command (or fallback to regular install)
|
||||
const resolved = resolveCommand(agent, "frozen", []) || resolveCommand(agent, "install", []);
|
||||
// frozen-lockfile install only. eager prep is non-mutating by contract:
|
||||
// we run it before the agent starts and any artifact it leaves in the
|
||||
// tree (e.g. a generated `package-lock.json`) trips the dirty-tree
|
||||
// post-run gate and produces a spurious PR. `frozen` commands
|
||||
// (`npm ci`, `pnpm install --frozen-lockfile`, etc.) fail cleanly
|
||||
// without modifying state when there's no lockfile, which is exactly
|
||||
// what we want — repos that need a non-frozen install must opt in via
|
||||
// a `setup` lifecycle hook (`action/utils/lifecycle.ts`).
|
||||
const resolved = resolveCommand(agent, "frozen", []);
|
||||
if (!resolved) {
|
||||
return {
|
||||
language: "node",
|
||||
packageManager,
|
||||
dependenciesInstalled: false,
|
||||
issues: [`no install command found for ${agent}`],
|
||||
issues: [`no frozen-install command available for ${agent}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`latest model per provider snapshot > matches snapshot 1`] = `
|
||||
{
|
||||
"anthropic": {
|
||||
"modelId": "claude-opus-4-7",
|
||||
"releaseDate": "2026-04-16",
|
||||
},
|
||||
"deepseek": {
|
||||
"modelId": "deepseek-v4-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"google": {
|
||||
"modelId": "gemini-3.1-flash-lite",
|
||||
"releaseDate": "2026-05-07",
|
||||
},
|
||||
"moonshotai": {
|
||||
"modelId": "kimi-k2.6",
|
||||
"releaseDate": "2026-04-21",
|
||||
},
|
||||
"openai": {
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-23",
|
||||
},
|
||||
"opencode": {
|
||||
"modelId": "gpt-5.5-pro",
|
||||
"releaseDate": "2026-04-24",
|
||||
},
|
||||
"openrouter": {
|
||||
"modelId": "x-ai/grok-4.3",
|
||||
"releaseDate": "2026-05-01",
|
||||
},
|
||||
"xai": {
|
||||
"modelId": "grok-4.3",
|
||||
"releaseDate": "2026-05-01",
|
||||
},
|
||||
}
|
||||
`;
|
||||
+74
-32
@@ -1,54 +1,96 @@
|
||||
/**
|
||||
* emits a JSON array of { slug, agent, name } entries for the `models-live`
|
||||
* matrix job. `agent` is auto-derived from the alias provider and matches the
|
||||
* harness the runtime would pick in production.
|
||||
* emits a JSON array of { slug, agent, name } entries for one of two CI matrix
|
||||
* jobs. `agent` mirrors the harness the runtime would pick in production
|
||||
* (anthropic/* → claude-code, everything else → opencode).
|
||||
*
|
||||
* set MATRIX_FILTER to a substring to restrict the matrix to matching aliases
|
||||
* — useful for iterating on a single provider without paying for every model.
|
||||
* MODE=aliases (default) — every alias minus pruned passthroughs. consumed by
|
||||
* `models-live`, which runs the cheap top-level CLI smoke per alias
|
||||
* (`action/test/model-smoke.ts`) to validate resolution + auth.
|
||||
*
|
||||
* passthrough pruning: openrouter/* aliases and keyed opencode/* aliases are
|
||||
* just routing-layer wrappers around models we already smoke-test directly
|
||||
* (anthropic/*, openai/*, google/*, etc). running every passthrough burns CI
|
||||
* minutes without catching anything the direct smoke doesn't. we keep one
|
||||
* canary per routing layer to validate the routing layer itself is alive;
|
||||
* slug-drift is caught separately by the `models-catalog` job. set
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 to bypass this for full validation.
|
||||
* MODE=flagships — one standard-tier model per provider. consumed by
|
||||
* `providers-live`, which runs the full harness smoke
|
||||
* (`pnpm runtest smoke <agent>`) to validate provider-class tool-calling
|
||||
* (e.g. Gemini schema sanitizer, OpenAI tool-call format).
|
||||
*
|
||||
* passthrough pruning (aliases mode): openrouter/* aliases and keyed opencode/*
|
||||
* aliases are routing-layer wrappers around models we already smoke-test
|
||||
* directly. running every passthrough burns CI minutes without catching
|
||||
* anything new — slug-drift is covered by the `models-catalog` job. one canary
|
||||
* per routing layer proves the routing surface (auth, tool-call translation)
|
||||
* is alive; set INCLUDE_PASSTHROUGHS=1 to bypass for full validation.
|
||||
*
|
||||
* usage:
|
||||
* node action/test/list-aliases.ts
|
||||
* MODE=flagships node action/test/list-aliases.ts
|
||||
* MATRIX_FILTER=gemini node action/test/list-aliases.ts
|
||||
* INCLUDE_ALL_PASSTHROUGHS=1 node action/test/list-aliases.ts
|
||||
* INCLUDE_PASSTHROUGHS=1 node action/test/list-aliases.ts
|
||||
*/
|
||||
import { modelAliases } from "../models.ts";
|
||||
|
||||
function agentForSlug(slug: string): "claude" | "opencode" {
|
||||
return slug.startsWith("anthropic/") ? "claude" : "opencode";
|
||||
}
|
||||
|
||||
// one canary per routing layer — proves the routing surface (auth, tool-call
|
||||
// translation) is alive without re-testing every underlying model.
|
||||
const ROUTING_CANARIES = new Set(["openrouter/claude-sonnet", "opencode/claude-sonnet"]);
|
||||
|
||||
// hand-picked "standard good model" per provider — not the pro/opus tier (too
|
||||
// expensive for per-push) and not the free/experimental tier (too flaky). these
|
||||
// aliases anchor the harness smoke job that catches provider-class regressions
|
||||
// like Gemini schema sanitization or OpenAI tool-call format drift. the
|
||||
// assertion below catches slug-drift loudly, but adding a NEW provider without
|
||||
// an entry here silently omits it from `providers-live` — see
|
||||
// wiki/models-catalog.md "To add a provider".
|
||||
const FLAGSHIPS = [
|
||||
"anthropic/claude-sonnet",
|
||||
"openai/gpt",
|
||||
"google/gemini-pro",
|
||||
"xai/grok",
|
||||
"deepseek/deepseek-pro",
|
||||
"moonshotai/kimi-k2",
|
||||
"opencode/big-pickle",
|
||||
"openrouter/claude-sonnet",
|
||||
];
|
||||
|
||||
function isPrunablePassthrough(alias: (typeof modelAliases)[number]): boolean {
|
||||
if (ROUTING_CANARIES.has(alias.slug)) return false;
|
||||
if (alias.provider === "openrouter") return true;
|
||||
// opencode FREE models (big-pickle, mimo, minimax, gpt-5-nano) are unique
|
||||
// to opencode and used in prod — keep them. only prune the keyed mirrors.
|
||||
if (alias.provider === "opencode" && !alias.isFree) return true;
|
||||
return false;
|
||||
// opencode FREE models (big-pickle, mimo-v2-pro-free, minimax-m2.5-free)
|
||||
// are unique to opencode and used in prod — keep them. only prune the keyed
|
||||
// mirrors.
|
||||
return alias.provider === "opencode" && !alias.isFree;
|
||||
}
|
||||
|
||||
const filter = process.env.MATRIX_FILTER?.trim() ?? "";
|
||||
const includeAllPassthroughs = process.env.INCLUDE_ALL_PASSTHROUGHS === "1";
|
||||
|
||||
const matrix = modelAliases
|
||||
.filter((alias) => (filter ? alias.slug.toLowerCase().includes(filter.toLowerCase()) : true))
|
||||
.filter((alias) => includeAllPassthroughs || !isPrunablePassthrough(alias))
|
||||
.map((alias) => ({
|
||||
function toMatrixEntry(alias: (typeof modelAliases)[number]) {
|
||||
return {
|
||||
slug: alias.slug,
|
||||
agent: agentForSlug(alias.slug),
|
||||
agent: alias.slug.startsWith("anthropic/") ? "claude" : "opencode",
|
||||
// readable display name (GHA renders slashes awkwardly in matrix job titles)
|
||||
name: alias.slug.replace("/", "-"),
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
const mode = process.env.MODE === "flagships" ? "flagships" : "aliases";
|
||||
const filter = process.env.MATRIX_FILTER?.trim().toLowerCase() ?? "";
|
||||
const includePassthroughs = process.env.INCLUDE_PASSTHROUGHS === "1";
|
||||
|
||||
const aliasBySlug = new Map(modelAliases.map((a) => [a.slug, a]));
|
||||
const matrix = (() => {
|
||||
if (mode === "flagships") {
|
||||
return FLAGSHIPS.map((slug) => {
|
||||
const alias = aliasBySlug.get(slug);
|
||||
if (!alias) {
|
||||
throw new Error(
|
||||
`list-aliases: flagship "${slug}" missing from modelAliases — update FLAGSHIPS`
|
||||
);
|
||||
}
|
||||
return alias;
|
||||
})
|
||||
.filter((alias) => !filter || alias.slug.toLowerCase().includes(filter))
|
||||
.map(toMatrixEntry);
|
||||
}
|
||||
return modelAliases
|
||||
.filter((alias) => {
|
||||
if (filter && !alias.slug.toLowerCase().includes(filter)) return false;
|
||||
if (!includePassthroughs && isPrunablePassthrough(alias)) return false;
|
||||
return true;
|
||||
})
|
||||
.map(toMatrixEntry);
|
||||
})();
|
||||
|
||||
process.stdout.write(JSON.stringify(matrix));
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* model-smoke: per-alias resolution + auth check that bypasses the Pullfrog
|
||||
* harness. resolves a model alias to its concrete provider/model + agent CLI,
|
||||
* invokes the CLI directly with a trivial "reply OK" prompt, and asserts the
|
||||
* provider replied. validates exactly the surface that changes when models.ts
|
||||
* changes — alias → resolve mapping, agent classification, env-var wiring —
|
||||
* without booting Docker, MCP, or the full agent runtime.
|
||||
*
|
||||
* tool-calling correctness is a property of the underlying model, not the
|
||||
* alias; the `providers-live` job runs the full harness smoke once per
|
||||
* provider (one standard-tier model each), which is enough.
|
||||
*
|
||||
* usage:
|
||||
* node action/test/model-smoke.ts --slug openai/gpt
|
||||
* PULLFROG_MODEL=openai/gpt node action/test/model-smoke.ts
|
||||
*/
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "dotenv";
|
||||
import { modelAliases, resolveCliModel } from "../models.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { getDevDependencyVersion } from "../utils/version.ts";
|
||||
|
||||
config({ path: join(import.meta.dirname, "..", ".env") });
|
||||
config({ path: join(import.meta.dirname, "..", "..", ".env") });
|
||||
|
||||
const PROMPT = "Reply with exactly OK and nothing else.";
|
||||
const MATCH = /\bOK\b/i;
|
||||
const TIMEOUT_MS = 60_000;
|
||||
|
||||
function parseSlug(): string {
|
||||
const argIdx = process.argv.indexOf("--slug");
|
||||
if (argIdx >= 0 && process.argv[argIdx + 1]) return process.argv[argIdx + 1];
|
||||
if (process.env.PULLFROG_MODEL) return process.env.PULLFROG_MODEL;
|
||||
throw new Error("model-smoke: pass --slug <alias> or set PULLFROG_MODEL");
|
||||
}
|
||||
|
||||
type Plan =
|
||||
| { agent: "opencode"; cliPath: string; args: string[] }
|
||||
| { agent: "claude"; cliPath: string; args: string[] };
|
||||
|
||||
async function plan(slug: string): Promise<Plan> {
|
||||
const alias = modelAliases.find((a) => a.slug === slug);
|
||||
if (!alias) throw new Error(`model-smoke: unknown alias "${slug}"`);
|
||||
|
||||
// walk the fallback chain so deprecated aliases (those with `fallback` set,
|
||||
// e.g. opencode/mimo-v2-pro-free → opencode/big-pickle) hit their replacement
|
||||
// instead of the dead resolve target. mirrors production via resolveCliModel.
|
||||
const cliModel = resolveCliModel(slug);
|
||||
if (!cliModel) throw new Error(`model-smoke: fallback chain for "${slug}" is broken or cyclic`);
|
||||
|
||||
// anthropic/* aliases run through claude-code in production; everything else
|
||||
// (openai, google, xai, deepseek, moonshot, opencode, openrouter) runs through
|
||||
// opencode. mirrors the inline classification in list-aliases.ts toMatrixEntry().
|
||||
if (slug.startsWith("anthropic/")) {
|
||||
const cliPath = await installFromNpmTarball({
|
||||
packageName: "@anthropic-ai/claude-code",
|
||||
version: getDevDependencyVersion("@anthropic-ai/claude-code"),
|
||||
executablePath: "cli.js",
|
||||
installDependencies: false,
|
||||
});
|
||||
// claude expects a bare model id (e.g. "claude-sonnet-4-6"), not "anthropic/claude-sonnet-4-6"
|
||||
const bareModel = cliModel.split("/").slice(1).join("/");
|
||||
return {
|
||||
agent: "claude",
|
||||
cliPath,
|
||||
args: [cliPath, "-p", PROMPT, "--model", bareModel],
|
||||
};
|
||||
}
|
||||
|
||||
const cliPath = await installFromNpmTarball({
|
||||
packageName: "opencode-ai",
|
||||
version: getDevDependencyVersion("opencode-ai"),
|
||||
executablePath: "bin/opencode",
|
||||
installDependencies: true,
|
||||
});
|
||||
return {
|
||||
agent: "opencode",
|
||||
cliPath,
|
||||
args: ["run", "--model", cliModel, PROMPT],
|
||||
};
|
||||
}
|
||||
|
||||
type SpawnResult = { ok: boolean; output: string; reason: string };
|
||||
|
||||
function runCli(p: Plan, env: NodeJS.ProcessEnv): Promise<SpawnResult> {
|
||||
// claude's cli.js shebangs to env node, but we invoke node explicitly to
|
||||
// avoid PATH-resolution surprises in CI runners; opencode is a real binary.
|
||||
const command = p.agent === "claude" ? "node" : p.cliPath;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, p.args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
}, TIMEOUT_MS);
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
const output = stdout + (stderr ? `\n---stderr---\n${stderr}` : "");
|
||||
if (signal === "SIGKILL") {
|
||||
resolve({ ok: false, output, reason: `timed out after ${TIMEOUT_MS / 1000}s` });
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
resolve({ ok: false, output, reason: `exit ${code}` });
|
||||
return;
|
||||
}
|
||||
if (!MATCH.test(stdout)) {
|
||||
resolve({ ok: false, output, reason: "no OK in stdout" });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, output, reason: "ok" });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ ok: false, output: stderr, reason: `spawn error: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const slug = parseSlug();
|
||||
const tempDir = mkdtempSync(join(tmpdir(), "model-smoke-"));
|
||||
const homeDir = join(tempDir, "home");
|
||||
|
||||
// installFromNpmTarball reads PULLFROG_TEMP_DIR from process.env, not from
|
||||
// the spawn env, so we mutate process.env up-front. HOME/XDG_CONFIG_HOME are
|
||||
// redirected to keep the agent CLIs from picking up the dev user's config.
|
||||
process.env.PULLFROG_TEMP_DIR = tempDir;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.XDG_CONFIG_HOME = join(homeDir, ".config");
|
||||
// opencode reads GOOGLE_GENERATIVE_AI_API_KEY for gemini; mirror the harness fallback.
|
||||
if (!process.env.GOOGLE_GENERATIVE_AI_API_KEY && process.env.GEMINI_API_KEY) {
|
||||
process.env.GOOGLE_GENERATIVE_AI_API_KEY = process.env.GEMINI_API_KEY;
|
||||
}
|
||||
|
||||
console.log(`» model-smoke ${slug}`);
|
||||
const p = await plan(slug);
|
||||
console.log(
|
||||
`» agent=${p.agent} cmd=${[p.agent === "claude" ? "node" : p.cliPath, ...p.args].join(" ")}`
|
||||
);
|
||||
|
||||
const result = await runCli(p, process.env);
|
||||
if (result.ok) {
|
||||
console.log(`✓ ${slug} (${p.agent})`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error(`✗ ${slug} (${p.agent}): ${result.reason}`);
|
||||
if (result.output) console.error(result.output);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { type ModelProvider, modelAliases, providers } from "../models.ts";
|
||||
import { modelAliases, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
// ── catalog drift tests — main-only ─────────────────────────────────────────────
|
||||
//
|
||||
@@ -8,6 +8,12 @@ import { type ModelProvider, modelAliases, providers } from "../models.ts";
|
||||
// catalog drift (new model ships, old model deprecated, etc.) causes failures
|
||||
// that are unrelated to any code change in the PR — so these run only on main.
|
||||
//
|
||||
// the registry is kept in sync with upstreams by the `models-bump` cron
|
||||
// (`.github/workflows/models-bump.yml`), which scans models.dev every 12h and
|
||||
// opens a PR bumping `resolve` / `openRouterResolve` for any alias whose
|
||||
// upstream has shipped a newer GA version. these tests are the integrity gate
|
||||
// for that PR — they catch typos, removed models, and openrouter mismatches.
|
||||
//
|
||||
// run locally with `pnpm test:catalog`.
|
||||
// in CI, gated to push events on main.
|
||||
|
||||
@@ -15,6 +21,7 @@ type ModelsDevModel = {
|
||||
name: string;
|
||||
status?: string;
|
||||
release_date?: string;
|
||||
cost?: { input?: number; output?: number };
|
||||
};
|
||||
|
||||
type ModelsDevProvider = {
|
||||
@@ -107,39 +114,69 @@ describe("openRouterResolve OpenRouter API validity", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("latest model per provider snapshot", async () => {
|
||||
const data = await api;
|
||||
const providerKeys = Object.keys(providers) as ModelProvider[];
|
||||
// ── OpenCode Zen served-list + free-cost checks ────────────────────────────────
|
||||
//
|
||||
// these enforce the two dynamic conditions for "this opencode alias works for a
|
||||
// user without OPENCODE_API_KEY" — the gap that let issue #691 ship:
|
||||
// 1. the alias's terminal-fallback resolve appears in Zen's /v1/models (Zen
|
||||
// actually serves it). caught nothing in #691 because mimo had a fallback
|
||||
// to big-pickle which IS served, but would catch any future alias that
|
||||
// points at a Zen-removed model without a fallback.
|
||||
// 2. for isFree aliases, the terminal-fallback's models.dev `cost.input` is
|
||||
// zero. caught the gpt-5-nano regression: $0.05/M input on models.dev,
|
||||
// marked isFree in our catalog.
|
||||
//
|
||||
// we check the terminal-fallback (via resolveDisplayAlias) because deprecated
|
||||
// aliases legitimately point at dead resolve targets — the terminal is what
|
||||
// actually runs at the agent CLI.
|
||||
|
||||
const latestByProvider: Record<string, { modelId: string; releaseDate: string }> = {};
|
||||
type ZenModel = { id: string };
|
||||
type ZenModelsResponse = { data: ZenModel[] };
|
||||
|
||||
for (const key of providerKeys) {
|
||||
const providerData = data[key];
|
||||
if (!providerData) continue;
|
||||
const zenApi = fetch("https://opencode.ai/zen/v1/models").then(
|
||||
(r) => r.json() as Promise<ZenModelsResponse>
|
||||
);
|
||||
|
||||
let latest: { modelId: string; releaseDate: string } | undefined;
|
||||
for (const [modelId, model] of Object.entries(providerData.models)) {
|
||||
// skip non-GA models so beta/nightly churn doesn't break the snapshot
|
||||
if (model.status) continue;
|
||||
const rd = model.release_date;
|
||||
if (!rd) continue;
|
||||
// tiebreak by modelId for stable ordering when release dates match
|
||||
if (
|
||||
!latest ||
|
||||
rd > latest.releaseDate ||
|
||||
(rd === latest.releaseDate && modelId > latest.modelId)
|
||||
) {
|
||||
latest = { modelId, releaseDate: rd };
|
||||
}
|
||||
}
|
||||
if (latest) {
|
||||
latestByProvider[key] = latest;
|
||||
}
|
||||
describe("opencode Zen served list", async () => {
|
||||
const zenData = await zenApi;
|
||||
const zenIds = new Set(zenData.data.map((m) => m.id));
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases) {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
if (!terminal) continue;
|
||||
const parsed = parseResolve(terminal.resolve);
|
||||
if (parsed.provider !== "opencode") continue;
|
||||
if (seen.has(terminal.resolve)) continue;
|
||||
seen.add(terminal.resolve);
|
||||
|
||||
it(`${alias.slug} terminal resolve ${terminal.resolve} is served by Zen`, () => {
|
||||
expect(
|
||||
zenIds.has(parsed.modelId),
|
||||
`terminal resolve "${terminal.resolve}" for alias "${alias.slug}" is not in https://opencode.ai/zen/v1/models — Zen no longer serves it. either point a fallback at a Zen-served alias or remove the entry.`
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("isFree models.dev cost", async () => {
|
||||
const data = await api;
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const alias of modelAliases.filter((a) => a.isFree)) {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
if (!terminal) continue;
|
||||
const parsed = parseResolve(terminal.resolve);
|
||||
if (seen.has(terminal.resolve)) continue;
|
||||
seen.add(terminal.resolve);
|
||||
|
||||
it(`${alias.slug} terminal resolve ${terminal.resolve} has cost.input === 0`, () => {
|
||||
const model = data[parsed.provider]?.models[parsed.modelId];
|
||||
expect(model, `terminal resolve "${terminal.resolve}" missing on models.dev`).toBeDefined();
|
||||
expect(
|
||||
model?.cost?.input,
|
||||
`isFree alias "${alias.slug}" walks to "${terminal.resolve}" which reports cost.input=${model?.cost?.input} on models.dev — either repoint the fallback or drop \`isFree\``
|
||||
).toBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
// when this fails, a provider shipped a new model. check whether we need
|
||||
// to add or update an alias in models.ts before updating the snapshot.
|
||||
it("matches snapshot", () => {
|
||||
expect(latestByProvider).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
+47
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { modelAliases, resolveCliModel } from "../models.ts";
|
||||
import { getModelEnvVars, modelAliases, resolveCliModel, resolveDisplayAlias } from "../models.ts";
|
||||
|
||||
// ── pure alias-registry invariants ──────────────────────────────────────────────
|
||||
//
|
||||
@@ -42,3 +42,49 @@ describe("fallback chain resolution", () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ── isFree invariants — sanity-check the catalog data shape ─────────────────────
|
||||
//
|
||||
// these catch the latent regressions that produced issue #691:
|
||||
// - opencode/gpt-5-nano was marked `isFree` despite costing $0.05/M
|
||||
// (no static check existed; demoted to paid in the same PR adding these tests)
|
||||
// - opencode/mimo-v2-pro-free was free + fallback to big-pickle (correct shape),
|
||||
// but nothing enforced that the terminal of an isFree fallback chain is itself
|
||||
// free. if someone repointed big-pickle's fallback at a paid model, all of mimo
|
||||
// and big-pickle's users would silently start hitting a paid endpoint.
|
||||
//
|
||||
// the cost.input check itself is network-dependent (lives in
|
||||
// models-catalog.main.test.ts); these are the static sibling that runs on every PR.
|
||||
describe("isFree invariants", () => {
|
||||
for (const alias of modelAliases.filter((a) => a.isFree)) {
|
||||
it(`${alias.slug} lives under the opencode provider`, () => {
|
||||
expect(
|
||||
alias.provider,
|
||||
`isFree alias "${alias.slug}" must be under "opencode" (Zen's keyless gate is opencode-only)`
|
||||
).toBe("opencode");
|
||||
});
|
||||
|
||||
it(`${alias.slug} has empty envVars`, () => {
|
||||
expect(
|
||||
getModelEnvVars(alias.slug),
|
||||
`isFree alias "${alias.slug}" must declare \`envVars: []\` so validateAgentApiKey doesn't demand OPENCODE_API_KEY`
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it(`${alias.slug} has no openRouterResolve`, () => {
|
||||
expect(
|
||||
alias.openRouterResolve,
|
||||
`isFree alias "${alias.slug}" must omit \`openRouterResolve\` — free Zen models don't exist on OpenRouter`
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`${alias.slug} fallback chain terminates at an isFree alias`, () => {
|
||||
const terminal = resolveDisplayAlias(alias.slug);
|
||||
expect(terminal, `fallback chain for "${alias.slug}" is broken`).toBeDefined();
|
||||
expect(
|
||||
terminal?.isFree,
|
||||
`isFree alias "${alias.slug}" walks to "${terminal?.slug}" which is NOT isFree — users would silently start paying`
|
||||
).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import type { AgentUsage } from "./agents/shared.ts";
|
||||
import type { PrepResult } from "./prep/types.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import type { DiffCoverageState } from "./utils/diffCoverage.ts";
|
||||
import {
|
||||
type ProgressComment,
|
||||
type ProgressCommentType,
|
||||
parseProgressComment,
|
||||
} from "./utils/progressComment.ts";
|
||||
import type { TodoTracker } from "./utils/todoTracking.ts";
|
||||
|
||||
export type BackgroundProcess = {
|
||||
pid: number;
|
||||
outputPath: string;
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type BrowserDaemon = { binDir: string; error?: never } | { binDir?: never; error: string };
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Valid inline-comment anchor lines per side at a particular checkout SHA.
|
||||
* Lives here (not in `mcp/review.ts`) so `ToolState` — which caches
|
||||
* `Map<path, CommentableLines>` per checkout — does not pull the MCP server
|
||||
* graph into every consumer of run state (the action's main loop, agent
|
||||
* harnesses, cf-worker indexing).
|
||||
*/
|
||||
export type CommentableLines = { RIGHT: Set<number>; LEFT: Set<number> };
|
||||
|
||||
/**
|
||||
* mutable per-run record of facts that occurred during execution. shared
|
||||
* between the action process and the MCP server (one process — toolState is
|
||||
* just a JS object passed by reference into both surfaces).
|
||||
*
|
||||
* design rule: ToolState is LITERAL. each field records a thing that
|
||||
* happened — `review` is set when `create_pull_request_review` succeeded,
|
||||
* `finalSummaryWritten` flips when `report_progress` wrote a non-plan body,
|
||||
* `selectedMode` is set when `select_mode` was called. fields should never
|
||||
* encode the absence of an event ("unsubmittedReview", "missingArtifact"),
|
||||
* speculative state, or values derived from other fields.
|
||||
*
|
||||
* any predicate the rest of the code needs ("the agent picked review mode but
|
||||
* never produced a review or progress write") is computed inline at the call
|
||||
* site, not stored. derived state in this struct invariably drifts from the
|
||||
* literal fields under refactors and is the wrong layer for the check.
|
||||
*
|
||||
* write narrowly: prefer adding state inside the tool that mutates it (e.g.
|
||||
* `create_pull_request_review` populates `toolState.review`) and reading
|
||||
* narrowly elsewhere. don't introduce flags from main.ts that mirror what an
|
||||
* MCP tool already records.
|
||||
*/
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
// PR HEAD sha at checkout time — used to detect new commits pushed during a review
|
||||
checkoutSha?: string;
|
||||
// commentable lines per file at checkoutSha — captured during checkout_pr so
|
||||
// review-time inline-comment validation matches the diff GitHub will anchor
|
||||
// to (commit_id=checkoutSha). without this, a PR update between checkout and
|
||||
// review would make listFiles (latest HEAD) disagree with the anchor,
|
||||
// silently dropping valid comments or letting invalid ones through.
|
||||
//
|
||||
// commentableLinesPullNumber records WHICH PR this snapshot belongs to. if
|
||||
// the agent checks out PR B and then reviews PR A in the same session, the
|
||||
// cached snapshot for B would silently mis-validate A's comments — keying
|
||||
// by PR number forces a re-fetch when the target changes.
|
||||
//
|
||||
// commentableLinesCheckoutSha pins the snapshot to the SHA it was built
|
||||
// against. if a second checkout_pr for the SAME PR bumps checkoutSha but
|
||||
// fails before repopulating the cache (e.g., listFiles rate-limits), the
|
||||
// stale snapshot would silently mis-validate comments against the new SHA.
|
||||
// comparing both fields forces a re-fetch when either moves.
|
||||
commentableLinesByFile?: Map<string, CommentableLines>;
|
||||
commentableLinesPullNumber?: number;
|
||||
commentableLinesCheckoutSha?: string | undefined;
|
||||
// SHA to diff incrementally against — set from event payload on first checkout,
|
||||
// then from checkoutSha when review.ts detects new commits mid-review
|
||||
beforeSha?: string;
|
||||
selectedMode?: string;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
browserDaemon?: BrowserDaemon | undefined;
|
||||
review?: {
|
||||
id: number;
|
||||
nodeId: string;
|
||||
reviewedSha: string | undefined;
|
||||
};
|
||||
// dedupe key: parent review comment_id → most-recent reply written this
|
||||
// session by reply_to_review_comment. used by duplicateReplyDecision to
|
||||
// skip identical-body re-emissions of the same call (PR #610 root cause).
|
||||
// body-keyed (not just id-keyed) so legitimate follow-up replies with
|
||||
// different content still go through.
|
||||
reviewReplies?: Map<
|
||||
number,
|
||||
{ commentId: number; url: string | undefined; bodyWithFooter: string }
|
||||
>;
|
||||
dependencyInstallation?: {
|
||||
status: "not_started" | "in_progress" | "completed" | "failed";
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
// undefined = no comment yet, object = active comment, null = deliberately deleted
|
||||
progressComment: ProgressComment | null | undefined;
|
||||
// immutable snapshot: true if a progress comment was pre-created at init time.
|
||||
// survives deleteProgressComment so handleAgentResult can still detect "expected but never reported".
|
||||
hadProgressComment: boolean;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
// set after a non-plan report_progress successfully writes the final summary.
|
||||
// decoupled from todoTracker.enabled so cleanup detection survives API failures.
|
||||
finalSummaryWritten?: boolean;
|
||||
// set by select_mode when Plan + issue_number and plan-comment API returns existing plan (for report_progress target_plan_comment)
|
||||
existingPlanCommentId?: number;
|
||||
previousPlanBody?: string;
|
||||
// absolute path to the PR summary markdown file the agent edits in place.
|
||||
// seeded by main.ts before the agent starts when payload.generateSummary is set;
|
||||
// read back at end-of-run to persist to DB.
|
||||
summaryFilePath?: string;
|
||||
// exact bytes of the seeded snapshot file at run start. compared against
|
||||
// the file content at end-of-run to detect "agent never touched it" — in
|
||||
// that case persistSummary skips the DB write (saving the seed verbatim
|
||||
// would either re-write what the DB already has, on incremental runs, or
|
||||
// serialize the placeholder scaffold, on first runs).
|
||||
summarySeed?: string;
|
||||
// set to true after persistSummary completes once. prevents the error-path
|
||||
// call (which exists so a successful agent edit before a crash still gets
|
||||
// persisted) from redundantly re-running the DB PATCH on the
|
||||
// success-then-late-throw path.
|
||||
summaryPersistAttempted?: boolean;
|
||||
// absolute path to the rolling repo-level learnings markdown file the
|
||||
// agent reads at startup and may edit at end-of-run. seeded by main.ts
|
||||
// for every run from `Repo.learnings` (empty file when no learnings
|
||||
// exist yet); read back at end-of-run to persist any edits.
|
||||
learningsFilePath?: string;
|
||||
// exact bytes of the seeded learnings file at run start. compared
|
||||
// against the file content at end-of-run to detect "agent never touched
|
||||
// it" — in that case persistLearnings skips the DB PATCH (saving the
|
||||
// identical content would be a no-op write that wastes a LearningsRevision
|
||||
// row and the API round-trip).
|
||||
learningsSeed?: string;
|
||||
// mirror of `summaryPersistAttempted` for the learnings tmpfile — guards
|
||||
// the error-path / exit-signal callers from a redundant second PATCH
|
||||
// after the success path already persisted.
|
||||
learningsPersistAttempted?: boolean;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
model?: string | undefined;
|
||||
todoTracker?: TodoTracker | undefined;
|
||||
diffCoverage?: DiffCoverageState | undefined;
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
progressComment: { id: string; type: ProgressCommentType } | undefined;
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const resolved = parseProgressComment(params.progressComment);
|
||||
|
||||
if (resolved) {
|
||||
log.info(`» using pre-created progress comment: ${resolved.id} (${resolved.type})`);
|
||||
}
|
||||
|
||||
return {
|
||||
progressComment: resolved,
|
||||
hadProgressComment: !!resolved,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
@@ -37,6 +37,18 @@ export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
|
||||
// never send Content-Type on body-less requests. empirically, Vercel's
|
||||
// Next.js lambda adapter (Next 16.1.x) throws `SyntaxError: Unexpected
|
||||
// end of data` before delegating to the route handler — returning a 500 —
|
||||
// when Content-Type is set but no body is present. exact mechanism is
|
||||
// unverified (minified runtime frame), but Content-Type on a body-less
|
||||
// request has no defined semantics per RFC 9110 §8.3 anyway. see #692.
|
||||
if (!options.body) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase() === "content-type") delete headers[key];
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
|
||||
const init: RequestInit = {
|
||||
|
||||
@@ -27,11 +27,7 @@ describe("validateAgentApiKey", () => {
|
||||
});
|
||||
|
||||
it("passes for other free opencode models", () => {
|
||||
for (const slug of [
|
||||
"opencode/gpt-5-nano",
|
||||
"opencode/mimo-v2-pro-free",
|
||||
"opencode/minimax-m2.5-free",
|
||||
]) {
|
||||
for (const slug of ["opencode/mimo-v2-pro-free", "opencode/minimax-m2.5-free"]) {
|
||||
expect(() => validateAgentApiKey({ ...base, model: slug })).not.toThrow();
|
||||
}
|
||||
});
|
||||
@@ -59,6 +55,12 @@ describe("validateAgentApiKey", () => {
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws for opencode/gpt-5-nano without OPENCODE_API_KEY (paid Zen alias)", () => {
|
||||
expect(() => validateAgentApiKey({ ...base, model: "opencode/gpt-5-nano" })).toThrow(
|
||||
"no API key found"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no model (auto-select)", () => {
|
||||
|
||||
@@ -25,3 +25,18 @@ export function getApiUrl(): string {
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* true when the action is configured to talk to a localhost API server (i.e.
|
||||
* `pnpm dev` running on the developer's box). signals we can use dev-only
|
||||
* affordances like the `x-dev-repo` proxy-token bypass — the corresponding
|
||||
* server-side dev gates (`NODE_ENV === "development"`) ensure these paths
|
||||
* never activate against prod regardless of what the action does.
|
||||
*/
|
||||
export function isLocalApiUrl(): boolean {
|
||||
try {
|
||||
return isLocalUrl(new URL(getApiUrl()));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { filterEnv } from "./secrets.ts";
|
||||
import { getDevDependencyVersion } from "./version.ts";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
|
||||
+50
-9
@@ -99,6 +99,22 @@ type AcquireTokenOptions = {
|
||||
permissions?: GitHubAppPermissions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thrown when our token-exchange endpoint returns a non-2xx response.
|
||||
* The retry policy in `acquireNewToken` looks for this concrete type to
|
||||
* skip retries — 4xx is terminal user state (not-installed, not-authorized)
|
||||
* and 5xx is rare enough that re-running the workflow is the right escape
|
||||
* hatch. Genuine network failures throw plain `Error` and stay retryable.
|
||||
*/
|
||||
class TokenExchangeError extends Error {
|
||||
readonly status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "TokenExchangeError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
|
||||
@@ -128,7 +144,22 @@ async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string>
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||
// prefer the server-side `error` field — it's the single source of
|
||||
// truth for the install URL (uses GITHUB_APP_INSTALL_URL, which
|
||||
// varies per env / GITHUB_APP_SLUG). fall back to a generic message
|
||||
// if the body isn't JSON or doesn't carry an `error` field.
|
||||
let serverMessage: string | undefined;
|
||||
try {
|
||||
const body = (await tokenResponse.json()) as { error?: unknown };
|
||||
if (typeof body.error === "string") serverMessage = body.error;
|
||||
} catch {
|
||||
// body wasn't JSON — fall through to the generic message
|
||||
}
|
||||
throw new TokenExchangeError(
|
||||
tokenResponse.status,
|
||||
serverMessage ??
|
||||
`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as InstallationToken;
|
||||
@@ -221,8 +252,11 @@ const checkRepositoryAccess = async (
|
||||
headers: { Authorization: `token ${token}` },
|
||||
});
|
||||
|
||||
const ownerLower = repoOwner.toLowerCase();
|
||||
const nameLower = repoName.toLowerCase();
|
||||
return response.repositories.some(
|
||||
(repo) => repo.owner.login === repoOwner && repo.name === repoName
|
||||
(repo) =>
|
||||
repo.owner.login.toLowerCase() === ownerLower && repo.name.toLowerCase() === nameLower
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
@@ -330,13 +364,20 @@ export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<strin
|
||||
if (isOIDCAvailable()) {
|
||||
return await retry(() => acquireTokenViaOIDC(opts), {
|
||||
label: "token exchange",
|
||||
shouldRetry: (error) =>
|
||||
error instanceof Error &&
|
||||
(error.name === "AbortError" ||
|
||||
error.message.includes("fetch failed") ||
|
||||
error.message.includes("ECONNRESET") ||
|
||||
error.message.includes("ETIMEDOUT") ||
|
||||
error.message.includes("Token exchange failed")),
|
||||
shouldRetry: (error) => {
|
||||
// 4xx is terminal user state (app not installed, permissions wrong) —
|
||||
// retrying just triples our log noise and the user's CI bill (see
|
||||
// #693). 5xx/429 are transient (vercel cold start, github outage,
|
||||
// rate limit) and should ride the existing backoff.
|
||||
if (error instanceof TokenExchangeError) return error.status >= 500 || error.status === 429;
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message.includes("timed out") ||
|
||||
error.message.includes("fetch failed") ||
|
||||
error.message.includes("ECONNRESET") ||
|
||||
error.message.includes("ETIMEDOUT"))
|
||||
);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// local development via GitHub App
|
||||
|
||||
+42
-11
@@ -12,7 +12,10 @@ interface InstructionsContext {
|
||||
modes: Mode[];
|
||||
agentId: AgentId;
|
||||
outputSchema?: Record<string, unknown> | undefined;
|
||||
learnings: string | null;
|
||||
/** absolute path to the seeded learnings tmpfile, or null when the file
|
||||
* couldn't be seeded for some reason. main.ts always seeds, so in
|
||||
* practice this is always set; the null case keeps the type honest. */
|
||||
learningsFilePath: string | null;
|
||||
}
|
||||
|
||||
interface PromptContext extends InstructionsContext {
|
||||
@@ -29,6 +32,7 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||
"~pullfrog": _,
|
||||
prompt: _p,
|
||||
eventInstructions: _ei,
|
||||
previousRunsNote: _prn,
|
||||
event: _e,
|
||||
...payloadRest
|
||||
} = ctx.payload;
|
||||
@@ -143,17 +147,23 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
// section builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers
|
||||
// the user's task: blockquoted user prompt, or event-level instructions for auto-triggers.
|
||||
// `previousRunsNote` is system-injected context (e.g. prior runs superseded by a
|
||||
// comment edit); it's appended regardless of which branch wins so it survives
|
||||
// user-prompt precedence over eventInstructions.
|
||||
function buildTaskSection(ctx: PromptContext): string {
|
||||
const previousRunsNote = ctx.payload.previousRunsNote?.trim() ?? "";
|
||||
|
||||
if (ctx.userQuoted) {
|
||||
const parts = [ctx.userQuoted, previousRunsNote].filter(Boolean);
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${ctx.userQuoted}`;
|
||||
${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
const eventInstructions = ctx.payload.eventInstructions ?? "";
|
||||
if (eventInstructions) {
|
||||
const parts = [ctx.eventTitle, eventInstructions].filter(Boolean);
|
||||
if (eventInstructions || previousRunsNote) {
|
||||
const parts = [ctx.eventTitle, eventInstructions, previousRunsNote].filter(Boolean);
|
||||
return `************* YOUR TASK *************
|
||||
|
||||
${parts.join("\n\n")}`;
|
||||
@@ -273,6 +283,21 @@ ${getStandaloneModeInstructions(ctx.payload.event.trigger, t, ctx.outputSchema)}
|
||||
|
||||
Trust the tools — do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error. Exception: right before \`${t("push_branch")}\`, ensure the working tree is clean — that tool rejects dirty trees, and tests you ran earlier often leave untracked output.
|
||||
|
||||
### Parallel tool execution
|
||||
|
||||
For maximum efficiency, whenever you need to perform multiple independent operations, invoke all relevant tools simultaneously in a single assistant turn rather than sequentially. The dominant failure mode is grep → read → read → read → read across separate turns when one round trip would do. Always parallelize when calls are independent:
|
||||
- reading multiple files (especially after a grep returns candidates)
|
||||
- multiple greps with different patterns
|
||||
- glob + grep + read combos
|
||||
- listing multiple directories
|
||||
- inspecting multiple MCP tools or resources
|
||||
|
||||
Do NOT parallelize operations that depend on prior output (e.g. create a file then read it), or ordered stateful mutations. Edits are not parallelizable — sequence those normally.${
|
||||
ctx.agentId === "opencode"
|
||||
? `\n\nOn OpenCode you also have a \`batch\` tool that bundles 1-25 independent calls into one wrapper call. Reach for it whenever you have >=2 independent calls. Native parallel tool_use and \`batch\` both achieve one round trip instead of N — use whichever your provider supports best.`
|
||||
: `\n\nEmit multiple \`tool_use\` blocks in the same assistant message for independent calls — the runtime executes them concurrently. Do not wait for one tool result before issuing the next independent call.`
|
||||
}
|
||||
|
||||
### Command execution
|
||||
|
||||
Never use \`sleep\` to wait for commands to complete. Commands run synchronously — when the shell tool returns, the command has finished.
|
||||
@@ -350,11 +375,17 @@ function assembleFullPrompt(ctx: {
|
||||
procedure: string;
|
||||
eventContext: string;
|
||||
system: string;
|
||||
learnings: string | null;
|
||||
learningsFilePath: string | null;
|
||||
runtime: string;
|
||||
}): string {
|
||||
const learningsSection = ctx.learnings
|
||||
? `************* LEARNINGS *************\n\n${ctx.learnings}`
|
||||
// the LEARNINGS section is intentionally tiny — just the file path and a
|
||||
// one-line "read it" instruction. embedding the contents would re-inflate
|
||||
// the prompt every run (the previous design's failure mode) and clutter
|
||||
// CI logs. the agent reads the file with its native file tool; the
|
||||
// post-run reflection turn (action/agents/postRun.ts) is where editing
|
||||
// is encouraged, with the prune-stale framing.
|
||||
const learningsSection = ctx.learningsFilePath
|
||||
? `************* LEARNINGS *************\n\nRepo-level learnings accumulated by previous agent runs live at \`${ctx.learningsFilePath}\`. Read this file early and let the entries inform your approach (test commands, conventions, gotchas, etc.). The file may be empty if no learnings have been collected yet.`
|
||||
: "";
|
||||
|
||||
const runtimeSection = `************* RUNTIME *************\n\n${ctx.runtime}`;
|
||||
@@ -389,8 +420,8 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
if (eventContext)
|
||||
tocEntries.push({ label: "EVENT CONTEXT", description: "related PR/issue data" });
|
||||
tocEntries.push({ label: "SYSTEM", description: "persona, security, tools, workflow rules" });
|
||||
if (pctx.learnings)
|
||||
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge" });
|
||||
if (pctx.learningsFilePath)
|
||||
tocEntries.push({ label: "LEARNINGS", description: "repo-specific knowledge file path" });
|
||||
tocEntries.push({ label: "RUNTIME", description: "environment metadata" });
|
||||
|
||||
const toc = buildToc(tocEntries);
|
||||
@@ -401,7 +432,7 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
procedure,
|
||||
eventContext,
|
||||
system,
|
||||
learnings: pctx.learnings,
|
||||
learningsFilePath: pctx.learningsFilePath,
|
||||
runtime: pctx.runtime,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
LEARNINGS_FILE_NAME,
|
||||
learningsFilePath,
|
||||
readLearningsFile,
|
||||
seedLearningsFile,
|
||||
} from "./learnings.ts";
|
||||
|
||||
describe("learnings tmpfile round-trip", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "pullfrog-learnings-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("seeds with existing learnings and reads them back verbatim", async () => {
|
||||
const current = "- run tests with `pnpm -r test`\n- default branch is `main`";
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current });
|
||||
expect(path).toBe(learningsFilePath(dir));
|
||||
expect(path.endsWith(LEARNINGS_FILE_NAME)).toBe(true);
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe(current);
|
||||
});
|
||||
|
||||
it("seeds an empty file when the repo has no learnings yet", async () => {
|
||||
// empty seed (vs scaffold-with-comment) keeps the byte-trim equality
|
||||
// gate clean: an untouched first run reads back as "" and persistLearnings
|
||||
// skips the API round-trip rather than writing a placeholder string into
|
||||
// Repo.learnings.
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current: null });
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when the file is missing (treated as no-change by persist)", async () => {
|
||||
const path = learningsFilePath(dir);
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBeNull();
|
||||
});
|
||||
|
||||
it("trims whitespace so trailing newlines never trigger a spurious PATCH", async () => {
|
||||
// editors commonly add a trailing newline on save. without trimming, a
|
||||
// round-trip "read seed → save unchanged" would fail byte-equality and
|
||||
// burn a LearningsRevision row on every run.
|
||||
const current = "- one fact";
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current });
|
||||
await writeFile(path, `${current}\n\n `, "utf8");
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBe(current);
|
||||
});
|
||||
|
||||
it("truncates content over the 10k server-side cap", async () => {
|
||||
// server enforces MAX_LEARNINGS_LENGTH = 10_000. truncating client-side
|
||||
// avoids a 400 round-trip and keeps the bytes the agent will see in the
|
||||
// next run aligned with what the server actually stored.
|
||||
const oversized = "x".repeat(11_000);
|
||||
const path = await seedLearningsFile({ tmpdir: dir, current: null });
|
||||
await writeFile(path, oversized, "utf8");
|
||||
const read = await readLearningsFile(path);
|
||||
expect(read).toBeTruthy();
|
||||
expect(read?.length).toBe(10_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
/**
|
||||
* Repo-level learnings — operational facts about a repo (setup steps, test
|
||||
* commands, conventions, gotchas) that accumulate across agent runs and feed
|
||||
* back into future runs as durable context. Modeled on the PR-summary tmpfile
|
||||
* pattern (see action/utils/prSummary.ts):
|
||||
*
|
||||
* 1. server seeds `pullfrog-learnings.md` from `Repo.learnings` (or empty
|
||||
* when the repo has none yet)
|
||||
* 2. the agent reads the file at startup as part of its context, and may
|
||||
* edit it in place at end-of-run when prompted by the reflection turn
|
||||
* 3. main.ts reads the file back at end-of-run and PATCHes
|
||||
* `/api/repo/[owner]/[repo]/learnings` if it changed (byte-trim equality
|
||||
* against the seed determines change detection)
|
||||
*
|
||||
* Edit-in-place avoids stuffing the entire learnings list into both the
|
||||
* prompt context and an `update_learnings` MCP tool call (which previously
|
||||
* required passing the FULL merged list as a string parameter — an
|
||||
* output-token tax that grew linearly with the learnings size).
|
||||
*/
|
||||
|
||||
export const LEARNINGS_FILE_NAME = "pullfrog-learnings.md";
|
||||
|
||||
/** server-side cap mirrors `MAX_LEARNINGS_LENGTH` in
|
||||
* `app/api/repo/[owner]/[repo]/learnings/route.ts`. truncating client-side
|
||||
* keeps the PATCH from being rejected with a 400. */
|
||||
const MAX_LEARNINGS_LENGTH = 10_000;
|
||||
|
||||
export function learningsFilePath(tmpdir: string): string {
|
||||
return join(tmpdir, LEARNINGS_FILE_NAME);
|
||||
}
|
||||
|
||||
/** seed the learnings file with the repo's current learnings, or an empty
|
||||
* file when the repo has none yet. returns the absolute path. */
|
||||
export async function seedLearningsFile(params: {
|
||||
tmpdir: string;
|
||||
current: string | null;
|
||||
}): Promise<string> {
|
||||
const path = learningsFilePath(params.tmpdir);
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
// empty file when no learnings exist yet — the agent reads it, sees
|
||||
// nothing, and the LEARNINGS prompt section explains what the file is for.
|
||||
// a header comment would risk being persisted as part of the first real
|
||||
// edit, polluting the DB row with placeholder text.
|
||||
await writeFile(path, params.current ?? "", "utf8");
|
||||
return path;
|
||||
}
|
||||
|
||||
/** read the agent-edited learnings file. returns null when the file is
|
||||
* missing or unreadable (treated as "no change"). caps content at the
|
||||
* server's max length to avoid a 400 round-trip. */
|
||||
export async function readLearningsFile(path: string): Promise<string | null> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length > MAX_LEARNINGS_LENGTH) return trimmed.slice(0, MAX_LEARNINGS_LENGTH);
|
||||
return trimmed;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { normalizeEnv, sanitizeSecret } from "./normalizeEnv.ts";
|
||||
|
||||
/**
|
||||
* These tests pin the load-bearing invariants of secret sanitisation:
|
||||
* - sensitive values are trimmed before downstream code reads them
|
||||
* - whitespace-only values are NOT silently zeroed (leave env unchanged)
|
||||
* - case normalisation still happens
|
||||
*
|
||||
* Masking (`core.setSecret`) is delegated to `@actions/core` and trusted to
|
||||
* work as documented — we don't spy on stdout to re-test the toolkit.
|
||||
*/
|
||||
|
||||
describe("normalizeEnv: process.env state contract", () => {
|
||||
let originalEnv: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// normalizeEnv() iterates the entire process.env, so the test must
|
||||
// control it. snapshot + full wipe + restore is the cleanest isolation.
|
||||
originalEnv = { ...process.env };
|
||||
for (const k of Object.keys(process.env)) delete process.env[k];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of Object.keys(process.env)) delete process.env[k];
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it("trims trailing newline from sensitive env vars", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-secret-value\n";
|
||||
normalizeEnv();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-secret-value");
|
||||
});
|
||||
|
||||
it("trims surrounding whitespace including \\r\\n and spaces", () => {
|
||||
process.env.OPENAI_API_KEY = " sk-openai-value\r\n ";
|
||||
normalizeEnv();
|
||||
expect(process.env.OPENAI_API_KEY).toBe("sk-openai-value");
|
||||
});
|
||||
|
||||
it("leaves clean sensitive values untouched", () => {
|
||||
process.env.ANTHROPIC_API_KEY = "sk-ant-clean";
|
||||
normalizeEnv();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-clean");
|
||||
});
|
||||
|
||||
it("ignores non-sensitive env vars", () => {
|
||||
process.env.NODE_ENV = "production\n";
|
||||
normalizeEnv();
|
||||
expect(process.env.NODE_ENV).toBe("production\n");
|
||||
});
|
||||
|
||||
it("canonicalises case and trims the value", () => {
|
||||
process.env.anthropic_api_key = "sk-ant-lowercase\n";
|
||||
normalizeEnv();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe("sk-ant-lowercase");
|
||||
expect(process.env.anthropic_api_key).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves whitespace-only values rather than silently zeroing them", () => {
|
||||
// contract: don't mutate when value is whitespace-only. caller sees the
|
||||
// misconfigured value verbatim and either fails clearly downstream or
|
||||
// logs a missing-key error.
|
||||
process.env.ANTHROPIC_API_KEY = " \n ";
|
||||
normalizeEnv();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe(" \n ");
|
||||
});
|
||||
|
||||
it("preserves embedded newlines (toolkit masks each line)", () => {
|
||||
// multi-line PEMs aren't used in practice, but if one slipped in via a
|
||||
// DB secret we don't want to silently mutate it. trim() only touches
|
||||
// the ends; @actions/core handles per-line masking via the runner.
|
||||
process.env.ANTHROPIC_API_KEY = "line1\nline2";
|
||||
normalizeEnv();
|
||||
expect(process.env.ANTHROPIC_API_KEY).toBe("line1\nline2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeSecret return value", () => {
|
||||
it("returns the trimmed value for a sensitive secret with trailing newline", () => {
|
||||
expect(sanitizeSecret("ANTHROPIC_API_KEY", "sk-ant-secret\n")).toBe("sk-ant-secret");
|
||||
});
|
||||
|
||||
it("returns the value unchanged when no trimming is needed", () => {
|
||||
expect(sanitizeSecret("ANTHROPIC_API_KEY", "sk-ant-clean")).toBe("sk-ant-clean");
|
||||
});
|
||||
|
||||
it("returns null for whitespace-only input so caller can skip injection", () => {
|
||||
expect(sanitizeSecret("ANTHROPIC_API_KEY", " \n")).toBeNull();
|
||||
});
|
||||
});
|
||||
+45
-13
@@ -1,11 +1,40 @@
|
||||
import * as core from "@actions/core";
|
||||
import { log } from "./cli.ts";
|
||||
import { isSensitiveEnvName } from "./secrets.ts";
|
||||
|
||||
function maskValue(value: string | undefined) {
|
||||
if (value && typeof value === "string" && value.trim().length > 0) {
|
||||
// ::add-mask::value tells GitHub Actions to mask this value in logs
|
||||
console.log(`::add-mask::${value}`);
|
||||
/**
|
||||
* Trim surrounding whitespace from a sensitive value and register it as a
|
||||
* GitHub Actions log mask. Trailing newlines from terminal-copy paste are a
|
||||
* common footgun: the value travels through GH Actions logs and any tool
|
||||
* that re-emits parts of it leaks the unmasked tail. Trimming canonicalises
|
||||
* the value so the mask matches exactly what downstream tools will print.
|
||||
*
|
||||
* Masking is delegated to `core.setSecret` (not raw `console.log`) so the
|
||||
* toolkit percent-encodes `\r`/`\n`; the runner V2 parser decodes them and
|
||||
* registers the full value plus every non-empty line as separate masks. That
|
||||
* keeps us safe for embedded-newline values (PEMs, kubeconfigs, JSON blobs)
|
||||
* even though they aren't currently used.
|
||||
*
|
||||
* Returns the trimmed value, or `null` when the input was whitespace-only —
|
||||
* callers must leave `process.env` untouched in that case so a misconfigured
|
||||
* value surfaces as a clear "missing key" downstream rather than silently
|
||||
* mutating to the empty string.
|
||||
*/
|
||||
export function sanitizeSecret(key: string, value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
log.warning(
|
||||
`» ${key} is whitespace-only — leaving env var unchanged. check your secret value.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (trimmed !== value) {
|
||||
log.warning(
|
||||
`» stripped whitespace from ${key} (whitespace in secret values breaks GitHub Actions log masking)`
|
||||
);
|
||||
}
|
||||
core.setSecret(trimmed);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +44,8 @@ function maskValue(value: string | undefined) {
|
||||
* If there are conflicts (same key with different capitalizations but different values),
|
||||
* logs a warning and keeps the uppercase version.
|
||||
*
|
||||
* Also registers sensitive values as masks in GitHub Actions.
|
||||
* Also trims and masks sensitive values so accidental trailing whitespace
|
||||
* doesn't defeat GitHub Actions log masking.
|
||||
*/
|
||||
export function normalizeEnv(): void {
|
||||
const upperKeys = new Map<string, string[]>();
|
||||
@@ -30,14 +60,6 @@ export function normalizeEnv(): void {
|
||||
|
||||
// process each group
|
||||
for (const [upperKey, keys] of upperKeys) {
|
||||
// if sensitive, ensure we mask the value (regardless of whether we rename it or not)
|
||||
if (isSensitiveEnvName(upperKey)) {
|
||||
// mask all values associated with this key group
|
||||
for (const key of keys) {
|
||||
maskValue(process.env[key]);
|
||||
}
|
||||
}
|
||||
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (key !== upperKey) {
|
||||
@@ -71,4 +93,14 @@ export function normalizeEnv(): void {
|
||||
// set the uppercase version
|
||||
process.env[upperKey] = preferredValue;
|
||||
}
|
||||
|
||||
// trim + mask sensitive values after case normalisation so each key is
|
||||
// visited exactly once with its final, canonical value
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!isSensitiveEnvName(key)) continue;
|
||||
const value = process.env[key];
|
||||
if (typeof value !== "string" || value.length === 0) continue;
|
||||
const sanitized = sanitizeSecret(key, value);
|
||||
if (sanitized !== null) process.env[key] = sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const JsonPayload = type({
|
||||
"triggerer?": "string | undefined",
|
||||
|
||||
"eventInstructions?": "string",
|
||||
"previousRunsNote?": "string",
|
||||
"event?": "object",
|
||||
"timeout?": "string | undefined",
|
||||
"progressComment?": type({
|
||||
@@ -157,6 +158,7 @@ export function resolvePayload(
|
||||
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
||||
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
||||
eventInstructions: jsonPayload?.eventInstructions,
|
||||
previousRunsNote: jsonPayload?.previousRunsNote,
|
||||
event,
|
||||
timeout: inputs.timeout ?? jsonPayload?.timeout,
|
||||
cwd: resolveCwd(inputs.cwd),
|
||||
|
||||
@@ -7,7 +7,13 @@ describe("detectProviderError", () => {
|
||||
expect(detectProviderError("commit f609cc89e84596ab125d60dac568bfb2ef398396 429")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for x-ratelimit-* response headers in 401 error JSON", () => {
|
||||
it("classifies 401 + x-ratelimit-* headers as auth, not rate-limited", () => {
|
||||
// OpenRouter 401 responses bundle `x-ratelimit-*` rate-limit headers
|
||||
// alongside the auth error. the auth patterns must win — pre-fix this
|
||||
// got tagged as `rate limited` because of the loose `\brate[_ ]limit`
|
||||
// match against header names like `ratelimit-limit-requests`. note: in
|
||||
// OpenRouter's actual format the header name is `ratelimit` (one word),
|
||||
// but the dumped JSON sometimes contains `rate-limit` separators too.
|
||||
const stderr = JSON.stringify({
|
||||
error: { name: "APIError", statusCode: 401, message: "Invalid authentication credentials" },
|
||||
headers: {
|
||||
@@ -16,7 +22,7 @@ describe("detectProviderError", () => {
|
||||
"x-ratelimit-reset-tokens": "2025-01-01T00:00:00Z",
|
||||
},
|
||||
});
|
||||
expect(detectProviderError(stderr)).toBeNull();
|
||||
expect(detectProviderError(stderr)).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("returns null for INTERNAL_SERVER_ERROR substring", () => {
|
||||
@@ -29,6 +35,37 @@ describe("detectProviderError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth errors", () => {
|
||||
it("detects 401 / 403 status codes as auth errors", () => {
|
||||
expect(detectProviderError('{"statusCode": 401}')).toBe("auth error (401)");
|
||||
expect(detectProviderError('{"statusCode": 403}')).toBe("auth error (403)");
|
||||
expect(detectProviderError("status_code: 401")).toBe("auth error (401)");
|
||||
});
|
||||
|
||||
it("detects OpenRouter 'User not found' (disabled/invalid key)", () => {
|
||||
// bare `"code":401` lacks a status-key prefix so the 401 status pattern
|
||||
// intentionally doesn't fire; the User-not-found pattern catches it.
|
||||
expect(detectProviderError('{"error":{"message":"User not found","code":401}}')).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
expect(detectProviderError("APIError: User not found.")).toBe(
|
||||
"auth error (invalid/disabled key)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'Invalid authentication' phrasing", () => {
|
||||
expect(detectProviderError("Invalid authentication credentials")).toBe(
|
||||
"auth error (invalid credentials)"
|
||||
);
|
||||
});
|
||||
|
||||
it("detects 'No auth credentials found' phrasing", () => {
|
||||
expect(detectProviderError("AI_APICallError: No auth credentials found")).toBe(
|
||||
"auth error (missing credentials)"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("real provider errors", () => {
|
||||
it("detects 429 only when adjacent to a status key", () => {
|
||||
expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)");
|
||||
|
||||
@@ -6,6 +6,17 @@ type ProviderErrorPattern = { regex: RegExp; label: string };
|
||||
const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`;
|
||||
|
||||
const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [
|
||||
// auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error
|
||||
// payloads carry `x-ratelimit-*` response headers in the dump, and the
|
||||
// free-form rate-limit regex below would otherwise win on word-boundary
|
||||
// matches inside header names. canonical 401 messages: OpenRouter returns
|
||||
// `{"error":{"message":"User not found","code":401}}` for disabled or
|
||||
// invalid keys (https://openai.luzhipeng.com/docs/api/reference/errors-and-debugging).
|
||||
{ regex: new RegExp(`${statusKey}401\\b`, "i"), label: "auth error (401)" },
|
||||
{ regex: new RegExp(`${statusKey}403\\b`, "i"), label: "auth error (403)" },
|
||||
{ regex: /\bUser not found\b/i, label: "auth error (invalid/disabled key)" },
|
||||
{ regex: /\bInvalid authentication\b/i, label: "auth error (invalid credentials)" },
|
||||
{ regex: /\bNo auth credentials found\b/i, label: "auth error (missing credentials)" },
|
||||
{ regex: new RegExp(`${statusKey}429\\b`, "i"), label: "rate limited (429)" },
|
||||
{ regex: new RegExp(`${statusKey}500\\b`, "i"), label: "provider 500 error" },
|
||||
{ regex: new RegExp(`${statusKey}503\\b`, "i"), label: "provider unavailable (503)" },
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import type { AgentResult } from "../agents/shared.ts";
|
||||
import type { MainResult } from "../main.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { reportErrorToComment } from "./errorReport.ts";
|
||||
|
||||
|
||||
@@ -88,7 +88,6 @@ export async function fetchRunContext(params: {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (params.oidcToken) {
|
||||
headers["X-GitHub-OIDC-Token"] = params.oidcToken;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ShellPermission } from "../external.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import type { ToolState } from "../toolState.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import type { OctokitWithPlugins } from "./github.ts";
|
||||
import { isInsideDocker } from "./globals.ts";
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ export function installBundledSkills(params: { home: string }): void {
|
||||
writeFileSync(join(skillDir, "SKILL.md"), content);
|
||||
}
|
||||
}
|
||||
log.info(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
|
||||
log.success(`installed bundled skills: ${BUNDLED_SKILL_NAMES.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,7 +108,7 @@ export function addSkill(params: {
|
||||
}
|
||||
);
|
||||
if (result.status === 0) {
|
||||
log.info(`installed ${params.skill} skill (${params.agent})`);
|
||||
log.success(`installed ${params.skill} skill (${params.agent})`);
|
||||
} else {
|
||||
const stderr = (result.stderr?.toString() || "").trim();
|
||||
const errorMsg = result.error ? result.error.message : stderr;
|
||||
|
||||
+60
-33
@@ -1,6 +1,6 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { spawn } from "./subprocess.ts";
|
||||
import { spawn, TailBuffer } from "./subprocess.ts";
|
||||
|
||||
describe("spawn error path", () => {
|
||||
it("surfaces ENOENT-style spawn failures in stderr so callers can diagnose", async () => {
|
||||
@@ -79,48 +79,75 @@ describe("spawn error path", () => {
|
||||
expect(elapsed).toBeLessThan(10_000);
|
||||
}, 20_000);
|
||||
|
||||
it("isPausedExternally suspends the activity timer while it returns true", async () => {
|
||||
// opencode's `task` tool encapsulates subagent execution in-process —
|
||||
// subagent-internal events don't reach our parent NDJSON stream. this
|
||||
// means the child's stdout falls silent for the full subagent duration
|
||||
// even when real work is happening. opencode passes a predicate keyed
|
||||
// off its in-flight task dispatch tracker; while it returns true, the
|
||||
// activity timer must skip the kill decision and reset its baseline.
|
||||
it('retain:"tail" caps stderr at maxRetainedBytes and prepends a truncation sentinel', async () => {
|
||||
// regression for issue #680: unbounded `stderrBuffer += chunk` previously
|
||||
// crashed the wrapper with `RangeError: Invalid string length` once V8's
|
||||
// ~1 GiB kMaxLength was breached on long-lived agent runs. the fix caps
|
||||
// retention with a TailBuffer; this test exercises the cap end-to-end by
|
||||
// emitting ~2 MiB of stderr against a 256 KiB ceiling and asserts the
|
||||
// wrapper does not crash, the result is bounded, and the sentinel is
|
||||
// present so downstream consumers can detect the truncation.
|
||||
const result = await spawn({
|
||||
cmd: "bash",
|
||||
args: ["-c", "sleep 8; echo done"],
|
||||
// print ~2 MiB to stderr in 64 KiB chunks. `yes` + head gives us a
|
||||
// reliable byte budget that's well above the 256 KiB cap below.
|
||||
args: ["-c", "yes ABCDEFGH | head -c 2097152 1>&2"],
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
|
||||
activityTimeout: 1000,
|
||||
isPausedExternally: () => true,
|
||||
activityTimeout: 0,
|
||||
maxRetainedBytes: 256 * 1024,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toContain("done");
|
||||
}, 20_000);
|
||||
|
||||
it("isPausedExternally fires normally once it returns false again", async () => {
|
||||
// verifies suspend/resume rather than unconditional bypass: the predicate
|
||||
// flips to false after a few hundred ms, so the timer should resume from
|
||||
// a fresh baseline (advanced during the paused window) and fire after
|
||||
// activityTimeout idle time. critical that lastActivityTime is reset
|
||||
// while paused — otherwise the resume tick would see a stale baseline
|
||||
// and kill instantly.
|
||||
let paused = true;
|
||||
setTimeout(() => {
|
||||
paused = false;
|
||||
}, 500);
|
||||
expect(result.stderr).toMatch(/truncated by retain:tail cap/);
|
||||
expect(result.stderr.length).toBeLessThan(256 * 1024 + 200);
|
||||
}, 15_000);
|
||||
|
||||
it('retain:"none" returns empty stdout/stderr regardless of child output', async () => {
|
||||
// long-lived agent callers (opencode, claude) drain via onStdout/onStderr
|
||||
// and never read result.stdout/result.stderr — they pass retain:"none"
|
||||
// to skip the per-chunk concatenation entirely. assert that contract:
|
||||
// empty strings out, but onStdout still fires.
|
||||
const chunks: string[] = [];
|
||||
const result = await spawn({
|
||||
cmd: "sleep",
|
||||
args: ["30"],
|
||||
cmd: "bash",
|
||||
args: ["-c", "echo hello; echo world 1>&2"],
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
|
||||
activityTimeout: 1000,
|
||||
isPausedExternally: () => paused,
|
||||
}).catch((err) => err);
|
||||
activityTimeout: 0,
|
||||
retain: "none",
|
||||
onStdout: (chunk) => chunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Error);
|
||||
expect(String(result)).toMatch(/activity timeout/i);
|
||||
}, 20_000);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(chunks.join("")).toContain("hello");
|
||||
});
|
||||
|
||||
it('retain defaults to "tail" so short-lived callers keep failure-surfacing snapshots', async () => {
|
||||
// lock the default explicitly. gitAuth, package installs, and lifecycle
|
||||
// hooks all rely on `result.stderr` being non-empty on failure — flipping
|
||||
// the default to "none" would silently break their error messages while
|
||||
// all other tests in this file kept passing.
|
||||
const result = await spawn({
|
||||
cmd: "bash",
|
||||
args: ["-c", "echo -n diagnostic-output 1>&2; exit 7"],
|
||||
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "" },
|
||||
activityTimeout: 0,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.stderr).toBe("diagnostic-output");
|
||||
});
|
||||
|
||||
it("TailBuffer drops oldest bytes once the cap is exceeded", () => {
|
||||
const buf = new TailBuffer(10);
|
||||
buf.append("0123456789");
|
||||
expect(buf.toString()).toBe("0123456789");
|
||||
buf.append("abcde");
|
||||
// 0-9 plus abcde = 15 chars; cap is 10, so we keep the last 10 = "56789abcde"
|
||||
expect(buf.toString()).toMatch(/truncated by retain:tail cap/);
|
||||
expect(buf.toString()).toContain("56789abcde");
|
||||
});
|
||||
|
||||
it("reports signal-killed subprocesses as failures, not success", async () => {
|
||||
// regression: before the fix, `child.on("close", (exitCode) => ...)`
|
||||
|
||||
+111
-34
@@ -88,6 +88,29 @@ function installSignalHandler(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls what the wrapper retains in memory across the child's lifetime
|
||||
* for the post-hoc `SpawnResult.stdout` / `SpawnResult.stderr` snapshots.
|
||||
*
|
||||
* Streaming callbacks (`onStdout` / `onStderr`) fire regardless — `retain`
|
||||
* only governs the buffered snapshot returned in `SpawnResult`.
|
||||
*
|
||||
* - `"tail"` (default): keep the last `maxRetainedBytes` UTF-16 code units
|
||||
* of each stream. Once the cap is exceeded, oldest bytes are sliced off
|
||||
* and the result is prefixed with a `... [N MiB truncated] ...` sentinel.
|
||||
* Right default for short-lived commands whose failure mode is in their
|
||||
* final output (git errors, install failures, hook scripts).
|
||||
* - `"none"`: skip the buffer entirely. `SpawnResult.stdout` / `.stderr`
|
||||
* are empty strings. Use this for long-lived streaming agents that already
|
||||
* drain via `onStdout` / `onStderr` and never read the buffered snapshot.
|
||||
*
|
||||
* Default cap is 8 MiB — well below V8's ~1 GiB `kMaxLength` so `+= chunk`
|
||||
* can never throw `RangeError: Invalid string length`.
|
||||
*/
|
||||
export type RetainMode = "tail" | "none";
|
||||
|
||||
export const DEFAULT_MAX_RETAINED_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export interface SpawnOptions {
|
||||
cmd: string;
|
||||
args: string[];
|
||||
@@ -115,16 +138,46 @@ export interface SpawnOptions {
|
||||
// open, keeps emitting NDJSON, and `child.on("close")` never fires —
|
||||
// producing zombie runs that hang until the GitHub Actions job timeout).
|
||||
killGroup?: boolean;
|
||||
// optional pause predicate consulted on every activity check. when it
|
||||
// returns true the activity timer skips the kill decision *and* resets
|
||||
// its idle baseline so a clean unpause can't immediately fire on a stale
|
||||
// lastActivityTime. opencode uses this because its `task` tool encapsulates
|
||||
// subagent execution in-process — subagent-internal events don't surface
|
||||
// on the parent's NDJSON stream, so the local stdout-only signal would
|
||||
// falsely fire mid-subagent. preferred over fake-activity heartbeats
|
||||
// because there's no race window between a heartbeat tick and a subagent
|
||||
// that finishes between ticks.
|
||||
isPausedExternally?: () => boolean;
|
||||
retain?: RetainMode;
|
||||
maxRetainedBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded string accumulator that keeps the tail of appended chunks.
|
||||
* Once the cap is exceeded, oldest bytes are sliced off and `toString()`
|
||||
* prefixes the survivors with a sentinel describing the elided byte count.
|
||||
*
|
||||
* Exported because long-lived agent runtimes (opencode, claude) also
|
||||
* accumulate per-run narration strings independently of the spawn wrapper
|
||||
* and need the same protection against V8's `kMaxLength`.
|
||||
*/
|
||||
export class TailBuffer {
|
||||
// explicit field declarations rather than constructor parameter properties:
|
||||
// node's strip-only TS loader (used by action/test/run.ts in CI) rejects
|
||||
// `constructor(private readonly cap: number)` with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX.
|
||||
private readonly cap: number;
|
||||
private buffer = "";
|
||||
private truncatedBytes = 0;
|
||||
|
||||
constructor(cap: number) {
|
||||
this.cap = cap;
|
||||
}
|
||||
|
||||
append(chunk: string): void {
|
||||
if (this.cap <= 0) return;
|
||||
this.buffer += chunk;
|
||||
if (this.buffer.length > this.cap) {
|
||||
const drop = this.buffer.length - this.cap;
|
||||
this.truncatedBytes += drop;
|
||||
this.buffer = this.buffer.slice(drop);
|
||||
}
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if (this.truncatedBytes === 0) return this.buffer;
|
||||
const mib = (this.truncatedBytes / 1024 / 1024).toFixed(1);
|
||||
return `... [${mib} MiB truncated by retain:tail cap] ...\n${this.buffer}`;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpawnResult {
|
||||
@@ -143,8 +196,15 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
installSignalHandler();
|
||||
|
||||
const startTime = performance.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
// capped accumulators — unbounded `+= chunk` previously crashed the wrapper
|
||||
// with `RangeError: Invalid string length` once V8's ~1 GiB kMaxLength was
|
||||
// breached on long-lived agent subprocesses (e.g. multi-lens opencode
|
||||
// Reviews on large monorepos). retain:"none" skips the buffer entirely
|
||||
// for callers that already drain via onStdout/onStderr.
|
||||
const retain: RetainMode = options.retain ?? "tail";
|
||||
const cap = options.maxRetainedBytes ?? DEFAULT_MAX_RETAINED_BYTES;
|
||||
const stdoutBuffer = retain === "none" ? null : new TailBuffer(cap);
|
||||
const stderrBuffer = retain === "none" ? null : new TailBuffer(cap);
|
||||
|
||||
const killGroup = options.killGroup ?? false;
|
||||
|
||||
@@ -216,16 +276,6 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
`spawn activity timer: pid=${child.pid} cmd=${options.cmd} timeout=${activityTimeoutMs}ms`
|
||||
);
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
// when an external pause predicate says we're suspended (e.g.
|
||||
// opencode in a long-running task subagent whose internal events
|
||||
// don't surface on stdout), advance lastActivityTime to "now" so a
|
||||
// clean unpause doesn't immediately fire on a stale baseline, and
|
||||
// skip the kill decision for this tick.
|
||||
if (options.isPausedExternally?.()) {
|
||||
lastActivityTime = performance.now();
|
||||
log.debug(`spawn activity check: pid=${child.pid} paused externally`);
|
||||
return;
|
||||
}
|
||||
const idleMs = performance.now() - lastActivityTime;
|
||||
log.debug(
|
||||
`spawn activity check: pid=${child.pid} idle=${Math.round(idleMs)}ms / ${activityTimeoutMs}ms`
|
||||
@@ -254,20 +304,46 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
lastActivityTime = performance.now();
|
||||
}
|
||||
|
||||
// wrap handlers in try/catch as defense in depth for synchronous throws
|
||||
// inside the listener body. the historical `+= chunk` RangeError was such
|
||||
// a throw — synchronous and fatal under node's default uncaught-exception
|
||||
// policy. with the TailBuffer cap in place the wrapper-side `append` can
|
||||
// no longer throw, but the catch keeps protecting against any future
|
||||
// synchronous regression in this path.
|
||||
//
|
||||
// note: this does NOT catch rejections from async user callbacks —
|
||||
// `options.onStdout?.(chunk)` returns a Promise in the agent callers
|
||||
// (claude.ts, opencode.ts) and a throw inside an async callback surfaces
|
||||
// as an unhandled Promise rejection, not a synchronous exception. agent
|
||||
// callers handle their own NDJSON-parse failures internally; the
|
||||
// synchronous protection here is what matters for the RangeError class
|
||||
// of bugs (issue #680).
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data: Buffer) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
options.onStdout?.(chunk);
|
||||
try {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer?.append(chunk);
|
||||
options.onStdout?.(chunk);
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
`spawn stdout handler threw: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
options.onStderr?.(chunk);
|
||||
try {
|
||||
const chunk = data.toString();
|
||||
stderrBuffer?.append(chunk);
|
||||
options.onStderr?.(chunk);
|
||||
} catch (err) {
|
||||
log.debug(
|
||||
`spawn stderr handler threw: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +385,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
// appeared to succeed when they'd actually been killed — caller
|
||||
// checked `result.exitCode !== 0` and moved on.
|
||||
let resolvedExitCode = exitCode ?? 0;
|
||||
let resolvedStderr = stderrBuffer;
|
||||
let resolvedStderr = stderrBuffer?.toString() ?? "";
|
||||
if (exitCode === null && signal) {
|
||||
const killMsg = `[spawn] ${options.cmd}: killed by signal ${signal}`;
|
||||
resolvedStderr = resolvedStderr ? `${resolvedStderr}\n${killMsg}` : killMsg;
|
||||
@@ -317,7 +393,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: stdoutBuffer,
|
||||
stdout: stdoutBuffer?.toString() ?? "",
|
||||
stderr: resolvedStderr,
|
||||
exitCode: resolvedExitCode,
|
||||
durationMs,
|
||||
@@ -339,11 +415,12 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
// per the guidance, and hit the same wall every run.
|
||||
const errMsg = `[spawn] ${options.cmd}: ${error.message}`;
|
||||
console.error(errMsg);
|
||||
stderrBuffer = stderrBuffer ? `${stderrBuffer}\n${errMsg}` : errMsg;
|
||||
const existingStderr = stderrBuffer?.toString() ?? "";
|
||||
const finalStderr = existingStderr ? `${existingStderr}\n${errMsg}` : errMsg;
|
||||
|
||||
resolve({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
stdout: stdoutBuffer?.toString() ?? "",
|
||||
stderr: finalStderr,
|
||||
exitCode: 1,
|
||||
durationMs,
|
||||
});
|
||||
|
||||
@@ -203,5 +203,18 @@ describe("ThinkingTimer", () => {
|
||||
expect(cli.log.info).toHaveBeenNthCalledWith(1, "» thought for 4 seconds");
|
||||
expect(cli.log.info).toHaveBeenNthCalledWith(2, "» thought for 5 seconds");
|
||||
});
|
||||
|
||||
it("routes log lines through the optional formatLine for per-session prefixing", () => {
|
||||
const startTime = 1000000;
|
||||
vi.mocked(performance.now)
|
||||
.mockReturnValueOnce(startTime) // markToolResult
|
||||
.mockReturnValueOnce(startTime + 4000); // markToolCall
|
||||
|
||||
const timer = new ThinkingTimer((line) => `[lens:security] ${line}`);
|
||||
timer.markToolResult();
|
||||
timer.markToolCall();
|
||||
|
||||
expect(cli.log.info).toHaveBeenCalledWith("[lens:security] » thought for 4 seconds");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+23
-3
@@ -22,6 +22,15 @@ export class Timer {
|
||||
|
||||
const THINKING_THRESHOLD = 3000; // ms
|
||||
|
||||
/**
|
||||
* Measures wall-clock gap between the last tool_result and the next tool_call,
|
||||
* surfacing it as a "thought for Xs" log when over `THINKING_THRESHOLD`.
|
||||
*
|
||||
* Use one instance per logical session (orchestrator, each subagent) — sharing
|
||||
* a single timer across sessions conflates cross-session interleaving as
|
||||
* thinking time. The optional `formatLine` lets the caller prefix output with
|
||||
* a session label so attribution is visible in the merged log stream.
|
||||
*/
|
||||
export class ThinkingTimer {
|
||||
private readonly durationFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "unit",
|
||||
@@ -32,21 +41,32 @@ export class ThinkingTimer {
|
||||
});
|
||||
|
||||
private lastToolResultTimestamp: number | null = null;
|
||||
private readonly formatLine: (line: string) => string;
|
||||
|
||||
// node's native TS strip-only mode does not support parameter properties,
|
||||
// so the formatter is declared as a field and assigned in the body.
|
||||
constructor(formatLine: (line: string) => string = (l) => l) {
|
||||
this.formatLine = formatLine;
|
||||
}
|
||||
|
||||
markToolResult(): void {
|
||||
this.lastToolResultTimestamp = performance.now();
|
||||
log.debug(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`);
|
||||
log.debug(
|
||||
this.formatLine(`» thinking timer: markToolResult at ${this.lastToolResultTimestamp}`)
|
||||
);
|
||||
}
|
||||
|
||||
markToolCall(): void {
|
||||
const now = performance.now();
|
||||
log.debug(
|
||||
`» thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`
|
||||
this.formatLine(
|
||||
`» thinking timer: markToolCall at ${now}, lastToolResult=${this.lastToolResultTimestamp}`
|
||||
)
|
||||
);
|
||||
if (this.lastToolResultTimestamp === null) return;
|
||||
const elapsed = now - this.lastToolResultTimestamp;
|
||||
if (elapsed < THINKING_THRESHOLD) return;
|
||||
const seconds = elapsed / 1000;
|
||||
log.info(`» thought for ${this.durationFormatter.format(seconds)}`);
|
||||
log.info(this.formatLine(`» thought for ${this.durationFormatter.format(seconds)}`));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user