refactor delegation system, add PR summary comments, and improve code quality (#334)
* refactor delegation system and add PR summary comments Delegation system: - replace mode-based delegation with select_mode → delegate two-step flow - orchestrator crafts self-contained subagent prompts (clean context — no system/repo/event instructions leak) - add role-based tool filtering via FastMCP authenticate hook (?role=subagent hides orchestrator-only tools) - add select_mode tool for orchestrator guidance per mode - add ask_question tool for lightweight research subagents - extract shared subagent lifecycle into subagent.ts (create, complete, stdout, instructions) - route set_output to per-subagent state when activeSubagentId is set - track per-subagent state (SubagentState Map) replacing boolean delegationActive flag - capture and aggregate AgentUsage across all agents (claude, codex, gemini, opencode) - write usage summary table to GitHub job summary - block built-in subagent spawning (Task for Claude, Task(*) for Cursor) - increase activity timeout from 60s to 300s (subagent thinking phases) - fix gh CLI misguidance in system prompt — explicitly forbid usage PR summary comments: - add prSummaryComment trigger (DB schema + migrations + Zod + UI toggle) - dispatch mini-effort summary job alongside PR review on pr.created - add update_pull_request_body MCP tool - add defaultEffort option to webhook dispatch Hardening: - rewrite delegate/selectMode tests with simulated state management - add toolFiltering.test.ts for role extraction, canAccess, set_output routing - remove non-null assertions for PULLFROG_TEMP_DIR (proper error throws) - use fetchWithRetry for direct tarball downloads - DRY fix for rate limit check in test runner Co-authored-by: Cursor <cursoragent@cursor.com> * fix: add type keyword to Effort import in handleWebhook.ts Co-authored-by: Cursor <cursoragent@cursor.com> * clean up delegation system, improve code quality across the codebase - simplify delegate tool to instructions + effort params with subagent lifecycle in subagent.ts - add select_mode and ask_question orchestrator-only tools with canAccess filtering - replace delegate.test.ts/selectMode.test.ts with toolFiltering.test.ts (live MCP integration) - add set_output routing for subagent context and AgentUsage tracking across all agents - add PR summary comment trigger (schema, UI, webhook dispatch with silent flag) - add update_pull_request_body MCP tool - fix changed-agents.sh to always include claude canary for non-agent action changes - fix cursor pagination bug in getSelectedInstallationReposPage - remove destructuring patterns, inline type definitions, and unsafe type casts - replace non-null assertions with explicit checks in install.ts - convert multi-param functions to single param objects (postCleanup, runActionLocal, etc.) - use isHttpError helper in API routes instead of catch-any patterns - add adhoc test fixtures for delegation scenarios (context isolation, error handling, synthesis, etc.) Co-authored-by: Cursor <cursoragent@cursor.com> * no subagent mutation, one mcp per subagent * address review feedback: parallel-safe usage tracking, subagent isolation, minor improvements * fix subagent state isolation: replace Object.freeze with shallow copy Object.freeze throws TypeErrors when subagent tools (checkout_pr, report_progress) write scalar properties to toolState. A shallow copy achieves the same isolation for scalar fields while allowing tools to work normally. Shared references (subagents Map, usageEntries array) remain shared for coordination. --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: pullfrog[bot] <226033991+pullfrog[bot]@users.noreply.github.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
a90743e9fe
commit
cfd38d82fc
+31
-14
@@ -12,7 +12,7 @@ import { log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import { type AgentRunContext, agent } from "./shared.ts";
|
||||
import { type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
// model selection based on effort level
|
||||
// these are aliases that always resolve to the latest version
|
||||
@@ -40,10 +40,11 @@ function buildDisallowedTools(ctx: AgentRunContext): string[] {
|
||||
// both "disabled" and "restricted" block native bash
|
||||
// "restricted" means use MCP bash tool instead
|
||||
const bash = ctx.payload.bash;
|
||||
if (bash !== "enabled") disallowed.push("Bash", "Task(Bash)");
|
||||
if (bash !== "enabled") disallowed.push("Bash");
|
||||
// always block native file tools (use MCP file_read/file_write instead)
|
||||
disallowed.push("Read", "Write", "Edit", "MultiEdit");
|
||||
disallowed.push("Task(Read)", "Task(Write)", "Task(Edit)", "Task(MultiEdit)");
|
||||
// block built-in subagent spawning — delegation is handled by gh_pullfrog/delegate
|
||||
disallowed.push("Task");
|
||||
return disallowed;
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ export const claude = agent({
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let finalOutput = "";
|
||||
const usageContainer: UsageContainer = { value: null };
|
||||
|
||||
// Track bash tool IDs to identify when bash tool results come back
|
||||
const bashToolIds = new Set<string>();
|
||||
@@ -139,7 +141,7 @@ export const claude = agent({
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
||||
onStdout: async (chunk) => {
|
||||
finalOutput += chunk;
|
||||
markActivity(); // reset activity timeout on any CLI output
|
||||
@@ -162,7 +164,7 @@ export const claude = agent({
|
||||
|
||||
const handler = messageHandlers[message.type];
|
||||
if (handler) {
|
||||
await handler(message as never, bashToolIds, thinkingTimer);
|
||||
await handler(message as never, bashToolIds, thinkingTimer, usageContainer);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors - might be non-JSON output
|
||||
@@ -190,6 +192,7 @@ export const claude = agent({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || result.stdout || "",
|
||||
usage: usageContainer.value ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,16 +201,21 @@ export const claude = agent({
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput || result.stdout || "",
|
||||
usage: usageContainer.value ?? undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// run-local usage container — passed to handlers via closure for parallel-safe runs
|
||||
type UsageContainer = { value: AgentUsage | null };
|
||||
|
||||
type SDKMessageType = SDKMessage["type"];
|
||||
|
||||
type SDKMessageHandler<type extends SDKMessageType = SDKMessageType> = (
|
||||
data: Extract<SDKMessage, { type: type }>,
|
||||
bashToolIds: Set<string>,
|
||||
thinkingTimer: ThinkingTimer
|
||||
thinkingTimer: ThinkingTimer,
|
||||
usageContainer: UsageContainer
|
||||
) => void | Promise<void>;
|
||||
|
||||
type SDKMessageHandlers = {
|
||||
@@ -215,7 +223,7 @@ type SDKMessageHandlers = {
|
||||
};
|
||||
|
||||
const messageHandlers: SDKMessageHandlers = {
|
||||
assistant: (data, bashToolIds, thinkingTimer) => {
|
||||
assistant: (data, bashToolIds, thinkingTimer, _usageContainer) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (content.type === "text" && content.text?.trim()) {
|
||||
@@ -235,7 +243,7 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
}
|
||||
}
|
||||
},
|
||||
user: (data, bashToolIds, thinkingTimer) => {
|
||||
user: (data, bashToolIds, thinkingTimer, _usageContainer) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (typeof content === "string") {
|
||||
@@ -283,7 +291,7 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
}
|
||||
}
|
||||
},
|
||||
result: async (data) => {
|
||||
result: async (data, _bashToolIds, _thinkingTimer, usageContainer) => {
|
||||
if (data.subtype === "success") {
|
||||
const usage = data.usage;
|
||||
const inputTokens = usage?.input_tokens || 0;
|
||||
@@ -292,6 +300,15 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
const outputTokens = usage?.output_tokens || 0;
|
||||
const totalInput = inputTokens + cacheRead + cacheWrite;
|
||||
|
||||
usageContainer.value = {
|
||||
agent: "claude",
|
||||
inputTokens: totalInput,
|
||||
outputTokens,
|
||||
cacheReadTokens: cacheRead,
|
||||
cacheWriteTokens: cacheWrite,
|
||||
costUsd: data.total_cost_usd ?? undefined,
|
||||
};
|
||||
|
||||
log.table([
|
||||
[
|
||||
{ data: "Cost", header: true },
|
||||
@@ -316,9 +333,9 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
log.info(`Failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
},
|
||||
system: () => {},
|
||||
stream_event: () => {},
|
||||
tool_progress: () => {},
|
||||
tool_use_summary: () => {},
|
||||
auth_status: () => {},
|
||||
system: (_data, _bashToolIds, _thinkingTimer, _usageContainer) => {},
|
||||
stream_event: (_data, _bashToolIds, _thinkingTimer, _usageContainer) => {},
|
||||
tool_progress: (_data, _bashToolIds, _thinkingTimer, _usageContainer) => {},
|
||||
tool_use_summary: (_data, _bashToolIds, _thinkingTimer, _usageContainer) => {},
|
||||
auth_status: (_data, _bashToolIds, _thinkingTimer, _usageContainer) => {},
|
||||
};
|
||||
|
||||
+123
-95
@@ -12,7 +12,7 @@ import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { filterEnv } from "../utils/secrets.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import { type AgentRunContext, agent } from "./shared.ts";
|
||||
import { type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
// pinned CLI version — no 1-1 package.json dependency for the CLI package
|
||||
// (package.json has @openai/codex-sdk which is the SDK, not the CLI)
|
||||
@@ -200,6 +200,8 @@ export const codex = agent({
|
||||
`» Codex options: sandboxMode=${sandboxMode}, networkAccess=${networkAccessEnabled}, webSearch=${webSearchEnabled}`
|
||||
);
|
||||
log.info("» running Codex CLI...");
|
||||
const runState: CodexRunState = { usage: null };
|
||||
const messageHandlers = createMessageHandlers();
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let finalOutput = "";
|
||||
@@ -228,7 +230,7 @@ export const codex = agent({
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
||||
onStdout: async (chunk) => {
|
||||
finalOutput += chunk;
|
||||
markActivity(); // reset activity timeout on any CLI output
|
||||
@@ -251,7 +253,7 @@ export const codex = agent({
|
||||
|
||||
const handler = messageHandlers[event.type as keyof typeof messageHandlers];
|
||||
if (handler) {
|
||||
await handler(event as never, commandExecutionIds, thinkingTimer);
|
||||
await handler(event as never, commandExecutionIds, thinkingTimer, runState);
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors - might be non-JSON output
|
||||
@@ -276,6 +278,7 @@ export const codex = agent({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || result.stdout || "",
|
||||
usage: runState.usage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -284,109 +287,134 @@ export const codex = agent({
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput || result.stdout || "",
|
||||
usage: runState.usage ?? undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// run-local usage accumulator — passed to handlers via closure for parallel-safe runs.
|
||||
// codex fires turn.completed per-turn (not once at the end like claude/gemini),
|
||||
// so we must accumulate rather than overwrite.
|
||||
type CodexRunState = { usage: AgentUsage | null };
|
||||
|
||||
type ThreadEventHandler<type extends ThreadEvent["type"]> = (
|
||||
event: Extract<ThreadEvent, { type: type }>,
|
||||
commandExecutionIds: Set<string>,
|
||||
thinkingTimer: ThinkingTimer
|
||||
thinkingTimer: ThinkingTimer,
|
||||
runState: CodexRunState
|
||||
) => void | Promise<void>;
|
||||
|
||||
const messageHandlers: {
|
||||
function createMessageHandlers(): {
|
||||
[type in ThreadEvent["type"]]: ThreadEventHandler<type>;
|
||||
} = {
|
||||
"thread.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.completed": async (event) => {
|
||||
log.table([
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Cached Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
[
|
||||
String(event.usage.input_tokens || 0),
|
||||
String(event.usage.cached_input_tokens || 0),
|
||||
String(event.usage.output_tokens || 0),
|
||||
],
|
||||
]);
|
||||
},
|
||||
"turn.failed": (event) => {
|
||||
log.info(`Turn failed: ${event.error.message}`);
|
||||
},
|
||||
"item.started": (event, commandExecutionIds, thinkingTimer) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
commandExecutionIds.add(item.id);
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: item.command,
|
||||
input: (item as any).args || {},
|
||||
});
|
||||
} else if (item.type === "agent_message") {
|
||||
// Will be handled on completion
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: item.tool,
|
||||
input: {
|
||||
server: item.server,
|
||||
...((item as any).arguments || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Reasoning items are handled on completion for better readability
|
||||
},
|
||||
"item.updated": (event) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
if (item.status === "in_progress" && item.aggregated_output) {
|
||||
// Command is still running, could show progress if needed
|
||||
} {
|
||||
return {
|
||||
"thread.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.started": () => {
|
||||
// No logging needed
|
||||
},
|
||||
"turn.completed": async (event, _commandExecutionIds, _thinkingTimer, runState) => {
|
||||
const inputTokens = event.usage.input_tokens ?? 0;
|
||||
const cachedInputTokens = event.usage.cached_input_tokens ?? 0;
|
||||
const outputTokens = event.usage.output_tokens ?? 0;
|
||||
|
||||
// accumulate across turns (codex fires turn.completed per-turn, not once at end).
|
||||
// note: openai's input_tokens already includes cached tokens (unlike claude's API),
|
||||
// so we do not add cachedInputTokens to inputTokens — that would double-count.
|
||||
if (runState.usage) {
|
||||
runState.usage.inputTokens += inputTokens;
|
||||
runState.usage.outputTokens += outputTokens;
|
||||
runState.usage.cacheReadTokens = (runState.usage.cacheReadTokens ?? 0) + cachedInputTokens;
|
||||
} else {
|
||||
runState.usage = {
|
||||
agent: "codex",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens: cachedInputTokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
"item.completed": (event, commandExecutionIds, thinkingTimer) => {
|
||||
const item = event.item;
|
||||
if (item.type === "agent_message") {
|
||||
log.box(item.text.trim(), { title: "Codex" });
|
||||
} else if (item.type === "command_execution") {
|
||||
const isTracked = commandExecutionIds.has(item.id);
|
||||
if (isTracked) {
|
||||
thinkingTimer.markToolResult();
|
||||
log.startGroup(`bash output`);
|
||||
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
|
||||
log.info(item.aggregated_output || "Command failed");
|
||||
} else {
|
||||
log.info(item.aggregated_output || "");
|
||||
|
||||
log.table([
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Cached Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
],
|
||||
[String(inputTokens), String(cachedInputTokens), String(outputTokens)],
|
||||
]);
|
||||
},
|
||||
"turn.failed": (event) => {
|
||||
log.info(`Turn failed: ${event.error.message}`);
|
||||
},
|
||||
"item.started": (event, commandExecutionIds, thinkingTimer) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
commandExecutionIds.add(item.id);
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: item.command,
|
||||
input: (item as any).args || {},
|
||||
});
|
||||
} else if (item.type === "agent_message") {
|
||||
// Will be handled on completion
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: item.tool,
|
||||
input: {
|
||||
server: item.server,
|
||||
...((item as any).arguments || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
// Reasoning items are handled on completion for better readability
|
||||
},
|
||||
"item.updated": (event) => {
|
||||
const item = event.item;
|
||||
if (item.type === "command_execution") {
|
||||
if (item.status === "in_progress" && item.aggregated_output) {
|
||||
// Command is still running, could show progress if needed
|
||||
}
|
||||
log.endGroup();
|
||||
commandExecutionIds.delete(item.id);
|
||||
}
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
thinkingTimer.markToolResult();
|
||||
if (item.status === "failed" && item.error) {
|
||||
log.info(`MCP tool call failed: ${item.error.message}`);
|
||||
} else if ((item as any).output) {
|
||||
// log successful MCP tool call output so it appears in captured output
|
||||
const output = (item as any).output;
|
||||
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
||||
log.debug(`tool output: ${outputStr}`);
|
||||
},
|
||||
"item.completed": (event, commandExecutionIds, thinkingTimer) => {
|
||||
const item = event.item;
|
||||
if (item.type === "agent_message") {
|
||||
log.box(item.text.trim(), { title: "Codex" });
|
||||
} else if (item.type === "command_execution") {
|
||||
const isTracked = commandExecutionIds.has(item.id);
|
||||
if (isTracked) {
|
||||
thinkingTimer.markToolResult();
|
||||
log.startGroup(`bash output`);
|
||||
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
|
||||
log.info(item.aggregated_output || "Command failed");
|
||||
} else {
|
||||
log.info(item.aggregated_output || "");
|
||||
}
|
||||
log.endGroup();
|
||||
commandExecutionIds.delete(item.id);
|
||||
}
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
thinkingTimer.markToolResult();
|
||||
if (item.status === "failed" && item.error) {
|
||||
log.info(`MCP tool call failed: ${item.error.message}`);
|
||||
} else if ((item as any).output) {
|
||||
// log successful MCP tool call output so it appears in captured output
|
||||
const output = (item as any).output;
|
||||
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
||||
log.debug(`tool output: ${outputStr}`);
|
||||
}
|
||||
} else if (item.type === "reasoning") {
|
||||
// Display reasoning in a human-readable format
|
||||
const reasoningText = item.text.trim();
|
||||
// Remove markdown bold markers if present for cleaner output
|
||||
const cleanText = reasoningText.replace(/\*\*/g, "");
|
||||
log.box(cleanText, { title: "Codex" });
|
||||
}
|
||||
} else if (item.type === "reasoning") {
|
||||
// Display reasoning in a human-readable format
|
||||
const reasoningText = item.text.trim();
|
||||
// Remove markdown bold markers if present for cleaner output
|
||||
const cleanText = reasoningText.replace(/\*\*/g, "");
|
||||
log.box(cleanText, { title: "Codex" });
|
||||
}
|
||||
},
|
||||
error: (event) => {
|
||||
log.info(`Error: ${event.message}`);
|
||||
},
|
||||
};
|
||||
},
|
||||
error: (event) => {
|
||||
log.info(`Error: ${event.message}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -421,6 +421,8 @@ function configureCursorTools(ctx: AgentRunContext): void {
|
||||
if (bash !== "enabled") deny.push("Shell(*)");
|
||||
// always block native file tools (use MCP file_read/file_write instead)
|
||||
deny.push("Read(*)", "Write(*)", "StrReplace(*)", "EditNotebook(*)", "Delete(*)");
|
||||
// block built-in subagent spawning — delegation is handled by gh_pullfrog/delegate
|
||||
deny.push("Task(*)");
|
||||
|
||||
const config: CursorCliConfig = {
|
||||
permissions: {
|
||||
|
||||
+107
-86
@@ -12,7 +12,7 @@ import { installFromGithub } from "../utils/install.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import { getGitHubInstallationToken } from "../utils/token.ts";
|
||||
import { type AgentRunContext, agent } from "./shared.ts";
|
||||
import { type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
// effort configuration: model + thinking level
|
||||
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
||||
@@ -105,91 +105,108 @@ function isTransientApiError(output: string): boolean {
|
||||
const MAX_ATTEMPTS = 2;
|
||||
const RETRY_DELAY_MS = 5_000;
|
||||
|
||||
let assistantMessageBuffer = "";
|
||||
|
||||
const messageHandlers = {
|
||||
init: (_event: GeminiInitEvent) => {
|
||||
log.debug(JSON.stringify(_event, null, 2));
|
||||
// initialization event - no logging needed
|
||||
assistantMessageBuffer = "";
|
||||
},
|
||||
message: (event: GeminiMessageEvent) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
if (event.role === "assistant" && event.content?.trim()) {
|
||||
if (event.delta) {
|
||||
// accumulate delta messages
|
||||
assistantMessageBuffer += event.content;
|
||||
} else {
|
||||
// final message - log it
|
||||
const message = event.content.trim();
|
||||
if (message) {
|
||||
log.box(message, { title: "Gemini" });
|
||||
}
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
} else if (event.role === "assistant" && !event.delta && assistantMessageBuffer.trim()) {
|
||||
// if we have buffered content and get a non-delta message, log the buffer
|
||||
log.box(assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
},
|
||||
tool_use: (event: GeminiToolUseEvent, thinkingTimer: ThinkingTimer) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
if (event.tool_name) {
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: event.tool_name,
|
||||
input: event.parameters || {},
|
||||
});
|
||||
}
|
||||
},
|
||||
tool_result: (event: GeminiToolResultEvent, thinkingTimer: ThinkingTimer) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
thinkingTimer.markToolResult();
|
||||
if (event.status === "error") {
|
||||
const errorMsg =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.info(`Tool call failed: ${errorMsg}`);
|
||||
} else if (event.output) {
|
||||
// log successful tool result so it appears in output
|
||||
const outputStr =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.debug(`tool output: ${outputStr}`);
|
||||
}
|
||||
},
|
||||
result: async (event: GeminiResultEvent) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
// log any remaining buffered assistant message
|
||||
if (assistantMessageBuffer.trim()) {
|
||||
log.box(assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
assistantMessageBuffer = "";
|
||||
}
|
||||
|
||||
if (event.status === "success" && event.stats) {
|
||||
const stats = event.stats;
|
||||
const rows: Array<Array<{ data: string; header?: boolean } | string>> = [
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
{ data: "Total Tokens", header: true },
|
||||
{ data: "Tool Calls", header: true },
|
||||
{ data: "Duration (ms)", header: true },
|
||||
],
|
||||
[
|
||||
String(stats.input_tokens || 0),
|
||||
String(stats.output_tokens || 0),
|
||||
String(stats.total_tokens || 0),
|
||||
String(stats.tool_calls || 0),
|
||||
String(stats.duration_ms || 0),
|
||||
],
|
||||
];
|
||||
log.table(rows);
|
||||
} else if (event.status === "error") {
|
||||
log.error(`Gemini CLI failed: ${JSON.stringify(event)}`);
|
||||
}
|
||||
},
|
||||
// run-local state container — passed to handlers via closure for parallel-safe runs
|
||||
type GeminiRunState = {
|
||||
assistantMessageBuffer: string;
|
||||
usage: AgentUsage | null;
|
||||
};
|
||||
|
||||
function createMessageHandlers(runState: GeminiRunState) {
|
||||
return {
|
||||
init: (_event: GeminiInitEvent) => {
|
||||
log.debug(JSON.stringify(_event, null, 2));
|
||||
// initialization event - no logging needed
|
||||
runState.assistantMessageBuffer = "";
|
||||
},
|
||||
message: (event: GeminiMessageEvent) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
if (event.role === "assistant" && event.content?.trim()) {
|
||||
if (event.delta) {
|
||||
// accumulate delta messages
|
||||
runState.assistantMessageBuffer += event.content;
|
||||
} else {
|
||||
// final message - log it
|
||||
const message = event.content.trim();
|
||||
if (message) {
|
||||
log.box(message, { title: "Gemini" });
|
||||
}
|
||||
runState.assistantMessageBuffer = "";
|
||||
}
|
||||
} else if (
|
||||
event.role === "assistant" &&
|
||||
!event.delta &&
|
||||
runState.assistantMessageBuffer.trim()
|
||||
) {
|
||||
// if we have buffered content and get a non-delta message, log the buffer
|
||||
log.box(runState.assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
runState.assistantMessageBuffer = "";
|
||||
}
|
||||
},
|
||||
tool_use: (event: GeminiToolUseEvent, thinkingTimer: ThinkingTimer) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
if (event.tool_name) {
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName: event.tool_name,
|
||||
input: event.parameters || {},
|
||||
});
|
||||
}
|
||||
},
|
||||
tool_result: (event: GeminiToolResultEvent, thinkingTimer: ThinkingTimer) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
thinkingTimer.markToolResult();
|
||||
if (event.status === "error") {
|
||||
const errorMsg =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.info(`Tool call failed: ${errorMsg}`);
|
||||
} else if (event.output) {
|
||||
// log successful tool result so it appears in output
|
||||
const outputStr =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.debug(`tool output: ${outputStr}`);
|
||||
}
|
||||
},
|
||||
result: async (event: GeminiResultEvent) => {
|
||||
log.debug(JSON.stringify(event, null, 2));
|
||||
// log any remaining buffered assistant message
|
||||
if (runState.assistantMessageBuffer.trim()) {
|
||||
log.box(runState.assistantMessageBuffer.trim(), { title: "Gemini" });
|
||||
runState.assistantMessageBuffer = "";
|
||||
}
|
||||
|
||||
if (event.status === "success" && event.stats) {
|
||||
const stats = event.stats;
|
||||
|
||||
runState.usage = {
|
||||
agent: "gemini",
|
||||
inputTokens: stats.input_tokens ?? 0,
|
||||
outputTokens: stats.output_tokens ?? 0,
|
||||
};
|
||||
|
||||
const rows: Array<Array<{ data: string; header?: boolean } | string>> = [
|
||||
[
|
||||
{ data: "Input Tokens", header: true },
|
||||
{ data: "Output Tokens", header: true },
|
||||
{ data: "Total Tokens", header: true },
|
||||
{ data: "Tool Calls", header: true },
|
||||
{ data: "Duration (ms)", header: true },
|
||||
],
|
||||
[
|
||||
String(stats.input_tokens || 0),
|
||||
String(stats.output_tokens || 0),
|
||||
String(stats.total_tokens || 0),
|
||||
String(stats.tool_calls || 0),
|
||||
String(stats.duration_ms || 0),
|
||||
],
|
||||
];
|
||||
log.table(rows);
|
||||
} else if (event.status === "error") {
|
||||
log.error(`Gemini CLI failed: ${JSON.stringify(event)}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function installGemini(githubInstallationToken?: string): Promise<string> {
|
||||
return await installFromGithub({
|
||||
owner: "google-gemini",
|
||||
@@ -227,7 +244,8 @@ export const gemini = agent({
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
let finalOutput = "";
|
||||
let stdoutBuffer = "";
|
||||
assistantMessageBuffer = "";
|
||||
const runState: GeminiRunState = { assistantMessageBuffer: "", usage: null };
|
||||
const messageHandlers = createMessageHandlers(runState);
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
|
||||
try {
|
||||
@@ -235,7 +253,7 @@ export const gemini = agent({
|
||||
cmd: "node",
|
||||
args: [cliPath, ...args],
|
||||
env: process.env,
|
||||
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
finalOutput += text;
|
||||
@@ -297,6 +315,7 @@ export const gemini = agent({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || result.stdout || "",
|
||||
usage: runState.usage ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,6 +325,7 @@ export const gemini = agent({
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput,
|
||||
usage: runState.usage ?? undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
@@ -324,6 +344,7 @@ export const gemini = agent({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
output: finalOutput || "",
|
||||
usage: runState.usage ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { gemini } from "./gemini.ts";
|
||||
import { opencode } from "./opencode.ts";
|
||||
import type { Agent } from "./shared.ts";
|
||||
|
||||
export type { Agent } from "./shared.ts";
|
||||
export type { Agent, AgentUsage } from "./shared.ts";
|
||||
|
||||
export const agents = {
|
||||
claude,
|
||||
|
||||
+25
-3
@@ -10,7 +10,7 @@ import { log } from "../utils/cli.ts";
|
||||
import { installFromNpmTarball } from "../utils/install.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
import { ThinkingTimer } from "../utils/timer.ts";
|
||||
import { type AgentRunContext, agent } from "./shared.ts";
|
||||
import { type AgentRunContext, type AgentUsage, agent } from "./shared.ts";
|
||||
|
||||
// pinned CLI version — no 1-1 package.json dependency for the CLI package
|
||||
// (package.json has @opencode-ai/sdk which is the SDK, not the CLI)
|
||||
@@ -104,6 +104,13 @@ export const opencode = agent({
|
||||
let eventCount = 0;
|
||||
const thinkingTimer = new ThinkingTimer();
|
||||
|
||||
// reset module-level state before each run (same pattern as claude/codex/gemini).
|
||||
// without this, a failed subprocess that never emits an init event would
|
||||
// carry stale token counts or output from a prior delegation run.
|
||||
finalOutput = "";
|
||||
accumulatedTokens = { input: 0, output: 0 };
|
||||
tokensLogged = false;
|
||||
|
||||
// track recent stderr lines for provider error diagnosis.
|
||||
// when OpenCode goes silent on stdout, these are the only clue.
|
||||
const recentStderr: string[] = [];
|
||||
@@ -119,8 +126,7 @@ export const opencode = agent({
|
||||
args,
|
||||
cwd: repoDir,
|
||||
env,
|
||||
timeout: 600000, // 10 minutes timeout to prevent infinite hangs
|
||||
activityTimeout: 0, // disabled: process-level timeout in main.ts handles this (subprocess timeout would kill orchestrator during delegation)
|
||||
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
onStdout: async (chunk) => {
|
||||
const text = chunk.toString();
|
||||
@@ -226,6 +232,8 @@ export const opencode = agent({
|
||||
]);
|
||||
}
|
||||
|
||||
const usage = buildOpenCodeUsage();
|
||||
|
||||
// return result
|
||||
if (result.exitCode !== 0) {
|
||||
const errorContext = lastProviderError ? ` (${lastProviderError})` : "";
|
||||
@@ -242,12 +250,14 @@ export const opencode = agent({
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: errorMessage,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: finalOutput || output,
|
||||
usage,
|
||||
};
|
||||
} catch (error) {
|
||||
// activity timeout or process timeout - surface the real cause
|
||||
@@ -277,6 +287,7 @@ export const opencode = agent({
|
||||
success: false,
|
||||
output: finalOutput || output,
|
||||
error: `${errorMessage} [${diagnosis}]`,
|
||||
usage: buildOpenCodeUsage(),
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -474,6 +485,17 @@ type OpenCodeEvent =
|
||||
let finalOutput = "";
|
||||
let accumulatedTokens: { input: number; output: number } = { input: 0, output: 0 };
|
||||
let tokensLogged = false;
|
||||
|
||||
function buildOpenCodeUsage(): AgentUsage | undefined {
|
||||
return accumulatedTokens.input > 0 || accumulatedTokens.output > 0
|
||||
? {
|
||||
agent: "opencode",
|
||||
inputTokens: accumulatedTokens.input,
|
||||
outputTokens: accumulatedTokens.output,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const toolCallTimings = new Map<string, number>();
|
||||
let currentStepId: string | null = null;
|
||||
let currentStepType: string | null = null;
|
||||
|
||||
@@ -4,6 +4,18 @@ import { log } from "../utils/cli.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import type { ResolvedPayload } from "../utils/payload.ts";
|
||||
|
||||
/**
|
||||
* token/cost usage data from a single agent run
|
||||
*/
|
||||
export interface AgentUsage {
|
||||
agent: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens?: number | undefined;
|
||||
cacheWriteTokens?: number | undefined;
|
||||
costUsd?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned by agent execution
|
||||
*/
|
||||
@@ -12,6 +24,7 @@ export interface AgentResult {
|
||||
output?: string | undefined;
|
||||
error?: string | undefined;
|
||||
metadata?: Record<string, unknown>;
|
||||
usage?: AgentUsage | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
||||
import { initToolState, startMcpHttpServer } from "./mcp/server.ts";
|
||||
import { initToolState, startMcpHttpServer, type ToolState } from "./mcp/server.ts";
|
||||
import { computeModes } from "./modes.ts";
|
||||
import {
|
||||
type ActivityTimeout,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { resolveAgent } from "./utils/agent.ts";
|
||||
import { validateAgentApiKey } from "./utils/apiKeys.ts";
|
||||
import { resolveBody } from "./utils/body.ts";
|
||||
import { log, writeSummary } from "./utils/cli.ts";
|
||||
import { formatUsageSummary, log, writeSummary } from "./utils/cli.ts";
|
||||
import { reportErrorToComment } from "./utils/errorReport.ts";
|
||||
import { resolveGit } from "./utils/gitAuth.ts";
|
||||
import { createOctokit } from "./utils/github.ts";
|
||||
@@ -36,6 +36,14 @@ export interface MainResult {
|
||||
result?: string | undefined;
|
||||
}
|
||||
|
||||
async function writeJobSummary(toolState: ToolState): Promise<void> {
|
||||
const usageSummary = formatUsageSummary(toolState.usageEntries);
|
||||
const summaryParts = [toolState.lastProgressBody, usageSummary].filter(Boolean);
|
||||
if (summaryParts.length > 0) {
|
||||
await writeSummary(summaryParts.join("\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function main(): Promise<MainResult> {
|
||||
// normalize env var names to uppercase (handles case-insensitive workflow files)
|
||||
normalizeEnv();
|
||||
@@ -216,11 +224,13 @@ export async function main(): Promise<MainResult> {
|
||||
}
|
||||
}
|
||||
|
||||
// write last progress body to job summary
|
||||
if (toolState.lastProgressBody) {
|
||||
await writeSummary(toolState.lastProgressBody);
|
||||
// accumulate top-level agent usage
|
||||
if (result.usage) {
|
||||
toolState.usageEntries.push(result.usage);
|
||||
}
|
||||
|
||||
await writeJobSummary(toolState);
|
||||
|
||||
// emit structured output marker for test validation
|
||||
if (toolState.output) {
|
||||
log.info(`::pullfrog-output::${Buffer.from(toolState.output).toString("base64")}`);
|
||||
@@ -234,6 +244,12 @@ export async function main(): Promise<MainResult> {
|
||||
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
|
||||
killTrackedChildren();
|
||||
log.error(errorMessage);
|
||||
|
||||
// best-effort summary — don't mask the original error
|
||||
try {
|
||||
await writeJobSummary(toolState);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await reportErrorToComment({ toolState, error: errorMessage });
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type } from "arktype";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
import { createSubagentState, runSubagent } from "./subagent.ts";
|
||||
|
||||
export const AskQuestionParams = type({
|
||||
question: type.string.describe(
|
||||
"the question to answer about the codebase, architecture, or implementation details"
|
||||
),
|
||||
});
|
||||
|
||||
function buildQuestionPrompt(question: string): string {
|
||||
return `You are a focused research subagent. Answer the following question by exploring the codebase using the available MCP tools (${ghPullfrogMcpName}/file_read, ${ghPullfrogMcpName}/list_directory, etc.).
|
||||
|
||||
Be thorough in your investigation but concise in your answer. When done, call ${ghPullfrogMcpName}/set_output with a maximally concise answer — key facts only, no filler, no preamble.
|
||||
|
||||
Question: ${question}`;
|
||||
}
|
||||
|
||||
export function AskQuestionTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "ask_question",
|
||||
description:
|
||||
"Ask a question about the codebase and get a concise answer from a lightweight research subagent. The intermediate exploration context stays in the subagent — only the concise answer returns to you.",
|
||||
parameters: AskQuestionParams,
|
||||
execute: execute(async (params) => {
|
||||
if (ctx.toolState.activeSubagentId) {
|
||||
return { error: "cannot ask questions while a subagent is already running" };
|
||||
}
|
||||
|
||||
const subagent = createSubagentState({ ctx, mode: "ask_question" });
|
||||
log.info(`» ask_question subagent=${subagent.id}: ${params.question.slice(0, 100)}`);
|
||||
|
||||
const result = await runSubagent({
|
||||
ctx,
|
||||
subagent,
|
||||
effort: "mini",
|
||||
instructions: buildQuestionPrompt(params.question),
|
||||
});
|
||||
log.info(`» ask_question completed (success=${result.success})`);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
answer:
|
||||
subagent.output ??
|
||||
result.error ??
|
||||
"no answer produced — the subagent may not have called set_output. check stdoutFile for details.",
|
||||
stdoutFile: subagent.stdoutFilePath,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -169,6 +169,12 @@ export async function reportProgress(
|
||||
// always track the body for job summary
|
||||
ctx.toolState.lastProgressBody = body;
|
||||
|
||||
// silent events (e.g., auto-label, PR summary) should never create or update progress comments.
|
||||
// the body is still tracked above for the GitHub Actions job summary.
|
||||
if (ctx.payload.event.silent) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
const issueNumber = ctx.toolState.issueNumber ?? ctx.payload.event.issue_number;
|
||||
const isPlanMode = ctx.toolState.selectedMode === "Plan";
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { resolveMode, truncateOutput } from "./delegate.ts";
|
||||
|
||||
// ─── mode resolution tests ─────────────────────────────────────────────
|
||||
|
||||
const testModes: Mode[] = [
|
||||
{ name: "Build", description: "build things", prompt: "build prompt" },
|
||||
{ name: "Plan", description: "plan things", prompt: "plan prompt" },
|
||||
{ name: "Review", description: "review things", prompt: "review prompt" },
|
||||
{ name: "Fix", description: "fix things", prompt: "fix prompt" },
|
||||
{ name: "AddressReviews", description: "address reviews", prompt: "address prompt" },
|
||||
];
|
||||
|
||||
describe("delegate - mode resolution", () => {
|
||||
it("resolves valid mode name", () => {
|
||||
const mode = resolveMode(testModes, "Build");
|
||||
expect(mode).not.toBeNull();
|
||||
expect(mode!.name).toBe("Build");
|
||||
});
|
||||
|
||||
it("resolves case-insensitively (lowercase)", () => {
|
||||
const mode = resolveMode(testModes, "build");
|
||||
expect(mode).not.toBeNull();
|
||||
expect(mode!.name).toBe("Build");
|
||||
});
|
||||
|
||||
it("resolves case-insensitively (uppercase)", () => {
|
||||
const mode = resolveMode(testModes, "BUILD");
|
||||
expect(mode).not.toBeNull();
|
||||
expect(mode!.name).toBe("Build");
|
||||
});
|
||||
|
||||
it("resolves case-insensitively (mixed case)", () => {
|
||||
const mode = resolveMode(testModes, "pLaN");
|
||||
expect(mode).not.toBeNull();
|
||||
expect(mode!.name).toBe("Plan");
|
||||
});
|
||||
|
||||
it("returns null for invalid mode name", () => {
|
||||
const mode = resolveMode(testModes, "nonexistent");
|
||||
expect(mode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty string", () => {
|
||||
const mode = resolveMode(testModes, "");
|
||||
expect(mode).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves custom modes appended alongside built-in modes", () => {
|
||||
const modesWithCustom: Mode[] = [
|
||||
...testModes,
|
||||
{ name: "CustomLabel", description: "label issues", prompt: "label prompt" },
|
||||
];
|
||||
const mode = resolveMode(modesWithCustom, "customlabel");
|
||||
expect(mode).not.toBeNull();
|
||||
expect(mode!.name).toBe("CustomLabel");
|
||||
});
|
||||
|
||||
it("returns null when modes list is empty", () => {
|
||||
const mode = resolveMode([], "Build");
|
||||
expect(mode).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves all built-in modes", () => {
|
||||
for (const m of testModes) {
|
||||
const resolved = resolveMode(testModes, m.name);
|
||||
expect(resolved).not.toBeNull();
|
||||
expect(resolved!.name).toBe(m.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── output truncation tests ────────────────────────────────────────────
|
||||
|
||||
describe("delegate - output truncation", () => {
|
||||
it("returns undefined for undefined input", () => {
|
||||
expect(truncateOutput(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty string as-is", () => {
|
||||
expect(truncateOutput("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns short output unchanged", () => {
|
||||
const short = "a".repeat(100);
|
||||
expect(truncateOutput(short)).toBe(short);
|
||||
});
|
||||
|
||||
it("returns output at exactly the limit unchanged", () => {
|
||||
const exact = "x".repeat(20_000);
|
||||
expect(truncateOutput(exact)).toBe(exact);
|
||||
});
|
||||
|
||||
it("truncates output exceeding the limit", () => {
|
||||
const long = "a".repeat(30_000);
|
||||
const result = truncateOutput(long);
|
||||
expect(result).not.toBe(long);
|
||||
expect(result).toContain("[truncated");
|
||||
expect(result).toContain("20000");
|
||||
});
|
||||
|
||||
it("keeps the tail of the output (last N chars)", () => {
|
||||
const prefix = "START_".repeat(5000);
|
||||
const suffix = "END_MARKER";
|
||||
const long = prefix + suffix;
|
||||
const result = truncateOutput(long)!;
|
||||
expect(result).toContain("END_MARKER");
|
||||
// the very beginning of the original is lost (starts with truncation prefix, not original content)
|
||||
expect(result.startsWith("START_")).toBe(false);
|
||||
});
|
||||
|
||||
it("adds truncation prefix before the content", () => {
|
||||
const long = "x".repeat(25_000);
|
||||
const result = truncateOutput(long)!;
|
||||
expect(result).toMatch(/^\[truncated.*\]\n/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── effort validation tests ────────────────────────────────────────────
|
||||
|
||||
// mirrors the effort default logic in the delegate handler
|
||||
function resolveEffort(effort: string | undefined): string {
|
||||
return effort ?? "auto";
|
||||
}
|
||||
|
||||
describe("delegate - effort defaults", () => {
|
||||
it("defaults to 'auto' when undefined", () => {
|
||||
expect(resolveEffort(undefined)).toBe("auto");
|
||||
});
|
||||
|
||||
it("passes through 'mini'", () => {
|
||||
expect(resolveEffort("mini")).toBe("mini");
|
||||
});
|
||||
|
||||
it("passes through 'auto'", () => {
|
||||
expect(resolveEffort("auto")).toBe("auto");
|
||||
});
|
||||
|
||||
it("passes through 'max'", () => {
|
||||
expect(resolveEffort("max")).toBe("max");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delegation guard tests ─────────────────────────────────────────────
|
||||
|
||||
// mirrors the delegationActive guard logic
|
||||
function checkDelegationGuard(delegationActive: boolean): string | null {
|
||||
if (delegationActive) {
|
||||
return "delegation is not available inside a delegated subagent";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("delegate - delegation guard", () => {
|
||||
it("allows delegation when delegationActive is false", () => {
|
||||
expect(checkDelegationGuard(false)).toBeNull();
|
||||
});
|
||||
|
||||
it("blocks delegation when delegationActive is true", () => {
|
||||
const error = checkDelegationGuard(true);
|
||||
expect(error).not.toBeNull();
|
||||
expect(error).toContain("not available");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delegation lifecycle tests ─────────────────────────────────────────
|
||||
|
||||
// simulates the delegationActive lifecycle across sequential delegations
|
||||
describe("delegate - delegation lifecycle", () => {
|
||||
it("delegationActive resets after successful delegation", () => {
|
||||
let delegationActive = false;
|
||||
|
||||
// first delegation
|
||||
delegationActive = true;
|
||||
// ... agent.run() succeeds ...
|
||||
delegationActive = false; // finally block
|
||||
|
||||
expect(delegationActive).toBe(false);
|
||||
});
|
||||
|
||||
it("delegationActive resets after failed delegation (finally block)", () => {
|
||||
let delegationActive = false;
|
||||
|
||||
// delegation that fails — finally block still runs
|
||||
delegationActive = true;
|
||||
try {
|
||||
throw new Error("agent failed");
|
||||
} catch {
|
||||
// agent error handled
|
||||
} finally {
|
||||
delegationActive = false;
|
||||
}
|
||||
|
||||
expect(delegationActive).toBe(false);
|
||||
});
|
||||
|
||||
it("supports sequential delegations", () => {
|
||||
let delegationActive = false;
|
||||
let selectedMode: string | undefined;
|
||||
|
||||
// first delegation: Plan
|
||||
expect(checkDelegationGuard(delegationActive)).toBeNull();
|
||||
delegationActive = true;
|
||||
selectedMode = "Plan";
|
||||
delegationActive = false; // completed
|
||||
|
||||
expect(selectedMode).toBe("Plan");
|
||||
|
||||
// second delegation: Build
|
||||
expect(checkDelegationGuard(delegationActive)).toBeNull();
|
||||
delegationActive = true;
|
||||
selectedMode = "Build";
|
||||
delegationActive = false; // completed
|
||||
|
||||
expect(selectedMode).toBe("Build");
|
||||
});
|
||||
|
||||
it("blocks during active delegation", () => {
|
||||
let delegationActive = false;
|
||||
|
||||
// start delegation
|
||||
delegationActive = true;
|
||||
|
||||
// attempt second delegation while first is active
|
||||
expect(checkDelegationGuard(delegationActive)).not.toBeNull();
|
||||
|
||||
// first completes
|
||||
delegationActive = false;
|
||||
|
||||
// now second should be allowed
|
||||
expect(checkDelegationGuard(delegationActive)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── subagent payload construction tests ────────────────────────────────
|
||||
|
||||
type MinimalPayload = {
|
||||
effort: string;
|
||||
prompt: string;
|
||||
bash: string;
|
||||
push: string;
|
||||
web: string;
|
||||
};
|
||||
|
||||
function buildSubagentPayload(payload: MinimalPayload, delegatedEffort: string): MinimalPayload {
|
||||
return { ...payload, effort: delegatedEffort };
|
||||
}
|
||||
|
||||
describe("delegate - subagent payload construction", () => {
|
||||
const basePayload: MinimalPayload = {
|
||||
effort: "auto",
|
||||
prompt: "test prompt",
|
||||
bash: "restricted",
|
||||
push: "restricted",
|
||||
web: "enabled",
|
||||
};
|
||||
|
||||
it("overrides effort in subagent payload", () => {
|
||||
const subPayload = buildSubagentPayload(basePayload, "mini");
|
||||
expect(subPayload.effort).toBe("mini");
|
||||
});
|
||||
|
||||
it("preserves other payload fields", () => {
|
||||
const subPayload = buildSubagentPayload(basePayload, "mini");
|
||||
expect(subPayload.prompt).toBe("test prompt");
|
||||
expect(subPayload.bash).toBe("restricted");
|
||||
expect(subPayload.push).toBe("restricted");
|
||||
expect(subPayload.web).toBe("enabled");
|
||||
});
|
||||
|
||||
it("does not mutate original payload", () => {
|
||||
const subPayload = buildSubagentPayload(basePayload, "max");
|
||||
expect(basePayload.effort).toBe("auto");
|
||||
expect(subPayload.effort).toBe("max");
|
||||
});
|
||||
|
||||
it("handles same effort as original", () => {
|
||||
const subPayload = buildSubagentPayload(basePayload, "auto");
|
||||
expect(subPayload.effort).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── return shape tests ─────────────────────────────────────────────────
|
||||
|
||||
type DelegateResult = {
|
||||
success: boolean;
|
||||
mode: string;
|
||||
effort: string;
|
||||
output: string | undefined;
|
||||
error: string | undefined;
|
||||
};
|
||||
|
||||
type BuildDelegateResultInput = {
|
||||
agentResult: {
|
||||
success: boolean;
|
||||
output?: string;
|
||||
error?: string;
|
||||
};
|
||||
mode: string;
|
||||
effort: string;
|
||||
};
|
||||
|
||||
function buildDelegateResult(input: BuildDelegateResultInput): DelegateResult {
|
||||
return {
|
||||
success: input.agentResult.success,
|
||||
mode: input.mode,
|
||||
effort: input.effort,
|
||||
output: input.agentResult.output,
|
||||
error: input.agentResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
describe("delegate - return shape", () => {
|
||||
it("returns success shape on successful delegation", () => {
|
||||
const result = buildDelegateResult({
|
||||
agentResult: { success: true, output: "agent output" },
|
||||
mode: "Build",
|
||||
effort: "auto",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mode).toBe("Build");
|
||||
expect(result.effort).toBe("auto");
|
||||
expect(result.output).toBe("agent output");
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns failure shape on failed delegation", () => {
|
||||
const result = buildDelegateResult({
|
||||
agentResult: { success: false, error: "agent crashed" },
|
||||
mode: "Review",
|
||||
effort: "mini",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.mode).toBe("Review");
|
||||
expect(result.effort).toBe("mini");
|
||||
expect(result.error).toBe("agent crashed");
|
||||
});
|
||||
|
||||
it("includes mode and effort in both success and failure", () => {
|
||||
const success = buildDelegateResult({
|
||||
agentResult: { success: true },
|
||||
mode: "Plan",
|
||||
effort: "max",
|
||||
});
|
||||
const failure = buildDelegateResult({
|
||||
agentResult: { success: false },
|
||||
mode: "Fix",
|
||||
effort: "mini",
|
||||
});
|
||||
expect(success.mode).toBe("Plan");
|
||||
expect(success.effort).toBe("max");
|
||||
expect(failure.mode).toBe("Fix");
|
||||
expect(failure.effort).toBe("mini");
|
||||
});
|
||||
});
|
||||
+31
-100
@@ -1,129 +1,60 @@
|
||||
import { type } from "arktype";
|
||||
import { Effort } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import { markActivity } from "../utils/activity.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { resolveSubagentInstructions } from "../utils/instructions.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
import { createSubagentState, runSubagent } from "./subagent.ts";
|
||||
|
||||
export const DelegateParams = type({
|
||||
mode: type.string.describe(
|
||||
"the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')"
|
||||
instructions: type.string.describe(
|
||||
"the complete prompt for the subagent. the subagent receives ONLY this text — include all context it needs (file paths, constraints, conventions, tool usage instructions). craft a focused, self-contained task description."
|
||||
),
|
||||
"effort?": Effort.describe(
|
||||
'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)'
|
||||
),
|
||||
"instructions?": type.string.describe(
|
||||
"optional additional context or instructions for the subagent — use this to pass results from earlier delegations or narrow the subagent's focus"
|
||||
),
|
||||
});
|
||||
|
||||
// exported for unit testing
|
||||
export function resolveMode(modes: Mode[], modeName: string): Mode | null {
|
||||
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
// cap subagent output to avoid bloating the orchestrator's context window.
|
||||
// the orchestrator needs enough to understand what happened, not the full NDJSON stream.
|
||||
const MAX_OUTPUT_CHARS = 20_000;
|
||||
|
||||
// exported for unit testing
|
||||
export function truncateOutput(output: string | undefined): string | undefined {
|
||||
if (!output || output.length <= MAX_OUTPUT_CHARS) return output;
|
||||
const truncated = output.slice(-MAX_OUTPUT_CHARS);
|
||||
return `[truncated — showing last ${MAX_OUTPUT_CHARS} chars]\n${truncated}`;
|
||||
}
|
||||
|
||||
export function DelegateTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "delegate",
|
||||
description:
|
||||
"Delegate a task to a subagent with a specific mode and effort level. The subagent runs as a separate process with the mode's step-by-step instructions.",
|
||||
"Delegate a task to a subagent. The subagent receives ONLY the instructions you provide — no other context is added. Use select_mode first to get guidance on how to craft the instructions for a given mode.",
|
||||
parameters: DelegateParams,
|
||||
execute: execute(async (params) => {
|
||||
// guard: prevent subagent recursion
|
||||
if (ctx.toolState.delegationActive) {
|
||||
if (ctx.toolState.activeSubagentId) {
|
||||
return {
|
||||
error:
|
||||
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next.",
|
||||
};
|
||||
}
|
||||
|
||||
// resolve mode
|
||||
const selectedMode = resolveMode(ctx.modes, params.mode);
|
||||
if (!selectedMode) {
|
||||
const availableModes = ctx.modes.map((m) => m.name).join(", ");
|
||||
return {
|
||||
error: `mode "${params.mode}" not found. available modes: ${availableModes}`,
|
||||
availableModes: ctx.modes.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools.",
|
||||
};
|
||||
}
|
||||
|
||||
const effort = params.effort ?? "auto";
|
||||
|
||||
// track state
|
||||
ctx.toolState.selectedMode = selectedMode.name;
|
||||
ctx.toolState.delegationActive = true;
|
||||
|
||||
log.info(
|
||||
`» delegating to ${selectedMode.name} mode (effort=${effort})${params.instructions ? " with orchestrator instructions" : ""}`
|
||||
);
|
||||
|
||||
// keep the process-level activity timeout alive while the subagent runs.
|
||||
// agent CLIs can have long silent thinking phases (>60s) with no stdout,
|
||||
// which would trigger the activity timeout. the overall run timeout (default 1h)
|
||||
// is the real safety net for stalled agents.
|
||||
const keepAliveInterval = setInterval(markActivity, 30_000);
|
||||
|
||||
try {
|
||||
// build subagent payload with effort override
|
||||
const subagentPayload = { ...ctx.payload, effort };
|
||||
|
||||
// build subagent instructions with mode prompt baked in
|
||||
const subagentInstructions = resolveSubagentInstructions({
|
||||
payload: subagentPayload,
|
||||
repo: ctx.repo,
|
||||
modes: ctx.modes,
|
||||
mode: selectedMode,
|
||||
orchestratorInstructions: params.instructions,
|
||||
});
|
||||
|
||||
// spawn subagent — reuses same MCP server, same toolState
|
||||
const result = await ctx.agent.run({
|
||||
payload: subagentPayload,
|
||||
mcpServerUrl: ctx.mcpServerUrl,
|
||||
tmpdir: ctx.tmpdir,
|
||||
instructions: subagentInstructions,
|
||||
});
|
||||
|
||||
log.info(`» delegation to ${selectedMode.name} completed (success=${result.success})`);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
mode: selectedMode.name,
|
||||
effort,
|
||||
output: truncateOutput(result.output),
|
||||
error: result.error,
|
||||
};
|
||||
} catch (err) {
|
||||
// normalize agent crashes into the same return shape as clean failures
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.info(`» delegation to ${selectedMode.name} crashed: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
mode: selectedMode.name,
|
||||
effort,
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
clearInterval(keepAliveInterval);
|
||||
// always release the lock so the orchestrator can delegate again
|
||||
ctx.toolState.delegationActive = false;
|
||||
const mode = ctx.toolState.selectedMode ?? "unknown";
|
||||
if (!ctx.toolState.selectedMode) {
|
||||
log.info(`» warning: delegating without calling select_mode first (mode=${mode})`);
|
||||
}
|
||||
const subagent = createSubagentState({ ctx, mode });
|
||||
|
||||
log.info(`» delegating subagent=${subagent.id} (mode=${mode}, effort=${effort})`);
|
||||
const result = await runSubagent({
|
||||
ctx,
|
||||
subagent,
|
||||
effort,
|
||||
instructions: params.instructions,
|
||||
});
|
||||
log.info(`» delegation completed (mode=${mode}, success=${result.success})`);
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
mode,
|
||||
effort,
|
||||
summary:
|
||||
subagent.output ??
|
||||
result.error ??
|
||||
"no output produced — the subagent may not have called set_output. check stdoutFile for full logs.",
|
||||
stdoutFile: subagent.stdoutFilePath,
|
||||
error: result.error,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
+14
-2
@@ -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";
|
||||
|
||||
@@ -10,11 +11,22 @@ export function SetOutputTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "set_output",
|
||||
description:
|
||||
"Set the action output for consumption by subsequent workflow steps. The value will be available as the 'result' output of the action.",
|
||||
"Set the action output. When called by a subagent, returns a summary result to the orchestrator. When called in standalone mode, exposes the value as the 'result' GitHub Action output.",
|
||||
parameters: SetOutputParams,
|
||||
execute: execute(async (params) => {
|
||||
const activeId = ctx.toolState.activeSubagentId;
|
||||
if (activeId) {
|
||||
const subagent = ctx.toolState.subagents.get(activeId);
|
||||
if (subagent) {
|
||||
subagent.output = params.value;
|
||||
return { success: true, routed: "subagent" };
|
||||
}
|
||||
log.warning(
|
||||
`set_output: activeSubagentId=${activeId} but subagent not found in map — routing to action output`
|
||||
);
|
||||
}
|
||||
ctx.toolState.output = params.value;
|
||||
return { success: true };
|
||||
return { success: true, routed: "action_output" };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,31 +24,60 @@ function buildPrBodyWithFooter(ctx: ToolContext, body: string): string {
|
||||
return `${bodyWithoutFooter}${footer}`;
|
||||
}
|
||||
|
||||
export const UpdatePullRequestBody = type({
|
||||
pull_number: type.number.describe("the pull request number to update"),
|
||||
body: type.string.describe("the new body content for the pull request"),
|
||||
});
|
||||
|
||||
export function UpdatePullRequestBodyTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "update_pull_request_body",
|
||||
description: "Update the body/description of an existing pull request",
|
||||
parameters: UpdatePullRequestBody,
|
||||
execute: execute(async (params) => {
|
||||
const bodyWithFooter = buildPrBodyWithFooter(ctx, params.body);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.update({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number: params.pull_number,
|
||||
body: bodyWithFooter,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
number: result.data.number,
|
||||
url: result.data.html_url,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "create_pull_request",
|
||||
description: "Create a pull request from the current branch",
|
||||
parameters: PullRequest,
|
||||
execute: execute(async ({ title, body, base }) => {
|
||||
execute: execute(async (params) => {
|
||||
const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false });
|
||||
log.debug(`Current branch: ${currentBranch}`);
|
||||
|
||||
const bodyWithFooter = buildPrBodyWithFooter(ctx, body);
|
||||
const bodyWithFooter = buildPrBodyWithFooter(ctx, params.body);
|
||||
|
||||
const result = await ctx.octokit.rest.pulls.create({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
title: title,
|
||||
title: params.title,
|
||||
body: bodyWithFooter,
|
||||
head: currentBranch,
|
||||
base: base,
|
||||
base: params.base,
|
||||
});
|
||||
|
||||
// best-effort: request review from the user who triggered the workflow
|
||||
const reviewer = ctx.payload.triggeringUser;
|
||||
if (reviewer) {
|
||||
try {
|
||||
log.debug(`Requesting review from ${reviewer} on PR #${result.data.number}`);
|
||||
log.debug(`requesting review from ${reviewer} on PR #${result.data.number}`);
|
||||
await ctx.octokit.rest.pulls.requestReviewers({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { type } from "arktype";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
export const SelectModeParams = type({
|
||||
mode: type.string.describe(
|
||||
"the name of the mode to select (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews', 'Prompt')"
|
||||
),
|
||||
});
|
||||
|
||||
function resolveMode(modes: Mode[], modeName: string): Mode | null {
|
||||
return modes.find((m) => m.name.toLowerCase() === modeName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
function defaultGuidance(mode: Mode): string {
|
||||
return `Delegate a single subagent for this "${mode.name}" task. Craft a self-contained prompt that includes all context the subagent needs. Include \`${ghPullfrogMcpName}/report_progress\` for user-facing updates and \`${ghPullfrogMcpName}/set_output\` to return results back to you. Subagents do NOT have push or PR creation tools — if the task involves code changes, you must push and create the PR yourself after the subagent completes.`;
|
||||
}
|
||||
|
||||
const modeGuidance: Record<string, string> = {
|
||||
Build: `For Build tasks, consider a multi-phase approach:
|
||||
|
||||
1. **plan phase** (optional, for complex tasks): delegate a subagent to analyze the requirements, read AGENTS.md and relevant code, and produce a step-by-step implementation plan. Include \`${ghPullfrogMcpName}/set_output\` with the plan so it returns to you. Use mini or auto effort.
|
||||
|
||||
2. **build phase**: delegate a subagent with the implementation task. Include in its prompt:
|
||||
- the plan (if you ran a plan phase)
|
||||
- specific files to modify and why
|
||||
- branch naming: \`pullfrog/<issue-number>-<description>\`
|
||||
- testing expectations: run relevant tests/lints before committing
|
||||
- commit changes locally (do NOT instruct to push or create a PR — subagents cannot do that)
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with a summary of changes
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary including the branch name (this is how results get back to you)
|
||||
|
||||
3. **post-delegation** (your responsibility as orchestrator): after the build subagent completes:
|
||||
- push the branch via \`${ghPullfrogMcpName}/push_branch\`
|
||||
- create a PR via \`${ghPullfrogMcpName}/create_pull_request\`
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR link
|
||||
|
||||
4. **review phase** (optional, for high-stakes changes): delegate a review subagent to verify the implementation.
|
||||
|
||||
For simple, well-defined tasks, a single build subagent is sufficient — skip the plan and review phases.
|
||||
|
||||
Your subagent receives ONLY what you write. Include file paths, constraints, conventions, and any context from AGENTS.md or the codebase directly in the prompt. Subagents do NOT have push_branch, create_pull_request, or other remote-mutating tools.`,
|
||||
|
||||
AddressReviews: `Delegate a single subagent to address PR review feedback:
|
||||
|
||||
Include in its prompt:
|
||||
- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\`
|
||||
- instruct it to fetch review comments via \`${ghPullfrogMcpName}/get_review_comments\`
|
||||
- reply to EACH comment individually via \`${ghPullfrogMcpName}/reply_to_review_comment\`
|
||||
- resolve threads via \`${ghPullfrogMcpName}/resolve_review_thread\` after addressing them
|
||||
- test changes and commit locally (do NOT instruct to push — subagents cannot do that)
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with a brief summary
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of what was addressed (this is how results get back to you)
|
||||
|
||||
After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`.
|
||||
|
||||
Use auto or max effort depending on review complexity.`,
|
||||
|
||||
Review: `Delegate a single review subagent:
|
||||
|
||||
Include in its prompt:
|
||||
- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\`
|
||||
- what aspects to focus on (if any specific concerns exist)
|
||||
- instruct it to read the diff, trace data flow, check boundaries, and verify assumptions
|
||||
- draft inline comments with NEW line numbers from the diff
|
||||
- submit via \`${ghPullfrogMcpName}/create_pull_request_review\`
|
||||
- use GitHub permalink format for code references
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise review summary (this is how results get back to you)
|
||||
|
||||
Use max effort for thorough reviews.`,
|
||||
|
||||
Plan: `Delegate a single planning subagent:
|
||||
|
||||
Include in its prompt:
|
||||
- the task to plan for
|
||||
- relevant codebase context (file paths, architecture notes from AGENTS.md)
|
||||
- instruct it to produce a structured, actionable plan with clear milestones
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with the plan
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with the plan (this is how results get back to you — you'll need the plan to craft the next subagent's prompt)
|
||||
|
||||
Use mini or auto effort. After receiving the plan, you may delegate a Build subagent to implement it.`,
|
||||
|
||||
Fix: `For CI fix tasks, consider a focused single-phase approach:
|
||||
|
||||
Delegate a single fix subagent with:
|
||||
- the check_suite_id to fetch logs via \`${ghPullfrogMcpName}/get_check_suite_logs\`
|
||||
- the PR number to checkout via \`${ghPullfrogMcpName}/checkout_pr\`
|
||||
- CRITICAL: instruct it to verify the failure was INTRODUCED BY THIS PR before fixing. If unrelated, abort and report.
|
||||
- instruct it to read the workflow file, reproduce locally, fix, verify, and commit (do NOT instruct to push — subagents cannot do that)
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with what was fixed
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary of the fix (this is how results get back to you)
|
||||
|
||||
After the subagent completes, push the changes via \`${ghPullfrogMcpName}/push_branch\`.
|
||||
|
||||
Use auto effort.`,
|
||||
|
||||
Prompt: `Delegate a single subagent for this general-purpose task:
|
||||
|
||||
Include in its prompt:
|
||||
- the full task description with all relevant context
|
||||
- if code changes are needed: branch naming, testing, commit instructions (do NOT instruct to push or create PR)
|
||||
- call \`${ghPullfrogMcpName}/report_progress\` with results
|
||||
- call \`${ghPullfrogMcpName}/set_output\` with a concise summary (this is how results get back to you)
|
||||
|
||||
If the task involved code changes, push via \`${ghPullfrogMcpName}/push_branch\` and create a PR via \`${ghPullfrogMcpName}/create_pull_request\` after the subagent completes.
|
||||
|
||||
Use mini effort for simple tasks (labeling, commenting), auto for typical tasks.`,
|
||||
};
|
||||
|
||||
type OrchestratorGuidance = {
|
||||
modeName: string;
|
||||
description: string;
|
||||
orchestratorGuidance: string;
|
||||
};
|
||||
|
||||
function buildOrchestratorGuidance(mode: Mode): OrchestratorGuidance {
|
||||
const guidance = modeGuidance[mode.name] ?? defaultGuidance(mode);
|
||||
return {
|
||||
modeName: mode.name,
|
||||
description: mode.description,
|
||||
orchestratorGuidance: guidance,
|
||||
};
|
||||
}
|
||||
|
||||
export function SelectModeTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "select_mode",
|
||||
description:
|
||||
"Select a mode and receive orchestrator-level guidance on how to handle it, including suggested delegation flows and prompt-crafting tips. Call this before delegating to understand the best approach for the task.",
|
||||
parameters: SelectModeParams,
|
||||
execute: execute(async (params) => {
|
||||
const selectedMode = resolveMode(ctx.modes, params.mode);
|
||||
|
||||
if (!selectedMode) {
|
||||
const availableModes = ctx.modes.map((m) => m.name).join(", ");
|
||||
return {
|
||||
error: `mode "${params.mode}" not found. available modes: ${availableModes}`,
|
||||
availableModes: ctx.modes.map((m) => ({
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
ctx.toolState.selectedMode = selectedMode.name;
|
||||
return buildOrchestratorGuidance(selectedMode);
|
||||
}),
|
||||
});
|
||||
}
|
||||
+106
-26
@@ -1,8 +1,8 @@
|
||||
// this must be imported first
|
||||
import "./arkConfig.ts";
|
||||
import { createServer } from "node:net";
|
||||
// this must be imported first
|
||||
import { FastMCP, type Tool } from "fastmcp";
|
||||
import type { Agent } from "../agents/index.ts";
|
||||
import type { Agent, AgentUsage } from "../agents/index.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { PrepResult } from "../prep/index.ts";
|
||||
@@ -21,6 +21,19 @@ export type StoredPushDest = {
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
export type SubagentStatus = "running" | "completed" | "failed";
|
||||
|
||||
export type SubagentState = {
|
||||
id: string;
|
||||
status: SubagentStatus;
|
||||
mode: string;
|
||||
stdoutFilePath: string;
|
||||
output: string | undefined;
|
||||
usage: AgentUsage | undefined;
|
||||
startedAt: number;
|
||||
keepAliveInterval: ReturnType<typeof setInterval> | undefined;
|
||||
};
|
||||
|
||||
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.
|
||||
@@ -31,8 +44,10 @@ export interface ToolState {
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
selectedMode?: string;
|
||||
// true while a subagent is running via the delegate tool — prevents recursive delegation
|
||||
delegationActive: boolean;
|
||||
// per-subagent lifecycle tracking (keyed by subagent uuid)
|
||||
subagents: Map<string, SubagentState>;
|
||||
// set while a subagent is running — routes set_output to the correct subagent and prevents nesting
|
||||
activeSubagentId: string | undefined;
|
||||
backgroundProcesses: Map<string, BackgroundProcess>;
|
||||
review?: {
|
||||
id: number;
|
||||
@@ -48,6 +63,7 @@ export interface ToolState {
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
output?: string;
|
||||
usageEntries: AgentUsage[];
|
||||
}
|
||||
|
||||
interface InitToolStateParams {
|
||||
@@ -56,7 +72,7 @@ interface InitToolStateParams {
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed) ? undefined : parsed;
|
||||
const resolvedId = Number.isNaN(parsed) || parsed <= 0 ? undefined : parsed;
|
||||
|
||||
if (resolvedId) {
|
||||
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
||||
@@ -64,8 +80,10 @@ export function initToolState(params: InitToolStateParams): ToolState {
|
||||
|
||||
return {
|
||||
progressCommentId: resolvedId,
|
||||
delegationActive: false,
|
||||
subagents: new Map(),
|
||||
activeSubagentId: undefined,
|
||||
backgroundProcesses: new Map(),
|
||||
usageEntries: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,8 +105,27 @@ export interface ToolContext {
|
||||
tmpdir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* tool names that are only available to the orchestrator.
|
||||
* subagent MCP servers are started with these tools excluded.
|
||||
*
|
||||
* - delegation tools: only the orchestrator can spawn/manage subagents
|
||||
* - remote-mutating tools: subagents work locally; the orchestrator pushes and creates PRs
|
||||
*/
|
||||
export const ORCHESTRATOR_ONLY_TOOLS = [
|
||||
"select_mode",
|
||||
"delegate",
|
||||
"ask_question",
|
||||
"push_branch",
|
||||
"push_tags",
|
||||
"delete_branch",
|
||||
"create_pull_request",
|
||||
"update_pull_request_body",
|
||||
] as const;
|
||||
|
||||
import { log } from "../utils/cli.ts";
|
||||
import type { RunContextData } from "../utils/runContextData.ts";
|
||||
import { AskQuestionTool } from "./askQuestion.ts";
|
||||
import { BashTool, KillBackgroundTool } from "./bash.ts";
|
||||
import { CheckoutPrTool } from "./checkout.ts";
|
||||
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
|
||||
@@ -118,7 +155,7 @@ import { GetIssueEventsTool } from "./issueEvents.ts";
|
||||
import { IssueInfoTool } from "./issueInfo.ts";
|
||||
import { AddLabelsTool } from "./labels.ts";
|
||||
import { SetOutputTool } from "./output.ts";
|
||||
import { CreatePullRequestTool } from "./pr.ts";
|
||||
import { CreatePullRequestTool, UpdatePullRequestBodyTool } from "./pr.ts";
|
||||
import { PullRequestInfoTool } from "./prInfo.ts";
|
||||
import { CreatePullRequestReviewTool } from "./review.ts";
|
||||
import {
|
||||
@@ -126,6 +163,7 @@ import {
|
||||
ListPullRequestReviewsTool,
|
||||
ResolveReviewThreadTool,
|
||||
} from "./reviewComments.ts";
|
||||
import { SelectModeTool } from "./selectMode.ts";
|
||||
import { addTools } from "./shared.ts";
|
||||
import { UploadFileTool } from "./upload.ts";
|
||||
|
||||
@@ -165,9 +203,10 @@ function isAddressInUse(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return message.includes("eaddrinuse") || message.includes("address already in use");
|
||||
}
|
||||
function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
|
||||
// tools shared by both orchestrator and subagent servers
|
||||
function buildCommonTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
const tools: Tool<any, any>[] = [
|
||||
DelegateTool(ctx),
|
||||
StartDependencyInstallationTool(ctx),
|
||||
AwaitDependencyInstallationTool(ctx),
|
||||
CreateCommentTool(ctx),
|
||||
@@ -177,7 +216,6 @@ function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
IssueInfoTool(ctx),
|
||||
GetIssueCommentsTool(ctx),
|
||||
GetIssueEventsTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
CreatePullRequestReviewTool(ctx),
|
||||
PullRequestInfoTool(ctx),
|
||||
CommitInfoTool(ctx),
|
||||
@@ -187,11 +225,8 @@ function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
ResolveReviewThreadTool(ctx),
|
||||
GetCheckSuiteLogsTool(ctx),
|
||||
AddLabelsTool(ctx),
|
||||
PushBranchTool(ctx),
|
||||
GitTool(ctx),
|
||||
GitFetchTool(ctx),
|
||||
DeleteBranchTool(ctx),
|
||||
PushTagsTool(ctx),
|
||||
UploadFileTool(ctx),
|
||||
SetOutputTool(ctx),
|
||||
FileReadTool(ctx),
|
||||
@@ -199,6 +234,7 @@ function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
FileEditTool(ctx),
|
||||
FileDeleteTool(ctx),
|
||||
ListDirectoryTool(ctx),
|
||||
ReportProgressTool(ctx),
|
||||
];
|
||||
|
||||
// only add BashTool when bash is "restricted"
|
||||
@@ -210,23 +246,41 @@ function buildTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
tools.push(KillBackgroundTool(ctx));
|
||||
}
|
||||
|
||||
tools.push(ReportProgressTool(ctx));
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
// orchestrator gets common tools + delegation + remote-mutating tools
|
||||
function buildOrchestratorTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
return [
|
||||
...buildCommonTools(ctx),
|
||||
SelectModeTool(ctx),
|
||||
DelegateTool(ctx),
|
||||
AskQuestionTool(ctx),
|
||||
PushBranchTool(ctx),
|
||||
PushTagsTool(ctx),
|
||||
DeleteBranchTool(ctx),
|
||||
CreatePullRequestTool(ctx),
|
||||
UpdatePullRequestBodyTool(ctx),
|
||||
];
|
||||
}
|
||||
|
||||
// subagent gets only common tools (no delegation, no remote mutation)
|
||||
function buildSubagentTools(ctx: ToolContext): Tool<any, any>[] {
|
||||
return buildCommonTools(ctx);
|
||||
}
|
||||
|
||||
type McpStartResult = {
|
||||
server: FastMCP;
|
||||
url: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
async function tryStartMcpServer(ctx: ToolContext, port: number): Promise<McpStartResult | null> {
|
||||
const server = new FastMCP({
|
||||
name: ghPullfrogMcpName,
|
||||
version: "0.0.1",
|
||||
});
|
||||
const tools = buildTools(ctx);
|
||||
async function tryStartMcpServer(
|
||||
ctx: ToolContext,
|
||||
tools: Tool<any, any>[],
|
||||
port: number
|
||||
): Promise<McpStartResult | null> {
|
||||
const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" });
|
||||
addTools(ctx, server, tools);
|
||||
|
||||
try {
|
||||
@@ -253,13 +307,13 @@ async function tryStartMcpServer(ctx: ToolContext, port: number): Promise<McpSta
|
||||
}
|
||||
}
|
||||
|
||||
async function selectMcpPort(ctx: ToolContext): Promise<McpStartResult> {
|
||||
async function selectMcpPort(ctx: ToolContext, tools: Tool<any, any>[]): Promise<McpStartResult> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
const requestedPort = readEnvPort();
|
||||
if (requestedPort !== null) {
|
||||
if (await isPortAvailable(requestedPort)) {
|
||||
const requestedResult = await tryStartMcpServer(ctx, requestedPort);
|
||||
const requestedResult = await tryStartMcpServer(ctx, tools, requestedPort);
|
||||
if (requestedResult) {
|
||||
return requestedResult;
|
||||
}
|
||||
@@ -275,7 +329,7 @@ async function selectMcpPort(ctx: ToolContext): Promise<McpStartResult> {
|
||||
if (!(await isPortAvailable(port))) {
|
||||
continue;
|
||||
}
|
||||
const result = await tryStartMcpServer(ctx, port);
|
||||
const result = await tryStartMcpServer(ctx, tools, port);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
@@ -315,12 +369,13 @@ async function killBackgroundProcesses(toolState: ToolState): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MCP HTTP server and return the URL and close function
|
||||
* Start the orchestrator MCP HTTP server (has all tools including push/PR/delegation).
|
||||
*/
|
||||
export async function startMcpHttpServer(
|
||||
ctx: ToolContext
|
||||
): Promise<{ url: string; [Symbol.asyncDispose]: () => Promise<void> }> {
|
||||
const startResult = await selectMcpPort(ctx);
|
||||
const tools = buildOrchestratorTools(ctx);
|
||||
const startResult = await selectMcpPort(ctx, tools);
|
||||
|
||||
return {
|
||||
url: startResult.url,
|
||||
@@ -330,3 +385,28 @@ export async function startMcpHttpServer(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ManagedMcpServer = {
|
||||
url: string;
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a per-subagent MCP server (common tools only — no push/PR/delegation).
|
||||
* Each subagent gets its own server; call stop() when the subagent completes.
|
||||
*
|
||||
* The subagent gets its own shallow copy of toolState so scalar writes
|
||||
* (pushUrl, pushDest, selectedMode, etc.) don't mutate the orchestrator's state.
|
||||
* Shared references (subagents Map, usageEntries array, dependencyInstallation)
|
||||
* are intentionally shared for coordination (set_output routing, usage tracking).
|
||||
*/
|
||||
export async function startSubagentMcpServer(ctx: ToolContext): Promise<ManagedMcpServer> {
|
||||
const subagentToolState: ToolState = {
|
||||
...ctx.toolState,
|
||||
backgroundProcesses: new Map(),
|
||||
};
|
||||
const subagentCtx: ToolContext = { ...ctx, toolState: subagentToolState };
|
||||
const tools = buildSubagentTools(subagentCtx);
|
||||
const startResult = await selectMcpPort(subagentCtx, tools);
|
||||
return { url: startResult.url, stop: () => startResult.server.stop() };
|
||||
}
|
||||
|
||||
+1
-2
@@ -58,7 +58,6 @@ export const execute = <T, R extends Record<string, any> | string>(
|
||||
return handleToolError(error);
|
||||
}
|
||||
};
|
||||
(_fn as any).raw = fn;
|
||||
return _fn;
|
||||
};
|
||||
|
||||
@@ -183,7 +182,7 @@ function sanitizeTool<T extends Tool<any, any>>(tool: T): T {
|
||||
} as T;
|
||||
}
|
||||
|
||||
export const addTools = (ctx: ToolContext, server: FastMCP, tools: Tool<any, any>[]) => {
|
||||
export const addTools = (ctx: ToolContext, server: FastMCP<any>, tools: Tool<any, any>[]) => {
|
||||
// sanitize schemas for gemini agent and opencode (when using Google API)
|
||||
// both have issues with draft-2020-12 schemas and any_of enum constructs
|
||||
const shouldSanitize = ctx.agent.name === "gemini" || ctx.agent.name === "opencode";
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Effort } from "../external.ts";
|
||||
import { markActivity } from "../utils/activity.ts";
|
||||
import type { ResolvedInstructions } from "../utils/instructions.ts";
|
||||
import { type SubagentState, startSubagentMcpServer, type ToolContext } from "./server.ts";
|
||||
|
||||
type CreateSubagentParams = {
|
||||
ctx: ToolContext;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export function createSubagentState(params: CreateSubagentParams): SubagentState {
|
||||
const id = randomUUID();
|
||||
const stdoutFilePath = join(params.ctx.tmpdir, `subagent-${id}.log`);
|
||||
const state: SubagentState = {
|
||||
id,
|
||||
status: "running",
|
||||
mode: params.mode,
|
||||
stdoutFilePath,
|
||||
output: undefined,
|
||||
usage: undefined,
|
||||
startedAt: Date.now(),
|
||||
keepAliveInterval: undefined,
|
||||
};
|
||||
params.ctx.toolState.subagents.set(id, state);
|
||||
params.ctx.toolState.activeSubagentId = id;
|
||||
return state;
|
||||
}
|
||||
|
||||
type CompleteSubagentParams = {
|
||||
ctx: ToolContext;
|
||||
subagent: SubagentState;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
function completeSubagent(params: CompleteSubagentParams): void {
|
||||
params.subagent.status = params.success ? "completed" : "failed";
|
||||
if (params.subagent.keepAliveInterval) {
|
||||
clearInterval(params.subagent.keepAliveInterval);
|
||||
params.subagent.keepAliveInterval = undefined;
|
||||
}
|
||||
if (params.subagent.usage) {
|
||||
params.ctx.toolState.usageEntries.push(params.subagent.usage);
|
||||
}
|
||||
params.ctx.toolState.activeSubagentId = undefined;
|
||||
// keep completed subagents in the map for post-completion inspection
|
||||
}
|
||||
|
||||
export function buildSubagentInstructions(orchestratorPrompt: string): ResolvedInstructions {
|
||||
return {
|
||||
full: orchestratorPrompt,
|
||||
system: "",
|
||||
user: orchestratorPrompt,
|
||||
eventInstructions: "",
|
||||
repo: "",
|
||||
event: "",
|
||||
runtime: "",
|
||||
};
|
||||
}
|
||||
|
||||
type RunSubagentParams = {
|
||||
ctx: ToolContext;
|
||||
subagent: SubagentState;
|
||||
effort: Effort;
|
||||
instructions: string;
|
||||
};
|
||||
|
||||
type RunSubagentResult = {
|
||||
success: boolean;
|
||||
error: string | undefined;
|
||||
};
|
||||
|
||||
export async function runSubagent(params: RunSubagentParams): Promise<RunSubagentResult> {
|
||||
params.subagent.keepAliveInterval = setInterval(markActivity, 30_000);
|
||||
const mcpServer = await startSubagentMcpServer(params.ctx);
|
||||
try {
|
||||
const subagentPayload = { ...params.ctx.payload, effort: params.effort };
|
||||
const subagentInstructions = buildSubagentInstructions(params.instructions);
|
||||
const result = await params.ctx.agent.run({
|
||||
payload: subagentPayload,
|
||||
mcpServerUrl: mcpServer.url,
|
||||
tmpdir: params.ctx.tmpdir,
|
||||
instructions: subagentInstructions,
|
||||
});
|
||||
params.subagent.usage = result.usage;
|
||||
writeFileSync(params.subagent.stdoutFilePath, result.output ?? "", "utf-8");
|
||||
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: result.success });
|
||||
return { success: result.success, error: result.error };
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
writeFileSync(params.subagent.stdoutFilePath, "", "utf-8");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
completeSubagent({ ctx: params.ctx, subagent: params.subagent, success: false });
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
await mcpServer.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { createServer } from "node:net";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { type } from "arktype";
|
||||
import { FastMCP } from "fastmcp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { ORCHESTRATOR_ONLY_TOOLS } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
import { buildSubagentInstructions } from "./subagent.ts";
|
||||
|
||||
// ─── unit tests for pure exported functions ─────────────────────────────
|
||||
|
||||
describe("ORCHESTRATOR_ONLY_TOOLS", () => {
|
||||
it("includes delegation tools", () => {
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("select_mode");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("delegate");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("ask_question");
|
||||
});
|
||||
|
||||
it("includes remote-mutating tools", () => {
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("push_branch");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("push_tags");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("delete_branch");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("create_pull_request");
|
||||
expect(ORCHESTRATOR_ONLY_TOOLS).toContain("update_pull_request_body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSubagentInstructions", () => {
|
||||
it("returns clean-room instructions with only the orchestrator prompt", () => {
|
||||
const prompt = "Read file.ts and fix the type error.";
|
||||
const instructions = buildSubagentInstructions(prompt);
|
||||
expect(instructions).toEqual({
|
||||
full: prompt,
|
||||
system: "",
|
||||
user: prompt,
|
||||
eventInstructions: "",
|
||||
repo: "",
|
||||
event: "",
|
||||
runtime: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── per-server tool isolation integration test ─────────────────────────
|
||||
// demonstrates the architecture: orchestrator and subagent get separate servers
|
||||
|
||||
function getRandomPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const srv = createServer();
|
||||
srv.listen(0, "127.0.0.1", () => {
|
||||
const addr = srv.address();
|
||||
if (!addr || typeof addr === "string") return reject(new Error("bad address"));
|
||||
const port = addr.port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function connectMcpClient(url: string): Promise<Client> {
|
||||
const transport = new StreamableHTTPClientTransport(new URL(url));
|
||||
const client = new Client({ name: "test-client", version: "0.0.1" });
|
||||
// @ts-expect-error — exactOptionalPropertyTypes mismatch: SDK Transport.sessionId?: string vs StreamableHTTPClientTransport getter returning string | undefined
|
||||
await client.connect(transport);
|
||||
return client;
|
||||
}
|
||||
|
||||
function mockTool(name: string, description: string) {
|
||||
return tool({
|
||||
name,
|
||||
description,
|
||||
parameters: type({ value: "string" }),
|
||||
execute: execute(async () => ({ ok: true })),
|
||||
});
|
||||
}
|
||||
|
||||
describe("per-server tool isolation - integration", () => {
|
||||
let orchestratorServer: FastMCP;
|
||||
let subagentServer: FastMCP;
|
||||
let orchestratorUrl: string;
|
||||
let subagentUrl: string;
|
||||
const clients: Client[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const [orchestratorPort, subagentPort] = await Promise.all([getRandomPort(), getRandomPort()]);
|
||||
orchestratorUrl = `http://127.0.0.1:${orchestratorPort}/mcp`;
|
||||
subagentUrl = `http://127.0.0.1:${subagentPort}/mcp`;
|
||||
|
||||
// orchestrator gets ALL tools (common + delegation + remote mutation)
|
||||
orchestratorServer = new FastMCP({ name: "orchestrator", version: "0.0.1" });
|
||||
orchestratorServer.addTool(mockTool("file_read", "read a file"));
|
||||
orchestratorServer.addTool(mockTool("git", "run git commands"));
|
||||
orchestratorServer.addTool(mockTool("set_output", "set output"));
|
||||
orchestratorServer.addTool(mockTool("select_mode", "select a mode"));
|
||||
orchestratorServer.addTool(mockTool("delegate", "delegate a task"));
|
||||
orchestratorServer.addTool(mockTool("ask_question", "ask a question"));
|
||||
orchestratorServer.addTool(mockTool("push_branch", "push branch"));
|
||||
orchestratorServer.addTool(mockTool("create_pull_request", "create PR"));
|
||||
|
||||
// subagent gets ONLY common tools (no delegation, no remote mutation)
|
||||
subagentServer = new FastMCP({ name: "subagent", version: "0.0.1" });
|
||||
subagentServer.addTool(mockTool("file_read", "read a file"));
|
||||
subagentServer.addTool(mockTool("git", "run git commands"));
|
||||
subagentServer.addTool(mockTool("set_output", "set output"));
|
||||
|
||||
await Promise.all([
|
||||
orchestratorServer.start({
|
||||
transportType: "httpStream",
|
||||
httpStream: { port: orchestratorPort, host: "127.0.0.1", endpoint: "/mcp" },
|
||||
}),
|
||||
subagentServer.start({
|
||||
transportType: "httpStream",
|
||||
httpStream: { port: subagentPort, host: "127.0.0.1", endpoint: "/mcp" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
await client.close();
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
await Promise.all([orchestratorServer.stop(), subagentServer.stop()]);
|
||||
});
|
||||
|
||||
it("orchestrator sees all tools including delegation and mutation", async () => {
|
||||
const client = await connectMcpClient(orchestratorUrl);
|
||||
clients.push(client);
|
||||
const result = await client.listTools();
|
||||
const names = result.tools.map((t) => t.name);
|
||||
expect(names).toContain("select_mode");
|
||||
expect(names).toContain("delegate");
|
||||
expect(names).toContain("ask_question");
|
||||
expect(names).toContain("push_branch");
|
||||
expect(names).toContain("create_pull_request");
|
||||
expect(names).toContain("file_read");
|
||||
expect(names).toContain("git");
|
||||
expect(names).toContain("set_output");
|
||||
expect(names.length).toBe(8);
|
||||
});
|
||||
|
||||
it("subagent cannot see delegation or mutation tools", async () => {
|
||||
const client = await connectMcpClient(subagentUrl);
|
||||
clients.push(client);
|
||||
const result = await client.listTools();
|
||||
const names = result.tools.map((t) => t.name);
|
||||
expect(names).not.toContain("select_mode");
|
||||
expect(names).not.toContain("delegate");
|
||||
expect(names).not.toContain("ask_question");
|
||||
expect(names).not.toContain("push_branch");
|
||||
expect(names).not.toContain("create_pull_request");
|
||||
});
|
||||
|
||||
it("subagent sees only common tools", async () => {
|
||||
const client = await connectMcpClient(subagentUrl);
|
||||
clients.push(client);
|
||||
const result = await client.listTools();
|
||||
const names = result.tools.map((t) => t.name);
|
||||
expect(names).toContain("file_read");
|
||||
expect(names).toContain("git");
|
||||
expect(names).toContain("set_output");
|
||||
expect(names.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@
|
||||
"turndown": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/turndown": "^5.0.5",
|
||||
|
||||
Generated
+131
@@ -74,6 +74,9 @@ importers:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.2
|
||||
devDependencies:
|
||||
'@modelcontextprotocol/sdk':
|
||||
specifier: ^1.26.0
|
||||
version: 1.26.0(zod@4.3.5)
|
||||
'@types/node':
|
||||
specifier: ^24.7.2
|
||||
version: 24.7.2
|
||||
@@ -469,6 +472,12 @@ packages:
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@hono/node-server@1.19.9':
|
||||
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
peerDependencies:
|
||||
hono: ^4
|
||||
|
||||
'@img/sharp-darwin-arm64@0.33.5':
|
||||
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
@@ -568,6 +577,16 @@ packages:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0':
|
||||
resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@cfworker/json-schema': ^4.1.1
|
||||
zod: ^3.25 || ^4.0
|
||||
peerDependenciesMeta:
|
||||
'@cfworker/json-schema':
|
||||
optional: true
|
||||
|
||||
'@octokit/auth-token@6.0.0':
|
||||
resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -889,6 +908,10 @@ packages:
|
||||
resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
bottleneck@2.19.5:
|
||||
resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==}
|
||||
|
||||
@@ -1042,10 +1065,20 @@ packages:
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express-rate-limit@8.2.1:
|
||||
resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express@5.1.0:
|
||||
resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
express@5.2.1:
|
||||
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
fast-content-type-parse@3.0.0:
|
||||
resolution: {integrity: sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==}
|
||||
|
||||
@@ -1141,6 +1174,10 @@ packages:
|
||||
resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hono@4.12.0:
|
||||
resolution: {integrity: sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
http-errors@2.0.0:
|
||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1168,6 +1205,10 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ip-address@10.0.1:
|
||||
resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -1323,6 +1364,10 @@ packages:
|
||||
resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
range-parser@1.2.1:
|
||||
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -1880,6 +1925,10 @@ snapshots:
|
||||
dependencies:
|
||||
hono: 4.11.3
|
||||
|
||||
'@hono/node-server@1.19.9(hono@4.12.0)':
|
||||
dependencies:
|
||||
hono: 4.12.0
|
||||
|
||||
'@img/sharp-darwin-arm64@0.33.5':
|
||||
optionalDependencies:
|
||||
'@img/sharp-libvips-darwin-arm64': 1.0.4
|
||||
@@ -1965,6 +2014,28 @@ snapshots:
|
||||
- hono
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.26.0(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.12.0)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.5
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 8.2.1(express@5.2.1)
|
||||
hono: 4.12.0
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.0
|
||||
raw-body: 3.0.1
|
||||
zod: 4.3.5
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.5)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@octokit/auth-token@6.0.0': {}
|
||||
|
||||
'@octokit/core@7.0.5':
|
||||
@@ -2251,6 +2322,20 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
content-type: 1.0.5
|
||||
debug: 4.4.3
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.7.0
|
||||
on-finished: 2.4.1
|
||||
qs: 6.15.0
|
||||
raw-body: 3.0.1
|
||||
type-is: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
bottleneck@2.19.5: {}
|
||||
|
||||
bytes@3.1.2: {}
|
||||
@@ -2442,6 +2527,11 @@ snapshots:
|
||||
dependencies:
|
||||
express: 5.1.0
|
||||
|
||||
express-rate-limit@8.2.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
ip-address: 10.0.1
|
||||
|
||||
express@5.1.0:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
@@ -2474,6 +2564,39 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express@5.2.1:
|
||||
dependencies:
|
||||
accepts: 2.0.0
|
||||
body-parser: 2.2.2
|
||||
content-disposition: 1.0.0
|
||||
content-type: 1.0.5
|
||||
cookie: 0.7.2
|
||||
cookie-signature: 1.2.2
|
||||
debug: 4.4.3
|
||||
depd: 2.0.0
|
||||
encodeurl: 2.0.0
|
||||
escape-html: 1.0.3
|
||||
etag: 1.8.1
|
||||
finalhandler: 2.1.0
|
||||
fresh: 2.0.0
|
||||
http-errors: 2.0.0
|
||||
merge-descriptors: 2.0.0
|
||||
mime-types: 3.0.1
|
||||
on-finished: 2.4.1
|
||||
once: 1.4.0
|
||||
parseurl: 1.3.3
|
||||
proxy-addr: 2.0.7
|
||||
qs: 6.14.0
|
||||
range-parser: 1.2.1
|
||||
router: 2.2.0
|
||||
send: 1.2.0
|
||||
serve-static: 2.2.0
|
||||
statuses: 2.0.2
|
||||
type-is: 2.0.1
|
||||
vary: 1.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
fast-content-type-parse@3.0.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -2580,6 +2703,8 @@ snapshots:
|
||||
|
||||
hono@4.11.3: {}
|
||||
|
||||
hono@4.12.0: {}
|
||||
|
||||
http-errors@2.0.0:
|
||||
dependencies:
|
||||
depd: 2.0.0
|
||||
@@ -2604,6 +2729,8 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ip-address@10.0.1: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
@@ -2713,6 +2840,10 @@ snapshots:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
range-parser@1.2.1: {}
|
||||
|
||||
raw-body@3.0.1:
|
||||
|
||||
@@ -41283,6 +41283,7 @@ var package_default = {
|
||||
turndown: "^7.2.0"
|
||||
},
|
||||
devDependencies: {
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/turndown": "^5.0.5",
|
||||
@@ -41414,20 +41415,20 @@ The workflow encountered an error before any progress could be reported. Please
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
|
||||
if (!promptInput?.progressCommentId) {
|
||||
async function validateStuckProgressComment(params) {
|
||||
if (!params.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
const commentId = parseInt(promptInput.progressCommentId, 10);
|
||||
const commentId = parseInt(params.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
try {
|
||||
const { data: comment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
const commentResult = await params.octokit.rest.issues.getComment({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
comment_id: commentId
|
||||
});
|
||||
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
@@ -41442,13 +41443,15 @@ async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
|
||||
async function getIsCancelled(params) {
|
||||
if (!params.runIdStr) return false;
|
||||
try {
|
||||
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10)
|
||||
});
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName ? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)) : jobs.jobs[0];
|
||||
const currentJob = currentJobName ? jobsResult.data.jobs.find(
|
||||
(j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)
|
||||
) : jobsResult.data.jobs[0];
|
||||
if (!currentJob) {
|
||||
log.warning("[post] could not find current job");
|
||||
return false;
|
||||
@@ -41483,12 +41486,12 @@ async function runPostCleanup() {
|
||||
const token = getJobToken();
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
const commentId = await validateStuckProgressComment(
|
||||
const commentId = await validateStuckProgressComment({
|
||||
promptInput,
|
||||
octokit,
|
||||
repoContext.owner,
|
||||
repoContext.name
|
||||
);
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name
|
||||
});
|
||||
if (!commentId) return log.info("\xBB [post] no stuck progress comment to update, skipping cleanup");
|
||||
log.info(`\xBB [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-ask-question — orchestrator uses ask_question to gather codebase
|
||||
* info, then uses that answer to craft a targeted delegation.
|
||||
*
|
||||
* tests the ask_question → delegate pipeline: information gathering first,
|
||||
* then action based on gathered context. this validates that the orchestrator
|
||||
* can chain ask_question and delegate as a two-step workflow.
|
||||
*/
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. Your task has TWO steps:
|
||||
|
||||
STEP 1 — GATHER INFO:
|
||||
Use gh_pullfrog/ask_question to ask: "What files are in the root directory of this repository? List them."
|
||||
|
||||
STEP 2 — DELEGATE WITH CONTEXT:
|
||||
After receiving the answer, select Plan mode via select_mode, then delegate to a subagent with mini effort.
|
||||
Your subagent instructions MUST include:
|
||||
- The list of files you learned about from step 1
|
||||
- Tell the subagent to call gh_pullfrog/set_output with EXACTLY this format: "FILES_FOUND=true,COUNT=<N>" where <N> is the number of files from the list you gave it
|
||||
- Do NOT create any branches, commits, or PRs
|
||||
|
||||
After delegation completes, call set_output yourself with the subagent's result.
|
||||
|
||||
IMPORTANT: You MUST use ask_question BEFORE delegating. The subagent prompt must reference specific files from the ask_question answer.`,
|
||||
effort: "auto",
|
||||
timeout: "10m",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
const hasFilesFound = setOutputCalled && /FILES_FOUND=true/i.test(output ?? "");
|
||||
const countMatch = output ? /COUNT=(\d+)/i.exec(output) : null;
|
||||
const hasFileCount = countMatch !== null && parseInt(countMatch[1], 10) > 0;
|
||||
const askQuestionUsed = /» ask_question subagent=/i.test(agentOutput);
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "files_found", passed: hasFilesFound },
|
||||
{ name: "file_count", passed: hasFileCount },
|
||||
{ name: "ask_question_used", passed: askQuestionUsed },
|
||||
{ name: "delegation_occurred", passed: delegationOccurred },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-ask-question",
|
||||
fixture,
|
||||
validator,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-context-isolation — verifies that the subagent's "clean room"
|
||||
* actually works: the orchestrator receives a secret marker in its prompt but
|
||||
* MUST NOT include it in the subagent's instructions.
|
||||
*
|
||||
* the subagent is told to report everything it knows about the task context.
|
||||
* the validator checks that the secret marker does NOT appear in the subagent's
|
||||
* reported context, proving it only sees what the orchestrator explicitly passes.
|
||||
*
|
||||
* this is a critical architectural property of the delegation system.
|
||||
*/
|
||||
|
||||
const SECRET = randomUUID();
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. READ CAREFULLY.
|
||||
|
||||
Your SECRET value is: ${SECRET}
|
||||
You MUST remember this secret and include it in your OWN final set_output call.
|
||||
You MUST NOT include this secret in the subagent's instructions.
|
||||
|
||||
Your task:
|
||||
1. Select Plan mode via select_mode.
|
||||
2. Delegate to a subagent with mini effort. Your subagent instructions should be EXACTLY:
|
||||
"You are a research subagent. Your only task is to report what you know. Call gh_pullfrog/set_output with a summary of: (a) the instructions you were given, (b) any context you have about the parent task. Start your output with CONTEXT_REPORT:"
|
||||
DO NOT mention the secret value anywhere in the subagent instructions.
|
||||
3. After delegation, call set_output with: "SECRET=${SECRET},SUBAGENT_SAID=<paste the subagent's output here>"
|
||||
|
||||
CRITICAL: Your final output MUST contain "SECRET=${SECRET}" exactly.`,
|
||||
effort: "auto",
|
||||
timeout: "8m",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
// orchestrator should include at least the first segment of the UUID (proving it read it).
|
||||
// some agents paraphrase or truncate, so matching the first 8 hex chars is sufficient.
|
||||
const secretPrefix = SECRET.slice(0, 8);
|
||||
const secretInOutput = setOutputCalled && output !== null && output.includes(secretPrefix);
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
|
||||
// the subagent's context report should NOT contain any part of the secret
|
||||
const subagentMatch = output ? /SUBAGENT_SAID=([\s\S]*)/i.exec(output) : null;
|
||||
const subagentOutput = subagentMatch ? subagentMatch[1] : "";
|
||||
const secretLeaked = subagentOutput.includes(secretPrefix);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "secret_in_output", passed: secretInOutput },
|
||||
{ name: "delegation_occurred", passed: delegationOccurred },
|
||||
{ name: "no_secret_leak", passed: !secretLeaked },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-context-isolation",
|
||||
fixture,
|
||||
validator,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-error-handling — orchestrator delegates a task that will fail,
|
||||
* then must handle the failure gracefully and report it.
|
||||
*
|
||||
* the subagent is told to read a file that doesn't exist, which will cause
|
||||
* file_read to return an error. the orchestrator should detect the subagent
|
||||
* failure (via the delegate tool's return value) and report it clearly.
|
||||
*
|
||||
* tests error propagation through the delegation system and the orchestrator's
|
||||
* ability to reason about failure modes rather than blindly forwarding results.
|
||||
*/
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. This test validates error handling.
|
||||
|
||||
1. Select Plan mode via select_mode.
|
||||
2. Delegate to a subagent with mini effort. Subagent instructions:
|
||||
"Use gh_pullfrog/file_read to read the file 'this-file-does-not-exist-anywhere.xyz'. Report what you find by calling gh_pullfrog/set_output with the file content. If the file cannot be read, call gh_pullfrog/set_output with 'FILE_NOT_FOUND'."
|
||||
3. After the delegation completes, examine the result. The subagent should have reported FILE_NOT_FOUND or an error.
|
||||
4. Call set_output with EXACTLY: "ERROR_HANDLED=true,REASON=<brief description of what went wrong>"
|
||||
|
||||
If the delegation failed entirely (subagent crashed), still call set_output with "ERROR_HANDLED=true,REASON=delegation_failed".
|
||||
|
||||
The point of this test is that you handle the error gracefully and report it — not that you succeed at reading the file.`,
|
||||
effort: "auto",
|
||||
timeout: "8m",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
const errorHandled = setOutputCalled && /ERROR_HANDLED=true/i.test(output ?? "");
|
||||
const hasReason = setOutputCalled && /REASON=\S+/i.test(output ?? "");
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "error_handled", passed: errorHandled },
|
||||
{ name: "reason_provided", passed: hasReason },
|
||||
{ name: "delegation_occurred", passed: delegationOccurred },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-error-handling",
|
||||
fixture,
|
||||
validator,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-file-read — orchestrator delegates a subagent to read a real file
|
||||
* from the repository and return its content.
|
||||
*
|
||||
* tests the full delegation pipeline: mode selection → prompt crafting with MCP
|
||||
* tool references → subagent file read → result propagation back to orchestrator.
|
||||
*
|
||||
* unlike the basic delegate test (which just echoes a hardcoded string), this
|
||||
* requires the subagent to actually use MCP tools (file_read) to interact with
|
||||
* the repo and return derived data.
|
||||
*/
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. Your task:
|
||||
|
||||
1. Select the Plan mode via select_mode.
|
||||
2. Delegate to a subagent with mini effort. Craft instructions telling it to:
|
||||
- Use gh_pullfrog/file_read to read the file "README.md" from the repository root
|
||||
- Count the total number of lines in the file
|
||||
- Call gh_pullfrog/set_output with EXACTLY this format: "LINES=<number>" where <number> is the line count (e.g., "LINES=42")
|
||||
- Do NOT create any branches, commits, or PRs
|
||||
3. After the delegation completes, call set_output with the subagent's result (the LINES=<number> string).
|
||||
|
||||
IMPORTANT: Your subagent prompt must include the exact MCP tool names (gh_pullfrog/file_read, gh_pullfrog/set_output).`,
|
||||
effort: "auto",
|
||||
timeout: "8m",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
const linesMatch = output ? /LINES=(\d+)/i.exec(output) : null;
|
||||
const hasLineCount = linesMatch !== null && parseInt(linesMatch[1], 10) > 0;
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "line_count_reported", passed: hasLineCount },
|
||||
{ name: "delegation_occurred", passed: delegationOccurred },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-file-read",
|
||||
fixture,
|
||||
validator,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-synthesis — orchestrator delegates two research tasks to separate
|
||||
* subagents, then synthesizes their results into a combined answer.
|
||||
*
|
||||
* phase 1: subagent reads README.md and extracts the first line.
|
||||
* phase 2: subagent counts how many .md files exist via list_directory.
|
||||
* synthesis: orchestrator combines both pieces of info into the final output.
|
||||
*
|
||||
* this tests the orchestrator's ability to:
|
||||
* - run multiple sequential delegations
|
||||
* - pass specific, different instructions to each subagent
|
||||
* - extract and combine results from separate delegation phases
|
||||
* - produce a structured final output from heterogeneous subagent responses
|
||||
*/
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. You must delegate TWO research tasks and SYNTHESIZE the results.
|
||||
|
||||
PHASE 1 — GET FIRST LINE:
|
||||
Select Plan mode via select_mode, then delegate with mini effort.
|
||||
Subagent instructions: "Use gh_pullfrog/file_read to read 'README.md'. Extract the FIRST LINE of the file. Call gh_pullfrog/set_output with just the first line of text (nothing else)."
|
||||
|
||||
PHASE 2 — COUNT FILES:
|
||||
Select Plan mode again, then delegate with mini effort.
|
||||
Subagent instructions: "Use gh_pullfrog/list_directory to list the root directory '.'. Count how many items are listed. Call gh_pullfrog/set_output with just the number (nothing else)."
|
||||
|
||||
SYNTHESIS:
|
||||
After both phases complete, YOU (the orchestrator) must call set_output with EXACTLY:
|
||||
"FIRST_LINE=<first line from phase 1>,FILE_COUNT=<number from phase 2>"
|
||||
|
||||
Both pieces must come from the respective subagent results. Do NOT read the files yourself.`,
|
||||
effort: "auto",
|
||||
timeout: "10m",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
|
||||
// should have two delegation calls
|
||||
const delegationMatches = agentOutput.match(/» delegating subagent=/g);
|
||||
const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2;
|
||||
|
||||
// FIRST_LINE should be a non-empty string (the first line of README.md)
|
||||
const firstLineMatch = output ? /FIRST_LINE=([^,]+)/i.exec(output) : null;
|
||||
const hasFirstLine = firstLineMatch !== null && firstLineMatch[1].trim().length > 0;
|
||||
|
||||
// FILE_COUNT should be a positive number
|
||||
const countMatch = output ? /FILE_COUNT=(\d+)/i.exec(output) : null;
|
||||
const hasFileCount = countMatch !== null && parseInt(countMatch[1], 10) > 0;
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "two_delegations", passed: twoDelegations },
|
||||
{ name: "first_line_extracted", passed: hasFirstLine },
|
||||
{ name: "file_count_extracted", passed: hasFileCount },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-synthesis",
|
||||
fixture,
|
||||
validator,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -12,8 +12,8 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `Delegate to the Plan mode with auto effort. Pass these instructions to the subagent:
|
||||
"Carefully analyze the following engineering question and provide a thorough response, then call set_output with the value 'DELEGATE_TIMEOUT_PASSED'.
|
||||
prompt: `Select the Plan mode via select_mode, then delegate with auto effort. Your subagent instructions should be:
|
||||
"Carefully analyze the following engineering question. Think through each point thoroughly before finishing.
|
||||
|
||||
Question: Design a comprehensive error handling strategy for a distributed microservices architecture. Consider:
|
||||
1. Circuit breaker patterns — when to open, half-open, close. What thresholds to use.
|
||||
@@ -22,7 +22,9 @@ Question: Design a comprehensive error handling strategy for a distributed micro
|
||||
4. Health check endpoints — liveness vs readiness probes, dependency health checks.
|
||||
5. Graceful degradation — fallback responses, feature flags, bulkhead pattern.
|
||||
|
||||
Provide a detailed analysis covering ALL 5 points with concrete examples before calling set_output."`,
|
||||
After you have finished your analysis, call gh_pullfrog/set_output with EXACTLY the string 'DELEGATE_TIMEOUT_PASSED' — not your analysis, just that exact string."
|
||||
|
||||
After the delegation completes, call set_output yourself with the subagent's result (forward it verbatim).`,
|
||||
effort: "auto",
|
||||
timeout: "8m",
|
||||
},
|
||||
@@ -35,8 +37,7 @@ function validator(result: AgentResult): ValidationCheck[] {
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
const correctValue = setOutputCalled && /DELEGATE_TIMEOUT_PASSED/i.test(output);
|
||||
const delegationOccurred = /» delegating to \w+ mode/i.test(agentOutput);
|
||||
// the critical check: no activity timeout occurred
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
const noActivityTimeout = !/activity timeout/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AgentResult, TestRunnerOptions, ValidationCheck } from "../utils.ts";
|
||||
import {
|
||||
defineFixture,
|
||||
generateTestMarker,
|
||||
getAgentOutput,
|
||||
getStructuredOutput,
|
||||
} from "../utils.ts";
|
||||
|
||||
/**
|
||||
* delegate-two-phase — orchestrator runs two sequential delegations where
|
||||
* the second phase depends on state created by the first.
|
||||
*
|
||||
* phase 1: subagent writes a file with a unique marker.
|
||||
* phase 2: subagent reads the file and reports its content.
|
||||
*
|
||||
* tests that file state persists across delegation phases (both subagents
|
||||
* run in the same working directory) and that the orchestrator correctly
|
||||
* chains phases by passing context from phase 1 into phase 2's instructions.
|
||||
*/
|
||||
|
||||
const marker = generateTestMarker("PULLFROG_PHASE_MARKER");
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `You are an orchestrator. You must run TWO sequential delegation phases.
|
||||
|
||||
First, read the marker value: run echo $PULLFROG_PHASE_MARKER
|
||||
|
||||
PHASE 1 — WRITE:
|
||||
Select Plan mode via select_mode, then delegate with mini effort.
|
||||
Subagent instructions: "Use gh_pullfrog/file_write to write a file called 'delegation-test.txt' with the content '<MARKER_VALUE>'. Then call gh_pullfrog/set_output with 'PHASE1_DONE'. Do not create branches or PRs."
|
||||
(Replace <MARKER_VALUE> with the actual marker value you read.)
|
||||
|
||||
PHASE 2 — READ AND VERIFY:
|
||||
After Phase 1 completes, select Plan mode again and delegate with mini effort.
|
||||
Subagent instructions: "Use gh_pullfrog/file_read to read the file 'delegation-test.txt'. Call gh_pullfrog/set_output with the EXACT content of the file. Do not create branches or PRs."
|
||||
|
||||
After both phases complete, call set_output with: "WRITTEN=<marker>,READ=<what_phase2_returned>"`,
|
||||
effort: "auto",
|
||||
timeout: "10m",
|
||||
bash: "enabled",
|
||||
},
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const output = getStructuredOutput(result);
|
||||
const agentOutput = getAgentOutput(result);
|
||||
const secret = marker.value;
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
|
||||
// two delegation calls should appear in logs
|
||||
const delegationMatches = agentOutput.match(/» delegating subagent=/g);
|
||||
const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2;
|
||||
|
||||
// the marker should appear in both WRITTEN= and READ= sections.
|
||||
// use greedy match for READ= since subagents may prefix with "content:" etc.
|
||||
const writtenMatch = output ? /WRITTEN=([^\s,]+)/i.exec(output) : null;
|
||||
const markerWritten = writtenMatch?.[1].includes(secret) ?? false;
|
||||
const readSection = output ? /READ=(.+)/i.exec(output) : null;
|
||||
const markerRead = readSection?.[1].includes(secret) ?? false;
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
{ name: "two_delegations", passed: twoDelegations },
|
||||
{ name: "marker_written", passed: markerWritten },
|
||||
{ name: "marker_read_back", passed: markerRead },
|
||||
];
|
||||
}
|
||||
|
||||
export const test: TestRunnerOptions = {
|
||||
name: "delegate-two-phase",
|
||||
fixture,
|
||||
validator,
|
||||
agentEnv: marker.agentEnv,
|
||||
env: { GITHUB_REPOSITORY: "pullfrog/test-repo" },
|
||||
tags: ["adhoc"],
|
||||
};
|
||||
@@ -4,14 +4,14 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"
|
||||
/**
|
||||
* delegate test - validates core end-to-end delegation flow.
|
||||
*
|
||||
* the orchestrator delegates to Plan mode with mini effort, passing instructions
|
||||
* that tell the subagent to call set_output with a specific value.
|
||||
* the orchestrator selects Plan mode, then delegates with mini effort, passing
|
||||
* instructions that tell the subagent to call set_output with a specific value.
|
||||
* validates that the subagent executed and the result flows back.
|
||||
*/
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `Delegate to the Plan mode with mini effort. Pass these instructions to the subagent:
|
||||
prompt: `Select the Plan mode via select_mode, then delegate with mini effort. Your subagent instructions should be:
|
||||
"This is a delegation test. Your only task is to call set_output with the value 'DELEGATE_BASIC_PASSED'. Do not create plans, branches, or PRs. Just call set_output."`,
|
||||
effort: "mini",
|
||||
timeout: "5m",
|
||||
@@ -25,8 +25,7 @@ function validator(result: AgentResult): ValidationCheck[] {
|
||||
|
||||
const setOutputCalled = output !== null;
|
||||
const correctValue = setOutputCalled && /DELEGATE_BASIC_PASSED/i.test(output);
|
||||
// check for the specific log line emitted by the delegate tool handler
|
||||
const delegationOccurred = /» delegating to \w+ mode/i.test(agentOutput);
|
||||
const delegationOccurred = /» delegating subagent=/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
|
||||
@@ -4,7 +4,7 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"
|
||||
/**
|
||||
* delegateEffort test - validates effort selection for delegation.
|
||||
*
|
||||
* the orchestrator delegates to Plan mode with mini effort.
|
||||
* the orchestrator selects Plan mode, then delegates with mini effort.
|
||||
* validates that the subagent runs at mini effort (visible in agent logs
|
||||
* as "effort=mini" or sonnet model selection for claude).
|
||||
*/
|
||||
@@ -14,8 +14,8 @@ import { defineFixture, getAgentOutput, getStructuredOutput } from "../utils.ts"
|
||||
// the model selection — if it were ignored, the subagent would also run at auto.
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `This is a simple task. Delegate to the Plan mode with MINI effort (this is a trivial task).
|
||||
Pass these instructions to the subagent:
|
||||
prompt: `This is a simple task. Select the Plan mode via select_mode, then delegate with MINI effort (this is a trivial task).
|
||||
Your subagent instructions should be:
|
||||
"Call set_output with the value 'EFFORT_TEST_PASSED'. Do not create plans or PRs. Just call set_output."`,
|
||||
effort: "auto",
|
||||
timeout: "5m",
|
||||
|
||||
@@ -15,10 +15,10 @@ const fixture = defineFixture(
|
||||
{
|
||||
prompt: `This is a multi-delegation test. You must delegate exactly twice:
|
||||
|
||||
Phase 1: Delegate to Plan mode with mini effort. Pass these instructions:
|
||||
Phase 1: Select Plan mode via select_mode, then delegate with mini effort. Your subagent instructions:
|
||||
"Your task is to call set_output with the value 'PHASE_1_MARKER'. Do not create plans or PRs."
|
||||
|
||||
Phase 2: After Phase 1 completes, delegate to Plan mode again with mini effort. Pass these instructions (include the result from Phase 1 as context):
|
||||
Phase 2: After Phase 1 completes, select Plan mode again and delegate with mini effort. Include the result from Phase 1. Your subagent instructions:
|
||||
"Your task is to call set_output with the value 'MULTI_DELEGATE_PASSED'. Do not create plans or PRs."
|
||||
|
||||
Both delegations must complete successfully.`,
|
||||
@@ -36,8 +36,7 @@ function validator(result: AgentResult): ValidationCheck[] {
|
||||
// the last set_output call wins — should be from Phase 2
|
||||
const finalValue = setOutputCalled && /MULTI_DELEGATE_PASSED/i.test(output);
|
||||
|
||||
// count delegation evidence — match the exact log line format from the delegate handler
|
||||
const delegationMatches = agentOutput.match(/» delegating to/g);
|
||||
const delegationMatches = agentOutput.match(/» delegating subagent=/g);
|
||||
const twoDelegations = delegationMatches !== null && delegationMatches.length >= 2;
|
||||
|
||||
return [
|
||||
|
||||
@@ -9,8 +9,7 @@ import { defineFixture } from "../utils.ts";
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `Call the delegate tool with mode "Build" and effort "mini", then analyze the result.
|
||||
Then call delegate with mode "Review" and effort "mini".
|
||||
prompt: `Select the Build mode via select_mode, then delegate with mini effort. After that completes, select Review mode and delegate again with mini effort.
|
||||
Finally call set_output with "TIMEOUT TEST COMPLETED".`,
|
||||
timeout: "5s",
|
||||
effort: "mini",
|
||||
|
||||
@@ -35,11 +35,13 @@ while IFS= read -r file; do
|
||||
done <<< "$files"
|
||||
|
||||
# output agents based on change type.
|
||||
# non-agent action changes run claude as a canary.
|
||||
# non-agent action changes always include claude as a canary.
|
||||
if $has_non_agent_change; then
|
||||
changed_agents+=("claude")
|
||||
fi
|
||||
|
||||
if [[ ${#changed_agents[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${changed_agents[@]}" | sort -u | jq -R . | jq -sc .
|
||||
elif $has_non_agent_change; then
|
||||
echo '["claude"]'
|
||||
else
|
||||
echo '[]'
|
||||
fi
|
||||
|
||||
@@ -107,6 +107,14 @@ describe("ci workflow consistency", () => {
|
||||
expect(JSON.parse(output)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("changed-agents.sh includes claude canary alongside changed agents", () => {
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input: JSON.stringify(["action/agents/gemini.ts", "action/mcp/server.ts"]),
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["claude", "gemini"]);
|
||||
});
|
||||
|
||||
it("action agent matrix matches agentsManifest", () => {
|
||||
expect([...actionJob.strategy!.matrix.agent].sort()).toEqual(expectedAgents);
|
||||
});
|
||||
|
||||
+5
-7
@@ -254,13 +254,11 @@ function shouldRetry(result: AgentResult, validation: ValidationResult): RetryDe
|
||||
if (setOutputCheck && !setOutputCheck.passed) {
|
||||
// if the output contains rate limit indicators, use the longer backoff
|
||||
// (the agent process may have succeeded but the subagent hit quota limits)
|
||||
const backoffMs = isRateLimited(result.output) ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS;
|
||||
const rateLimited = isRateLimited(result.output);
|
||||
return {
|
||||
retry: true,
|
||||
reason: isRateLimited(result.output)
|
||||
? "rate limited (set_output cascade)"
|
||||
: "set_output not called (cascade)",
|
||||
backoffMs,
|
||||
reason: rateLimited ? "rate limited (set_output cascade)" : "set_output not called (cascade)",
|
||||
backoffMs: rateLimited ? RATE_LIMIT_BACKOFF_MS : FLAKY_RETRY_BACKOFF_MS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,9 +310,9 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
|
||||
}
|
||||
|
||||
// gemini: use flash for all tests (including mini-effort) to avoid pro quota limits
|
||||
// gemini: use 2.5 pro for testing
|
||||
if (ctx.agent === "gemini") {
|
||||
env.GEMINI_MODEL ??= "gemini-3-flash-preview";
|
||||
env.GEMINI_MODEL ??= "gemini-2.5-pro";
|
||||
}
|
||||
|
||||
// build file-based env vars for MCP servers that don't inherit parent env
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { log } from "./log.ts";
|
||||
|
||||
export const DEFAULT_ACTIVITY_TIMEOUT_MS = 60_000;
|
||||
export const DEFAULT_ACTIVITY_TIMEOUT_MS = 300_000;
|
||||
export const DEFAULT_ACTIVITY_CHECK_INTERVAL_MS = 5_000;
|
||||
|
||||
type ActivityTimeoutContext = {
|
||||
|
||||
+7
-1
@@ -6,7 +6,13 @@ import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
// re-export logging utilities for backward compatibility
|
||||
export { formatIndentedField, formatJsonValue, log, writeSummary } from "./log.ts";
|
||||
export {
|
||||
formatIndentedField,
|
||||
formatJsonValue,
|
||||
formatUsageSummary,
|
||||
log,
|
||||
writeSummary,
|
||||
} from "./log.ts";
|
||||
|
||||
/**
|
||||
* Finds a CLI executable path by checking if it's installed globally
|
||||
|
||||
+15
-10
@@ -80,7 +80,9 @@ export async function installFromNpmTarball(params: InstallFromNpmTarballParams)
|
||||
|
||||
log.debug(`» installing ${params.packageName}@${resolvedVersion}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
|
||||
|
||||
const tarballPath = join(tempDir, "package.tgz");
|
||||
|
||||
// Download tarball from npm
|
||||
@@ -302,7 +304,9 @@ export async function installFromGithubTarball(
|
||||
|
||||
log.debug(`» downloading asset from ${assetUrl}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
|
||||
|
||||
const tarballPath = join(tempDir, assetName);
|
||||
|
||||
// download the asset
|
||||
@@ -349,13 +353,12 @@ export async function installFromDirectTarball(
|
||||
): Promise<string> {
|
||||
log.info(`» downloading tarball from ${params.url}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
|
||||
|
||||
const tarballPath = join(tempDir, "direct-package.tgz");
|
||||
|
||||
const response = await fetch(params.url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to download tarball: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
const response = await fetchWithRetry(params.url, {}, "failed to download tarball");
|
||||
if (!response.body) throw new Error("response body is null");
|
||||
|
||||
const fileStream = createWriteStream(tarballPath);
|
||||
@@ -367,8 +370,8 @@ export async function installFromDirectTarball(
|
||||
mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
const tarArgs = ["-xzf", tarballPath, "-C", extractDir];
|
||||
if (params.stripComponents) {
|
||||
tarArgs.push(`--strip-components=${params.stripComponents}`);
|
||||
if (params.stripComponents !== undefined && params.stripComponents > 0) {
|
||||
tarArgs.push(`--strip-components=${Math.floor(params.stripComponents)}`);
|
||||
}
|
||||
|
||||
log.debug(`» extracting tarball...`);
|
||||
@@ -401,7 +404,9 @@ export async function installFromDirectTarball(
|
||||
export async function installFromCurl(params: InstallFromCurlParams): Promise<string> {
|
||||
log.info(`» installing ${params.executableName}...`);
|
||||
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR!;
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
|
||||
|
||||
const installScriptPath = join(tempDir, "install.sh");
|
||||
|
||||
// Download the install script
|
||||
|
||||
+33
-101
@@ -167,7 +167,7 @@ Protected branches (default branch) are blocked from direct pushes in restricted
|
||||
|
||||
**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally.
|
||||
|
||||
**GitHub** — Prefer using MCP tools from ${ghPullfrogMcpName} for GitHub operations. The \`gh\` CLI is available as a fallback if needed, but MCP tools handle authentication and provide better integration.
|
||||
**GitHub** — Use MCP tools from ${ghPullfrogMcpName} for all GitHub operations. Never use the \`gh\` CLI — it is not authenticated and will fail. The MCP tools handle authentication, enforce permissions, and integrate with the delegation system.
|
||||
|
||||
|
||||
**Efficiency**: 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.
|
||||
@@ -208,15 +208,6 @@ In case of conflict between instructions, follow this precedence (highest to low
|
||||
3. Event-level instructions
|
||||
4. Repo-level instructions`;
|
||||
|
||||
const subagentPriorityOrder = `## Priority Order
|
||||
|
||||
In case of conflict between instructions, follow this precedence (highest to lowest):
|
||||
1. Security rules and system instructions (non-overridable)
|
||||
2. User prompt
|
||||
3. Orchestrator context
|
||||
4. Event-level instructions
|
||||
5. Repo-level instructions`;
|
||||
|
||||
export interface ResolvedInstructions {
|
||||
full: string;
|
||||
system: string;
|
||||
@@ -235,7 +226,6 @@ interface ContextSectionsInput {
|
||||
eventTitleBody: string;
|
||||
eventMetadata: string;
|
||||
userQuoted: string;
|
||||
orchestratorSection?: string | undefined;
|
||||
}
|
||||
|
||||
function buildContextSections(ctx: ContextSectionsInput): string {
|
||||
@@ -254,12 +244,6 @@ ${ctx.repo}`
|
||||
${ctx.eventInstructions}`
|
||||
: "";
|
||||
|
||||
const orchestratorSection = ctx.orchestratorSection
|
||||
? `************* ORCHESTRATOR CONTEXT *************
|
||||
|
||||
${ctx.orchestratorSection}`
|
||||
: "";
|
||||
|
||||
const titleBodySection = ctx.eventTitleBody ? `${relatedLabel}\n\n${ctx.eventTitleBody}` : "";
|
||||
const metadataSection = ctx.eventMetadata ? `--- event context ---\n\n${ctx.eventMetadata}` : "";
|
||||
|
||||
@@ -277,9 +261,7 @@ ${titleBodySection}
|
||||
|
||||
${metadataSection}`;
|
||||
|
||||
return [repoSection, orchestratorSection, eventInstructionsSection, userSection]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
return [repoSection, eventInstructionsSection, userSection].filter(Boolean).join("\n\n");
|
||||
}
|
||||
|
||||
// shared computation for all instruction builders
|
||||
@@ -340,42 +322,48 @@ ${ctx.contextSections}`;
|
||||
export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructions {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
|
||||
const orchestratorTaskSection = `**Required!** You are an orchestrator. Evaluate the task below, then delegate to specialized subagents using \`${ghPullfrogMcpName}/delegate\`.
|
||||
const orchestratorTaskSection = `**Required!** You are an orchestrator. Evaluate the task below, then delegate to specialized subagents.
|
||||
|
||||
### How to delegate
|
||||
### Step 1: Select a mode
|
||||
|
||||
Call \`delegate\` with a mode, effort level, and optional instructions:
|
||||
- \`mode\`: The workflow to run (see available modes below)
|
||||
- \`effort\`:
|
||||
- \`"mini"\`: low-effort and fast, for simple tasks
|
||||
- \`"auto"\`: medium-effort, good for typical tasks that don't require significant reasoning
|
||||
- \`"max"\`: high-effort, good for PR reviews and complex coding tasks.
|
||||
- \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus.
|
||||
Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns orchestrator-level guidance on how to handle the task — including suggested delegation phases and prompt-crafting tips.
|
||||
|
||||
### Single vs. multi-phase delegation
|
||||
Available modes:
|
||||
${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
|
||||
|
||||
**Single delegation** (most common): Evaluate the task, pick the right mode and effort, delegate once. This is the default for most tasks.
|
||||
### Step 2: Craft subagent prompts and delegate
|
||||
|
||||
**Multi-phase delegation** (for complex tasks that benefit from distinct phases):
|
||||
- Plan then Build: delegate to Plan, read the result, then delegate to Build with the plan as instructions
|
||||
- Review then Build: delegate to Review for analysis, then delegate to Build to address the findings
|
||||
- Any combination that makes sense for the task
|
||||
Based on the guidance from select_mode, craft a focused, self-contained prompt for each subagent, then call \`${ghPullfrogMcpName}/delegate\` with:
|
||||
- \`instructions\`: Your crafted prompt. **The subagent receives ONLY this text — no other context is added.** Include everything it needs: file paths, constraints, conventions, tool usage instructions, and any relevant context from the codebase or previous phases.
|
||||
- \`effort\`: \`"mini"\` (simple tasks), \`"auto"\` (typical tasks), or \`"max"\` (complex tasks requiring deep reasoning).
|
||||
|
||||
After each delegation, you receive the subagent's result. Use it to decide whether to delegate again and what context to pass.
|
||||
### Step 3: Post-delegation (your responsibility)
|
||||
|
||||
### Effort guidelines
|
||||
After each delegation, you receive the subagent's summary (via set_output) and a path to its full stdout log (which you can inspect via \`${ghPullfrogMcpName}/file_read\` if needed). Use this to decide whether to delegate again or finalize.
|
||||
|
||||
- \`"auto"\` (default): Use for most tasks. Maps to the most capable model.
|
||||
- \`"mini"\`: Simple, mechanical tasks — issue labeling, adding a comment, trivial changes.
|
||||
- \`"max"\`: Deep architectural analysis, complex debugging, tasks requiring maximum reasoning.
|
||||
**Remote operations are YOUR job.** Subagents do NOT have push, PR creation, or other remote-mutating tools. After a subagent that makes code changes completes, you must:
|
||||
- Push the branch via \`${ghPullfrogMcpName}/push_branch\`
|
||||
- Create a PR via \`${ghPullfrogMcpName}/create_pull_request\` (if needed)
|
||||
- Call \`${ghPullfrogMcpName}/report_progress\` with the final summary including PR links
|
||||
|
||||
When all delegations are complete, call \`${ghPullfrogMcpName}/set_output\` with the final result — the last subagent's summary or a synthesis of all phases. This is required: it makes the result available as the GitHub Action output for downstream steps.
|
||||
|
||||
### Information gathering
|
||||
|
||||
Use \`${ghPullfrogMcpName}/ask_question\` to spawn a lightweight subagent that answers a specific question about the codebase. The intermediate exploration context stays in the subagent — only the concise answer returns to you.
|
||||
|
||||
### Prompt-crafting rules
|
||||
|
||||
- Your subagent has NO context beyond what you write. No repo instructions, no event instructions, no user prompt — only your crafted instructions.
|
||||
- Include MCP tool names when the subagent needs them (e.g., "commit via \`${ghPullfrogMcpName}/git\`").
|
||||
- Subagents do NOT have \`push_branch\`, \`create_pull_request\`, \`update_pull_request_body\`, \`delete_branch\`, or \`push_tags\`. Never instruct a subagent to push or create PRs — that is your job as the orchestrator.
|
||||
- Include branch naming conventions, testing expectations, and commit instructions when relevant.
|
||||
- For multi-phase flows, pass results from earlier phases directly into the next subagent's prompt.
|
||||
- The subagent should call \`${ghPullfrogMcpName}/set_output\` with a concise summary when done (include the branch name if code changes were made).
|
||||
|
||||
### No-action cases
|
||||
|
||||
If the task clearly requires no work (e.g., irrelevant event, duplicate request), you may skip delegation entirely. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.
|
||||
|
||||
### Available modes
|
||||
|
||||
${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}`;
|
||||
If the task clearly requires no work (e.g., irrelevant event, duplicate request), skip delegation entirely. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
|
||||
|
||||
const system = buildSystemPrompt({
|
||||
bash: ctx.payload.bash,
|
||||
@@ -409,59 +397,3 @@ ${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}`;
|
||||
runtime: inputs.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
// --- subagent instructions (used by delegate tool) ---
|
||||
|
||||
interface SubagentInstructionsContext extends InstructionsContext {
|
||||
mode: Mode;
|
||||
orchestratorInstructions: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveSubagentInstructions(
|
||||
ctx: SubagentInstructionsContext
|
||||
): ResolvedInstructions {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
|
||||
|
||||
### Delegation rules
|
||||
|
||||
- The \`delegate\` tool is NOT available to you — complete your task directly using the available tools.
|
||||
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
|
||||
- If you encounter an error you cannot resolve, report it clearly — do not attempt to delegate or re-run yourself.
|
||||
|
||||
${ctx.mode.prompt}`;
|
||||
|
||||
const system = buildSystemPrompt({
|
||||
bash: ctx.payload.bash,
|
||||
trigger: ctx.payload.event.trigger,
|
||||
priorityOrder: subagentPriorityOrder,
|
||||
taskSection: subagentTaskSection,
|
||||
});
|
||||
|
||||
const contextSections = buildContextSections({
|
||||
payload: ctx.payload,
|
||||
repo: inputs.repo,
|
||||
eventInstructions: inputs.eventInstructions,
|
||||
eventTitleBody: inputs.eventTitleBody,
|
||||
eventMetadata: inputs.eventMetadata,
|
||||
userQuoted: inputs.userQuoted,
|
||||
orchestratorSection: ctx.orchestratorInstructions,
|
||||
});
|
||||
|
||||
const full = assembleFullPrompt({
|
||||
runtime: inputs.runtime,
|
||||
system,
|
||||
contextSections,
|
||||
});
|
||||
|
||||
return {
|
||||
full,
|
||||
system,
|
||||
user: inputs.user,
|
||||
eventInstructions: inputs.eventInstructions,
|
||||
repo: inputs.repo,
|
||||
event: inputs.event,
|
||||
runtime: inputs.runtime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { table } from "table";
|
||||
import type { AgentUsage } from "../agents/shared.ts";
|
||||
import { isGitHubActions, isInsideDocker } from "./globals.ts";
|
||||
|
||||
const isRunnerDebugEnabled = () => core.isDebug();
|
||||
@@ -298,3 +299,58 @@ export function formatIndentedField(label: string, content: string): string {
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* format aggregated usage data as a markdown table for the GitHub step summary
|
||||
*/
|
||||
export function formatUsageSummary(entries: AgentUsage[]): string {
|
||||
if (entries.length === 0) return "";
|
||||
|
||||
const hasCost = entries.some((e) => e.costUsd !== undefined);
|
||||
|
||||
const header = hasCost
|
||||
? "| Agent | Input | Output | Cache Read | Cache Write | Cost |"
|
||||
: "| Agent | Input | Output | Cache Read | Cache Write |";
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString("en-US");
|
||||
|
||||
const separatorRow = hasCost
|
||||
? "| --- | ---: | ---: | ---: | ---: | ---: |"
|
||||
: "| --- | ---: | ---: | ---: | ---: |";
|
||||
|
||||
const rows = entries.map((e) => {
|
||||
const base = `| ${e.agent} | ${fmt(e.inputTokens)} | ${fmt(e.outputTokens)} | ${fmt(e.cacheReadTokens ?? 0)} | ${fmt(e.cacheWriteTokens ?? 0)} |`;
|
||||
return hasCost
|
||||
? `${base} ${e.costUsd !== undefined ? `$${e.costUsd.toFixed(4)}` : "-"} |`
|
||||
: base;
|
||||
});
|
||||
|
||||
// totals row (only useful when there are multiple entries)
|
||||
const totalsRows: string[] = [];
|
||||
if (entries.length > 1) {
|
||||
const totalInput = entries.reduce((sum, e) => sum + e.inputTokens, 0);
|
||||
const totalOutput = entries.reduce((sum, e) => sum + e.outputTokens, 0);
|
||||
const totalCacheRead = entries.reduce((sum, e) => sum + (e.cacheReadTokens ?? 0), 0);
|
||||
const totalCacheWrite = entries.reduce((sum, e) => sum + (e.cacheWriteTokens ?? 0), 0);
|
||||
const totalBase = `| **Total** | **${fmt(totalInput)}** | **${fmt(totalOutput)}** | **${fmt(totalCacheRead)}** | **${fmt(totalCacheWrite)}** |`;
|
||||
|
||||
if (hasCost) {
|
||||
const totalCost = entries.reduce((sum, e) => sum + (e.costUsd ?? 0), 0);
|
||||
totalsRows.push(`${totalBase} **$${totalCost.toFixed(4)}** |`);
|
||||
} else {
|
||||
totalsRows.push(totalBase);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"<details>",
|
||||
"<summary>Usage</summary>",
|
||||
"",
|
||||
header,
|
||||
separatorRow,
|
||||
...rows,
|
||||
...totalsRows,
|
||||
"",
|
||||
"</details>",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
+37
-44
@@ -7,22 +7,19 @@ import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
|
||||
/**
|
||||
* Controls whether the script should check the reason for the workflow termination.
|
||||
* It can be either canceled or failed.
|
||||
* YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
* */
|
||||
// controls whether the script should check the reason for the workflow termination.
|
||||
// it can be either canceled or failed.
|
||||
// YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
const SHOULD_CHECK_REASON = true;
|
||||
|
||||
/**
|
||||
* Build error comment body with error message and footer
|
||||
*/
|
||||
function buildErrorCommentBody(params: {
|
||||
type BuildErrorCommentBodyParams = {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: string | undefined;
|
||||
isCancellation: boolean;
|
||||
}): string {
|
||||
};
|
||||
|
||||
function buildErrorCommentBody(params: BuildErrorCommentBodyParams): string {
|
||||
const workflowRunLink = params.runId
|
||||
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
||||
: "workflow run logs";
|
||||
@@ -38,34 +35,31 @@ function buildErrorCommentBody(params: {
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the progress comment is stuck on "Leaping into action"
|
||||
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
|
||||
* Returns the comment ID if stuck, null otherwise
|
||||
*/
|
||||
type ValidateStuckCommentParams = {
|
||||
promptInput: JsonPromptInput | null;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
owner: string;
|
||||
repo: string;
|
||||
};
|
||||
async function validateStuckProgressComment(
|
||||
promptInput: JsonPromptInput | null,
|
||||
octokit: ReturnType<typeof createOctokit>,
|
||||
owner: string,
|
||||
repo: string
|
||||
params: ValidateStuckCommentParams
|
||||
): Promise<number | null> {
|
||||
if (!promptInput?.progressCommentId) {
|
||||
if (!params.promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(promptInput.progressCommentId, 10);
|
||||
const commentId = parseInt(params.promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
|
||||
try {
|
||||
const { data: comment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
const commentResult = await params.octokit.rest.issues.getComment({
|
||||
owner: params.owner,
|
||||
repo: params.repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
// check if comment is stuck on "Leaping into action"
|
||||
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
if (commentResult.data.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
@@ -79,31 +73,31 @@ async function validateStuckProgressComment(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the workflow or its steps is cancelled.
|
||||
* While the job is still in_progress, the individual steps may have their conclusions set.
|
||||
*/
|
||||
async function getIsCancelled(params: {
|
||||
type GetIsCancelledParams = {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runIdStr: string | undefined;
|
||||
}): Promise<boolean> {
|
||||
};
|
||||
|
||||
async function getIsCancelled(params: GetIsCancelledParams): Promise<boolean> {
|
||||
if (!params.runIdStr) return false; // can't check without a run ID — assume failure
|
||||
try {
|
||||
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
const jobsResult = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10),
|
||||
});
|
||||
|
||||
// find current job by matching GITHUB_JOB env var
|
||||
// Note: GITHUB_JOB is the job ID (yaml key), but job.name is the display name
|
||||
// For matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
||||
// So we match jobs that START with the job ID
|
||||
// find current job by matching GITHUB_JOB env var.
|
||||
// GITHUB_JOB is the job ID (yaml key), but job.name is the display name.
|
||||
// for matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
||||
// so we match jobs that START with the job ID
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName
|
||||
? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`))
|
||||
: jobs.jobs[0]; // fallback to first job
|
||||
? jobsResult.data.jobs.find(
|
||||
(j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`)
|
||||
)
|
||||
: jobsResult.data.jobs[0]; // fallback to first job
|
||||
|
||||
if (!currentJob) {
|
||||
log.warning("[post] could not find current job");
|
||||
@@ -150,13 +144,12 @@ export async function runPostCleanup(): Promise<void> {
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
// validate that progressCommentId from prompt input is stuck on "Leaping into action"
|
||||
const commentId = await validateStuckProgressComment(
|
||||
const commentId = await validateStuckProgressComment({
|
||||
promptInput,
|
||||
octokit,
|
||||
repoContext.owner,
|
||||
repoContext.name
|
||||
);
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
});
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user