Improve delegate (#377)

* Improve delegate

* fix stale log regexes in delegate tests and add test-coupling comments

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Colin McDonnell
2026-02-23 23:41:27 +00:00
committed by pullfrog[bot]
parent a7bd746f21
commit 2017922780
30 changed files with 853 additions and 522 deletions
+1
View File
@@ -110,6 +110,7 @@ export function createProcessOutputActivityTimeout(ctx: ActivityTimeoutContext):
if (monitor) {
monitor.stop();
}
// matched by delegateTimeout test validator — update tests if changed
rejectFn(new Error(`activity timeout: no output for ${idleSec}s`));
},
});
+56 -35
View File
@@ -53,6 +53,17 @@ interface NpmRegistryData {
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromNpmTarball(params: InstallFromNpmTarballParams): Promise<string> {
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, params.executablePath);
if (existsSync(cliPath)) {
log.debug(`» using cached binary at ${cliPath}`);
return cliPath;
}
// Resolve version if it's a range or "latest"
let resolvedVersion = params.version;
if (
@@ -80,9 +91,6 @@ export async function installFromNpmTarball(params: InstallFromNpmTarballParams)
log.debug(`» installing ${params.packageName}@${resolvedVersion}...`);
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
@@ -121,10 +129,6 @@ export async function installFromNpmTarball(params: InstallFromNpmTarballParams)
);
}
// Find executable in the extracted package
const extractedDir = join(tempDir, "package");
const cliPath = join(extractedDir, params.executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted package at ${cliPath}`);
}
@@ -189,6 +193,19 @@ async function fetchWithRetry(
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromGithub(params: InstallFromGithubParams): Promise<string> {
// use a deterministic subdir in PULLFROG_TEMP_DIR so repeated calls are cached
const pullfrogTemp = process.env.PULLFROG_TEMP_DIR;
const installDir = pullfrogTemp
? join(pullfrogTemp, `github-${params.owner}-${params.repo}`)
: await mkdtemp(join(tmpdir(), `${params.owner}-${params.repo}-github-`));
const expectedCliPath = join(installDir, params.executablePath ?? params.assetName ?? "asset");
if (existsSync(expectedCliPath)) {
log.debug(`» using cached binary at ${expectedCliPath}`);
return expectedCliPath;
}
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
// fetch release from GitHub API (pinned tag or latest)
@@ -222,14 +239,12 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis
log.debug(`» downloading asset from ${assetUrl}...`);
// create temp directory
const tempDirPrefix = `${params.owner}-${params.repo}-github-`;
const tempDirPath = await mkdtemp(join(tmpdir(), tempDirPrefix));
mkdirSync(installDir, { recursive: true });
// determine file extension and download path
const urlPath = new URL(assetUrl).pathname;
const fileName = urlPath.split("/").pop() || "asset";
const downloadPath = join(tempDirPath, fileName);
const downloadPath = join(installDir, fileName);
// download the asset
const assetResponse = await fetchWithRetry(assetUrl, headers, "Failed to download asset");
@@ -240,13 +255,7 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis
log.debug(`» downloaded asset to ${downloadPath}`);
// determine the executable path
let cliPath: string;
if (params.executablePath) {
cliPath = join(tempDirPath, params.executablePath);
} else {
// no executablePath, assume the downloaded file is the executable
cliPath = downloadPath;
}
const cliPath = params.executablePath ? join(installDir, params.executablePath) : downloadPath;
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
@@ -266,6 +275,16 @@ export async function installFromGithub(params: InstallFromGithubParams): Promis
export async function installFromGithubTarball(
params: InstallFromGithubTarballParams
): Promise<string> {
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
const cliPath = join(tempDir, params.executablePath);
if (existsSync(cliPath)) {
log.debug(`» using cached binary at ${cliPath}`);
return cliPath;
}
log.info(`» installing ${params.owner}/${params.repo} from GitHub releases...`);
// determine platform-specific asset name
@@ -304,9 +323,6 @@ export async function installFromGithubTarball(
log.debug(`» downloading asset from ${assetUrl}...`);
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
@@ -329,9 +345,6 @@ export async function installFromGithubTarball(
);
}
// find executable in the extracted tarball
const cliPath = join(tempDir, params.executablePath);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found in extracted tarball at ${cliPath}`);
}
@@ -351,11 +364,19 @@ export async function installFromGithubTarball(
export async function installFromDirectTarball(
params: InstallFromDirectTarballParams
): Promise<string> {
log.info(`» downloading tarball from ${params.url}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
const extractDir = join(tempDir, "direct-package");
const cliPath = join(extractDir, params.executablePath);
if (existsSync(cliPath)) {
log.debug(`» using cached binary at ${cliPath}`);
return cliPath;
}
log.info(`» downloading tarball from ${params.url}...`);
const tarballPath = join(tempDir, "direct-package.tgz");
const response = await fetchWithRetry(params.url, {}, "failed to download tarball");
@@ -365,8 +386,6 @@ export async function installFromDirectTarball(
await pipeline(response.body, fileStream);
log.debug(`» downloaded tarball to ${tarballPath}`);
// always extract into a dedicated directory
const extractDir = join(tempDir, "direct-package");
mkdirSync(extractDir, { recursive: true });
const tarArgs = ["-xzf", tarballPath, "-C", extractDir];
@@ -385,7 +404,6 @@ export async function installFromDirectTarball(
);
}
const cliPath = join(extractDir, params.executablePath);
if (!existsSync(cliPath)) {
throw new Error(`executable not found in extracted tarball at ${cliPath}`);
}
@@ -402,11 +420,18 @@ export async function installFromDirectTarball(
* The temp directory will be cleaned up by the OS automatically
*/
export async function installFromCurl(params: InstallFromCurlParams): Promise<string> {
log.info(`» installing ${params.executableName}...`);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (!tempDir) throw new Error("PULLFROG_TEMP_DIR is not set");
const cliPath = join(tempDir, ".local", "bin", params.executableName);
if (existsSync(cliPath)) {
log.debug(`» using cached binary at ${cliPath}`);
return cliPath;
}
log.info(`» installing ${params.executableName}...`);
const installScriptPath = join(tempDir, "install.sh");
// Download the install script
@@ -448,10 +473,6 @@ export async function installFromCurl(params: InstallFromCurlParams): Promise<st
);
}
// The Cursor install script creates a symlink at $HOME/.local/bin/{executableName}
// Since we set HOME=tempDir, the deterministic path is:
const cliPath = join(tempDir, ".local", "bin", params.executableName);
if (!existsSync(cliPath)) {
throw new Error(`Executable not found at ${cliPath}`);
}
+27 -22
View File
@@ -355,48 +355,53 @@ ${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.
const orchestratorTaskSection = `**Required!** You are an orchestrator. You do not perform tasks directly — you delegate to specialized subagents and handle all state-mutating and user-facing GitHub operations yourself.
### Step 1: Select a mode
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.
Call \`${ghPullfrogMcpName}/select_mode\` with the appropriate mode name. This returns **your workflow** — a step-by-step playbook you must follow, including:
- **Pre-delegation actions** you must perform (checkout, branch creation, setup)
- **Delegation instructions** (how to craft subagent prompts, what to include)
- **Post-delegation actions** you must perform (push, PR creation, review submission, progress reporting)
**Follow the returned guidance as your primary instruction set.** Do not improvise — the guidance defines what you do vs. what subagents do.
Available modes:
${ctx.modes.map((m) => `- "${m.name}": ${m.description}`).join("\n")}
### Step 2: Craft subagent prompts and delegate
### Step 2: Delegate
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).
Call \`${ghPullfrogMcpName}/delegate\` to fan out research, local coding tasks, and codebase investigations to subagents. Pass a \`tasks\` array. Each task has:
- \`label\`: Short identifier (e.g. "frontend-review", "schema-check"). Returned in results for matching.
- \`instructions\`: The subagent receives ONLY this text (plus a system preamble with tool documentation and resolved context). Include everything it needs: file paths, constraints, conventions, and any context from the codebase or previous phases.
- \`effort\` (optional): \`"mini"\`, \`"auto"\` (default), or \`"max"\`.
Subagents are designed for research and local work: reading files, exploring the codebase, writing and editing code, running tests, creating reviews, and posting comments. They do NOT have access to remote-mutating operations like pushing branches, creating PRs, or updating PR bodies — those are your responsibility as the orchestrator.
All tasks in a single \`delegate\` call run as **parallel subagents**. For sequential phases (plan → build → review), use separate \`delegate\` calls.
To investigate questions (e.g. web research, codebase investigations), prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`.
To investigate questions, prefer \`${ghPullfrogMcpName}/ask_question\` over \`${ghPullfrogMcpName}/delegate\`.
### Step 3: Post-delegation (your responsibility)
### Step 3: Post-delegation
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.
After each \`delegate\` call, you receive a \`results\` array — one entry per task with \`label\`, \`success\`, \`summary\` (from set_output), and \`stdoutFile\` (inspectable via \`${ghPullfrogMcpName}/file_read\`). Follow the post-delegation steps from the select_mode guidance.
**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. This makes it available as the GitHub Action output.
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.
### Subagent capabilities
Subagents have: file operations, bash (for local git, tests, builds), read-only GitHub queries, and upload_file. They do NOT have: \`git\`, \`checkout_pr\`, \`push_branch\`, \`create_pull_request\`, \`create_pull_request_review\`, \`report_progress\`, \`create_issue_comment\`, \`reply_to_review_comment\`, \`resolve_review_thread\`, \`delegate\`, \`ask_question\`, or any dependency/remote-mutating tools. All GitHub-write and state-mutating operations are your responsibility.
### 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).
- Subagents have NO context beyond what you write. No repo instructions, no event data, no user prompt.
- Specify exactly what information the subagent should return. The subagent's \`set_output\` call is your only way to get results back — be precise about what you need.
- Instruct subagents to use bash for local git (\`git add\`, \`git commit\`, \`git diff\`, \`git status\`).
- Never instruct a subagent to push, create PRs, submit reviews, or post comments.
- For multi-phase flows, pass results from earlier phases into the next delegate call's prompts.
- You do NOT need to instruct subagents to call \`set_output\` — the system preamble handles this.
### No-action cases
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.`;
If the task clearly requires no work, skip delegation. Call \`${ghPullfrogMcpName}/report_progress\` directly to explain why no action is needed.`;
const system = buildSystemPrompt({
shell: ctx.payload.shell,
+3 -17
View File
@@ -2,8 +2,7 @@ import { execSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { PayloadEvent, ShellPermission } from "../external.ts";
import { checkoutPrBranch } from "../mcp/checkout.ts";
import type { ShellPermission } from "../external.ts";
import type { ToolState } from "../mcp/server.ts";
import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts";
@@ -59,9 +58,7 @@ export interface GitContext {
postCheckoutScript: string | null;
}
export interface SetupGitParams extends GitContext {
event: PayloadEvent;
}
export type SetupGitParams = GitContext;
/**
* setup git configuration and authentication for the repository.
@@ -170,16 +167,5 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
// disable credential helpers to prevent prompts and ensure clean auth state
$("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir });
// non-PR events: stay on default branch
if (params.event.is_pr !== true || !params.event.issue_number) {
log.info("» git authentication configured");
return;
}
// PR event: checkout PR branch using shared helper
const prNumber = params.event.issue_number;
// use shared checkout helper (handles fork remotes, push config, post-checkout hook)
// this updates toolState.pushUrl for fork PRs and sets toolState.issueNumber
await checkoutPrBranch(prNumber, params);
log.info("» git authentication configured");
}
+1
View File
@@ -192,6 +192,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
if (isActivityTimedOut) {
const idleSec = Math.round((performance.now() - lastActivityTime) / 1000);
// matched by delegateTimeout test validator — update tests if changed
reject(new Error(`activity timeout: no output for ${idleSec}s`));
return;
}