cfd38d82fc
* 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>
342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
// changes to effort level configuration should be reflected in wiki/effort.md and docs/effort.mdx
|
|
// changes to tool permissions should be reflected in wiki/granular-tools.md
|
|
// changes to web search configuration should be reflected in wiki/websearch.md
|
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
|
import type { Effort } from "../external.ts";
|
|
import { ghPullfrogMcpName } from "../external.ts";
|
|
import packageJson from "../package.json" with { type: "json" };
|
|
import { markActivity } from "../utils/activity.ts";
|
|
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, type AgentUsage, agent } from "./shared.ts";
|
|
|
|
// model selection based on effort level
|
|
// these are aliases that always resolve to the latest version
|
|
const claudeEffortModels: Record<Effort, string> = {
|
|
mini: "sonnet",
|
|
auto: "opus",
|
|
max: "opus",
|
|
};
|
|
|
|
// Claude Code CLI --effort level per pullfrog effort
|
|
// null = use default (high). "max" is Opus 4.6 only.
|
|
const claudeEffortLevels: Record<Effort, string | null> = {
|
|
mini: null,
|
|
auto: null,
|
|
max: "max",
|
|
};
|
|
|
|
/**
|
|
* Build disallowedTools list from payload permissions.
|
|
*/
|
|
function buildDisallowedTools(ctx: AgentRunContext): string[] {
|
|
const disallowed: string[] = [];
|
|
if (ctx.payload.web === "disabled") disallowed.push("WebFetch");
|
|
if (ctx.payload.search === "disabled") disallowed.push("WebSearch");
|
|
// 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");
|
|
// always block native file tools (use MCP file_read/file_write instead)
|
|
disallowed.push("Read", "Write", "Edit", "MultiEdit");
|
|
// block built-in subagent spawning — delegation is handled by gh_pullfrog/delegate
|
|
disallowed.push("Task");
|
|
return disallowed;
|
|
}
|
|
|
|
/**
|
|
* Write MCP config file for Claude CLI.
|
|
* Returns the path to the config file.
|
|
*/
|
|
function writeMcpConfig(ctx: AgentRunContext): string {
|
|
const configDir = join(ctx.tmpdir, ".claude");
|
|
mkdirSync(configDir, { recursive: true });
|
|
const configPath = join(configDir, "mcp.json");
|
|
|
|
const mcpConfig = {
|
|
mcpServers: {
|
|
[ghPullfrogMcpName]: { type: "http", url: ctx.mcpServerUrl },
|
|
},
|
|
};
|
|
|
|
writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
|
|
log.debug(`» MCP config written to ${configPath}`);
|
|
return configPath;
|
|
}
|
|
|
|
async function installClaude(): Promise<string> {
|
|
const versionRange = packageJson.dependencies["@anthropic-ai/claude-agent-sdk"] || "latest";
|
|
return await installFromNpmTarball({
|
|
packageName: "@anthropic-ai/claude-agent-sdk",
|
|
version: versionRange,
|
|
executablePath: "cli.js",
|
|
});
|
|
}
|
|
|
|
export const claude = agent({
|
|
name: "claude",
|
|
install: installClaude,
|
|
run: async (ctx) => {
|
|
// install CLI at start of run
|
|
const cliPath = await installClaude();
|
|
|
|
// select model and effort level
|
|
const model = claudeEffortModels[ctx.payload.effort];
|
|
const effortLevel = claudeEffortLevels[ctx.payload.effort];
|
|
log.info(`» model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
|
|
|
// build disallowedTools based on tool permissions
|
|
const disallowedTools = buildDisallowedTools(ctx);
|
|
if (disallowedTools.length > 0) {
|
|
log.debug(`» disallowed built-ins: ${JSON.stringify(disallowedTools)}`);
|
|
}
|
|
|
|
// write MCP config file
|
|
const mcpConfigPath = writeMcpConfig(ctx);
|
|
|
|
// build CLI args
|
|
// claude -p "prompt" --dangerously-skip-permissions --mcp-config ./mcp.json --model opus --output-format stream-json --verbose
|
|
const args: string[] = [
|
|
cliPath,
|
|
"-p",
|
|
ctx.instructions.full,
|
|
"--dangerously-skip-permissions",
|
|
"--mcp-config",
|
|
mcpConfigPath,
|
|
"--model",
|
|
model,
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
];
|
|
|
|
// add --effort flag if specified (e.g. "max" for Opus 4.6)
|
|
if (effortLevel) {
|
|
args.push("--effort", effortLevel);
|
|
}
|
|
|
|
// add disallowed tools if any
|
|
if (disallowedTools.length > 0) {
|
|
args.push("--disallowedTools");
|
|
args.push(...disallowedTools);
|
|
}
|
|
|
|
log.info("» running Claude CLI...");
|
|
|
|
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>();
|
|
const thinkingTimer = new ThinkingTimer();
|
|
|
|
const result = await spawn({
|
|
cmd: "node",
|
|
args,
|
|
cwd: process.cwd(),
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
activityTimeout: 0, // process-level activity timeout (5min) is the single authority
|
|
onStdout: async (chunk) => {
|
|
finalOutput += chunk;
|
|
markActivity(); // reset activity timeout on any CLI output
|
|
|
|
// buffer incomplete lines across chunks (NDJSON format)
|
|
stdoutBuffer += chunk;
|
|
const lines = stdoutBuffer.split("\n");
|
|
|
|
// keep the last element (may be incomplete) in the buffer
|
|
stdoutBuffer = lines.pop() || "";
|
|
|
|
for (const line of lines) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
|
|
try {
|
|
const message = JSON.parse(trimmed) as SDKMessage;
|
|
markActivity(); // reset activity timeout on every event
|
|
log.debug(JSON.stringify(message, null, 2));
|
|
|
|
const handler = messageHandlers[message.type];
|
|
if (handler) {
|
|
await handler(message as never, bashToolIds, thinkingTimer, usageContainer);
|
|
}
|
|
} catch {
|
|
// ignore parse errors - might be non-JSON output
|
|
log.debug(`[claude] non-JSON stdout line: ${trimmed.substring(0, 200)}`);
|
|
}
|
|
}
|
|
},
|
|
onStderr: (chunk) => {
|
|
const trimmed = chunk.trim();
|
|
if (trimmed) {
|
|
log.info(`[claude stderr] ${trimmed}`);
|
|
finalOutput += trimmed + "\n";
|
|
}
|
|
},
|
|
});
|
|
|
|
if (result.exitCode !== 0) {
|
|
const errorMessage =
|
|
result.stderr ||
|
|
finalOutput ||
|
|
result.stdout ||
|
|
"Unknown error - no output from Claude CLI";
|
|
log.error(`Claude CLI exited with code ${result.exitCode}: ${errorMessage}`);
|
|
return {
|
|
success: false,
|
|
error: errorMessage,
|
|
output: finalOutput || result.stdout || "",
|
|
usage: usageContainer.value ?? undefined,
|
|
};
|
|
}
|
|
|
|
log.info("» Claude CLI completed successfully");
|
|
|
|
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,
|
|
usageContainer: UsageContainer
|
|
) => void | Promise<void>;
|
|
|
|
type SDKMessageHandlers = {
|
|
[type in SDKMessageType]: SDKMessageHandler<type>;
|
|
};
|
|
|
|
const messageHandlers: SDKMessageHandlers = {
|
|
assistant: (data, bashToolIds, thinkingTimer, _usageContainer) => {
|
|
if (data.message?.content) {
|
|
for (const content of data.message.content) {
|
|
if (content.type === "text" && content.text?.trim()) {
|
|
log.box(content.text.trim(), { title: "Claude" });
|
|
} else if (content.type === "tool_use") {
|
|
// Track bash tool IDs
|
|
if (content.name === "bash" && content.id) {
|
|
bashToolIds.add(content.id);
|
|
}
|
|
|
|
thinkingTimer.markToolCall();
|
|
log.toolCall({
|
|
toolName: content.name,
|
|
input: content.input,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
},
|
|
user: (data, bashToolIds, thinkingTimer, _usageContainer) => {
|
|
if (data.message?.content) {
|
|
for (const content of data.message.content) {
|
|
if (typeof content === "string") {
|
|
continue;
|
|
}
|
|
if (content.type === "tool_result") {
|
|
thinkingTimer.markToolResult();
|
|
|
|
const toolUseId = content.tool_use_id;
|
|
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
|
|
|
|
const outputContent =
|
|
typeof content.content === "string"
|
|
? content.content
|
|
: Array.isArray(content.content)
|
|
? content.content
|
|
.map((entry: unknown) =>
|
|
typeof entry === "string"
|
|
? entry
|
|
: typeof entry === "object" && entry !== null && "text" in entry
|
|
? String(entry.text)
|
|
: JSON.stringify(entry)
|
|
)
|
|
.join("\n")
|
|
: String(content.content);
|
|
|
|
if (isBashTool) {
|
|
// Log bash output in a collapsed group
|
|
log.startGroup(`bash output`);
|
|
if (content.is_error) {
|
|
log.info(outputContent);
|
|
} else {
|
|
log.info(outputContent);
|
|
}
|
|
log.endGroup();
|
|
// Clean up the tracked ID
|
|
bashToolIds.delete(toolUseId);
|
|
} else if (content.is_error) {
|
|
log.info(`Tool error: ${outputContent}`);
|
|
} else {
|
|
// log successful non-bash tool result at debug level
|
|
log.debug(`tool output: ${outputContent}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
result: async (data, _bashToolIds, _thinkingTimer, usageContainer) => {
|
|
if (data.subtype === "success") {
|
|
const usage = data.usage;
|
|
const inputTokens = usage?.input_tokens || 0;
|
|
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
const cacheWrite = usage?.cache_creation_input_tokens || 0;
|
|
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 },
|
|
{ data: "Input", header: true },
|
|
{ data: "Cache Read", header: true },
|
|
{ data: "Cache Write", header: true },
|
|
{ data: "Output", header: true },
|
|
],
|
|
[
|
|
`$${data.total_cost_usd?.toFixed(4) || "0.0000"}`,
|
|
String(totalInput),
|
|
String(cacheRead),
|
|
String(cacheWrite),
|
|
String(outputTokens),
|
|
],
|
|
]);
|
|
} else if (data.subtype === "error_max_turns") {
|
|
log.info(`Max turns reached: ${JSON.stringify(data)}`);
|
|
} else if (data.subtype === "error_during_execution") {
|
|
log.info(`Execution error: ${JSON.stringify(data)}`);
|
|
} else {
|
|
log.info(`Failed: ${JSON.stringify(data)}`);
|
|
}
|
|
},
|
|
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) => {},
|
|
};
|