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:
David Blass
2026-02-22 14:12:43 +00:00
committed by pullfrog[bot]
parent a90743e9fe
commit cfd38d82fc
43 changed files with 3293 additions and 2158 deletions
+107 -86
View File
@@ -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,
};
}
}