diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ab01117..546f425 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,6 +39,8 @@ jobs: GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} + OPENCODE_MODEL_MINI: ${{ vars.OPENCODE_MODEL_MINI }} + OPENCODE_MODEL_MAX: ${{ vars.OPENCODE_MODEL_MAX }} steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 diff --git a/README.md b/README.md index ffe8977..5c691ff 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - +

diff --git a/agents/opencode.ts b/agents/opencode.ts index 4f29f2c..69a8d40 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -1,7 +1,7 @@ // 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { performance } from "node:perf_hooks"; import { ghPullfrogMcpName } from "../external.ts"; @@ -38,6 +38,159 @@ function detectProviderError(text: string): string | null { return null; } +type OpenCodeConfig = { + mcp?: Record; + permission?: Record; + provider?: Record; + model?: string; + enabled_providers?: string[]; + [key: string]: unknown; +}; + +type RecordPropertyContext = { + value: unknown; + key: string; +}; + +type RepoConfigLoadContext = { + repoConfigPath: string; +}; + +type ProviderFromModelContext = { + model: string; +}; + +type InlineConfigOverrideContext = { + model: string; +}; + +type InlineConfigOverride = { + providerId: string; + content: string; +}; + +type ModelOverrideResolutionContext = { + effort: AgentRunContext["payload"]["effort"]; + env: NodeJS.ProcessEnv; +}; + +type ModelOverrideResolution = { + model: string; + source: "OPENCODE_MODEL_MINI" | "OPENCODE_MODEL_MAX" | "OPENCODE_MODEL"; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function getRecordProperty(ctx: RecordPropertyContext): Record | undefined { + if (!isRecord(ctx.value)) { + return undefined; + } + const propertyValue = ctx.value[ctx.key]; + if (!isRecord(propertyValue)) { + return undefined; + } + return propertyValue; +} + +function loadRepoOpenCodeConfig(ctx: RepoConfigLoadContext): OpenCodeConfig | undefined { + if (!existsSync(ctx.repoConfigPath)) { + log.info(`» repo opencode.json not found at ${ctx.repoConfigPath}`); + return undefined; + } + + try { + const rawConfig = readFileSync(ctx.repoConfigPath, "utf-8"); + const parsedConfig = JSON.parse(rawConfig); + if (!isRecord(parsedConfig)) { + log.warning(`» repo opencode.json is not an object: ${ctx.repoConfigPath}`); + return undefined; + } + + const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" }); + if (providerConfig) { + const providerNames = Object.keys(providerConfig); + log.info(`» repo opencode provider config detected: ${providerNames.join(", ")}`); + } + + const result: OpenCodeConfig = parsedConfig; + log.info(`» loaded repo opencode.json from ${ctx.repoConfigPath}`); + return result; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + log.warning(`» failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`); + return undefined; + } +} + +function parseProviderFromModel(ctx: ProviderFromModelContext): string | undefined { + const trimmedModel = ctx.model.trim(); + const slashIndex = trimmedModel.indexOf("/"); + if (slashIndex <= 0) { + return undefined; + } + const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase(); + if (!providerId) { + return undefined; + } + return providerId; +} + +function buildInlineConfigOverride( + ctx: InlineConfigOverrideContext +): InlineConfigOverride | undefined { + const providerId = parseProviderFromModel({ model: ctx.model }); + if (!providerId) { + return undefined; + } + const inlineConfig: OpenCodeConfig = { + model: ctx.model, + enabled_providers: [providerId], + }; + return { + providerId, + content: JSON.stringify(inlineConfig), + }; +} + +function readNonEmptyEnvVar(ctx: { env: NodeJS.ProcessEnv; name: string }): string | undefined { + const value = ctx.env[ctx.name]; + if (!value) { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed; +} + +function resolveModelOverride( + ctx: ModelOverrideResolutionContext +): ModelOverrideResolution | undefined { + if (ctx.effort === "mini") { + const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" }); + if (miniModel) { + return { model: miniModel, source: "OPENCODE_MODEL_MINI" }; + } + } + + if (ctx.effort === "max") { + const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" }); + if (maxModel) { + return { model: maxModel, source: "OPENCODE_MODEL_MAX" }; + } + } + + const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" }); + if (!baseModel) { + return undefined; + } + + return { model: baseModel, source: "OPENCODE_MODEL" }; +} + async function installOpencode(): Promise { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -66,13 +219,18 @@ export const opencode = agent({ // this is critical for debugging since opencode run suppresses errors by default (Issue #752). const args = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; - // only override model when OPENCODE_MODEL is set (e.g., test environments with - // restricted API quotas). in production, OpenCode auto-selects the best available - // model based on which provider API keys are present. - const modelOverride = process.env.OPENCODE_MODEL; + // resolve model override from environment. + // precedence: + // 1) effort-specific overrides (OPENCODE_MODEL_MINI / OPENCODE_MODEL_MAX) + // 2) OPENCODE_MODEL fallback + // 3) OpenCode auto-select + const modelOverride = resolveModelOverride({ + effort: ctx.payload.effort, + env: process.env, + }); if (modelOverride) { - args.push("--model", modelOverride); - log.info(`» model: ${modelOverride} (override)`); + args.push("--model", modelOverride.model); + log.info(`» model: ${modelOverride.model} (override via ${modelOverride.source})`); } else { log.info(`» model: auto-selected by OpenCode`); } @@ -89,6 +247,31 @@ export const opencode = agent({ GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY, }; + + if (modelOverride) { + const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model }); + if (inlineOverride) { + env.OPENCODE_CONFIG_CONTENT = inlineOverride.content; + log.info( + `» OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}` + ); + } else { + log.warning( + `» skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"` + ); + } + } + + const hasOpenRouterKey = Boolean(env.OPENROUTER_API_KEY); + const hasAnthropicKey = Boolean(env.ANTHROPIC_API_KEY); + const hasOpenAiKey = Boolean(env.OPENAI_API_KEY); + const hasGoogleKey = Boolean( + env.GOOGLE_API_KEY || env.GEMINI_API_KEY || env.GOOGLE_GENERATIVE_AI_API_KEY + ); + log.info( + `» provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}` + ); + // OpenCode doesn't support GitHub App installation tokens delete env.GITHUB_TOKEN; @@ -194,7 +377,7 @@ export const opencode = agent({ lastProviderError = providerError; log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`); } else { - // OpenCode's --print-logs output goes to stderr. demote internal + //agent OpenCode's --print-logs output goes to stderr. demote internal // INFO/DEBUG bus traffic to debug so it doesn't drown out tool // call logs in the GitHub Actions step output. log.debug(trimmed); @@ -301,28 +484,41 @@ function configureOpenCode(ctx: AgentRunContext): void { const configDir = join(ctx.tmpdir, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); const configPath = join(configDir, "opencode.json"); + const repoConfigPath = join(process.cwd(), "opencode.json"); + const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath }); + if (repoConfig?.model) { + log.info(`» repo opencode model configured: ${repoConfig.model}`); + } // build MCP servers config - const opencodeMcpServers = { - [ghPullfrogMcpName]: { type: "remote" as const, url: ctx.mcpServerUrl }, - }; + const opencodeMcpServers: Record = {}; + const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" }); + if (repoMcpServers) { + Object.assign(opencodeMcpServers, repoMcpServers); + } + opencodeMcpServers[ghPullfrogMcpName] = { type: "remote" as const, url: ctx.mcpServerUrl }; // build permission object based on tool permissions // note: OpenCode has no built-in web search tool const shell = ctx.payload.shell; - const permission = { - edit: "deny", - read: "deny", - bash: shell !== "enabled" ? "deny" : "allow", - webfetch: ctx.payload.web === "disabled" ? "deny" : "allow", - external_directory: "deny", - }; + const permission: Record = {}; + const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" }); + if (repoPermission) { + Object.assign(permission, repoPermission); + } + permission.edit = "deny"; + permission.read = "deny"; + permission.bash = shell !== "enabled" ? "deny" : "allow"; + permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow"; + permission.external_directory = "deny"; // build complete config in one object - const config = { - mcp: opencodeMcpServers, - permission, - }; + const config: OpenCodeConfig = {}; + if (repoConfig) { + Object.assign(config, repoConfig); + } + config.mcp = opencodeMcpServers; + config.permission = permission; const configJson = JSON.stringify(config, null, 2); try { diff --git a/entry b/entry index ae51417..0bd3792 100755 --- a/entry +++ b/entry @@ -144201,7 +144201,7 @@ function initToolState(params) { return { progressCommentId: resolvedId, subagents: /* @__PURE__ */ new Map(), - selfSubagentId: void 0, + activeSubagentId: void 0, backgroundProcesses: /* @__PURE__ */ new Map(), usageEntries: [] }; @@ -144238,43 +144238,26 @@ function isAddressInUse(error49) { const message = getErrorMessage(error49).toLowerCase(); return message.includes("eaddrinuse") || message.includes("address already in use"); } -function buildSubagentTools(ctx) { - const tools = [ - IssueInfoTool(ctx), - GetIssueCommentsTool(ctx), - GetIssueEventsTool(ctx), - PullRequestInfoTool(ctx), - CommitInfoTool(ctx), - GetReviewCommentsTool(ctx), - ListPullRequestReviewsTool(ctx), - GetCheckSuiteLogsTool(ctx), - UploadFileTool(ctx), - SetOutputTool(ctx), - FileReadTool(ctx), - FileWriteTool(ctx), - FileEditTool(ctx), - FileDeleteTool(ctx), - ListDirectoryTool(ctx) - ]; - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - return tools; -} -function buildOrchestratorTools(ctx) { +function buildCommonTools(ctx) { const tools = [ StartDependencyInstallationTool(ctx), AwaitDependencyInstallationTool(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), + CreatePullRequestReviewTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), + ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), + AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -144284,14 +144267,17 @@ function buildOrchestratorTools(ctx) { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - CreatePullRequestReviewTool(ctx), - ResolveReviewThreadTool(ctx), - IssueTool(ctx), - AddLabelsTool(ctx), - ReportProgressTool(ctx), + ReportProgressTool(ctx) + ]; + if (ctx.payload.shell === "restricted") { + tools.push(ShellTool(ctx)); + tools.push(KillBackgroundTool(ctx)); + } + return tools; +} +function buildOrchestratorTools(ctx) { + return [ + ...buildCommonTools(ctx), SelectModeTool(ctx), DelegateTool(ctx), AskQuestionTool(ctx), @@ -144301,11 +144287,9 @@ function buildOrchestratorTools(ctx) { CreatePullRequestTool(ctx), UpdatePullRequestBodyTool(ctx) ]; - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - return tools; +} +function buildSubagentTools(ctx) { + return buildCommonTools(ctx); } async function tryStartMcpServer(ctx, tools, port) { const server = new FastMCP({ name: ghPullfrogMcpName, version: "0.0.1" }); @@ -144395,13 +144379,12 @@ async function startMcpHttpServer(ctx) { } }; } -async function startSubagentMcpServer(params) { +async function startSubagentMcpServer(ctx) { const subagentToolState = { - ...params.ctx.toolState, - selfSubagentId: params.subagentId, + ...ctx.toolState, backgroundProcesses: /* @__PURE__ */ new Map() }; - const subagentCtx = { ...params.ctx, toolState: subagentToolState }; + const subagentCtx = { ...ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; @@ -144639,7 +144622,7 @@ import { join as join11 } from "node:path"; // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.171", + version: "0.0.169", type: "module", files: [ "index.js", @@ -146040,7 +146023,7 @@ function configureGeminiSettings(ctx) { } // agents/opencode.ts -import { mkdirSync as mkdirSync9, writeFileSync as writeFileSync12 } from "node:fs"; +import { existsSync as existsSync7, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync12 } from "node:fs"; import { join as join15 } from "node:path"; import { performance as performance7 } from "node:perf_hooks"; var OPENCODE_CLI_VERSION = "1.1.56"; @@ -146061,6 +146044,101 @@ function detectProviderError(text) { } return null; } +function isRecord(value2) { + return typeof value2 === "object" && value2 !== null && !Array.isArray(value2); +} +function getRecordProperty(ctx) { + if (!isRecord(ctx.value)) { + return void 0; + } + const propertyValue = ctx.value[ctx.key]; + if (!isRecord(propertyValue)) { + return void 0; + } + return propertyValue; +} +function loadRepoOpenCodeConfig(ctx) { + if (!existsSync7(ctx.repoConfigPath)) { + log.info(`\xBB repo opencode.json not found at ${ctx.repoConfigPath}`); + return void 0; + } + try { + const rawConfig = readFileSync7(ctx.repoConfigPath, "utf-8"); + const parsedConfig = JSON.parse(rawConfig); + if (!isRecord(parsedConfig)) { + log.warning(`\xBB repo opencode.json is not an object: ${ctx.repoConfigPath}`); + return void 0; + } + const providerConfig = getRecordProperty({ value: parsedConfig, key: "provider" }); + if (providerConfig) { + const providerNames = Object.keys(providerConfig); + log.info(`\xBB repo opencode provider config detected: ${providerNames.join(", ")}`); + } + const result = parsedConfig; + log.info(`\xBB loaded repo opencode.json from ${ctx.repoConfigPath}`); + return result; + } catch (error49) { + const errorMessage = error49 instanceof Error ? error49.message : String(error49); + log.warning(`\xBB failed to parse repo opencode.json at ${ctx.repoConfigPath}: ${errorMessage}`); + return void 0; + } +} +function parseProviderFromModel(ctx) { + const trimmedModel = ctx.model.trim(); + const slashIndex = trimmedModel.indexOf("/"); + if (slashIndex <= 0) { + return void 0; + } + const providerId = trimmedModel.slice(0, slashIndex).trim().toLowerCase(); + if (!providerId) { + return void 0; + } + return providerId; +} +function buildInlineConfigOverride(ctx) { + const providerId = parseProviderFromModel({ model: ctx.model }); + if (!providerId) { + return void 0; + } + const inlineConfig = { + model: ctx.model, + enabled_providers: [providerId] + }; + return { + providerId, + content: JSON.stringify(inlineConfig) + }; +} +function readNonEmptyEnvVar(ctx) { + const value2 = ctx.env[ctx.name]; + if (!value2) { + return void 0; + } + const trimmed = value2.trim(); + if (!trimmed) { + return void 0; + } + return trimmed; +} +function resolveModelOverride(ctx) { + if (ctx.effort === "mini") { + const miniModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MINI" }); + if (miniModel) { + return { model: miniModel, source: "OPENCODE_MODEL_MINI" }; + } + } + if (ctx.effort === "max") { + const maxModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL_MAX" }); + if (maxModel) { + return { model: maxModel, source: "OPENCODE_MODEL_MAX" }; + } + } + const baseModel = readNonEmptyEnvVar({ env: ctx.env, name: "OPENCODE_MODEL" }); + if (!baseModel) { + return void 0; + } + return { model: baseModel, source: "OPENCODE_MODEL" }; +} async function installOpencode() { return await installFromNpmTarball({ packageName: "opencode-ai", @@ -146079,10 +146157,13 @@ var opencode = agent({ mkdirSync9(configDir, { recursive: true }); configureOpenCode(ctx); const args2 = ["run", ctx.instructions.full, "--format", "json", "--print-logs"]; - const modelOverride = process.env.OPENCODE_MODEL; + const modelOverride = resolveModelOverride({ + effort: ctx.payload.effort, + env: process.env + }); if (modelOverride) { - args2.push("--model", modelOverride); - log.info(`\xBB model: ${modelOverride} (override)`); + args2.push("--model", modelOverride.model); + log.info(`\xBB model: ${modelOverride.model} (override via ${modelOverride.source})`); } else { log.info(`\xBB model: auto-selected by OpenCode`); } @@ -146094,6 +146175,28 @@ var opencode = agent({ // set GOOGLE_GENERATIVE_AI_API_KEY alias for Google provider compatibility (if not already set) GOOGLE_GENERATIVE_AI_API_KEY: process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY }; + if (modelOverride) { + const inlineOverride = buildInlineConfigOverride({ model: modelOverride.model }); + if (inlineOverride) { + env2.OPENCODE_CONFIG_CONTENT = inlineOverride.content; + log.info( + `\xBB OpenCode inline config override enabled: provider=${inlineOverride.providerId}, model=${modelOverride.model}` + ); + } else { + log.warning( + `\xBB skipping OpenCode inline config override: unable to parse provider from model "${modelOverride.model}"` + ); + } + } + const hasOpenRouterKey = Boolean(env2.OPENROUTER_API_KEY); + const hasAnthropicKey = Boolean(env2.ANTHROPIC_API_KEY); + const hasOpenAiKey = Boolean(env2.OPENAI_API_KEY); + const hasGoogleKey = Boolean( + env2.GOOGLE_API_KEY || env2.GEMINI_API_KEY || env2.GOOGLE_GENERATIVE_AI_API_KEY + ); + log.info( + `\xBB provider key presence: OPENROUTER=${hasOpenRouterKey ? "set" : "unset"}, ANTHROPIC=${hasAnthropicKey ? "set" : "unset"}, OPENAI=${hasOpenAiKey ? "set" : "unset"}, GOOGLE=${hasGoogleKey ? "set" : "unset"}` + ); delete env2.GITHUB_TOKEN; const repoDir = process.cwd(); log.debug(`\xBB starting OpenCode: ${cliPath} ${args2.join(" ")}`); @@ -146246,21 +146349,34 @@ function configureOpenCode(ctx) { const configDir = join15(ctx.tmpdir, ".config", "opencode"); mkdirSync9(configDir, { recursive: true }); const configPath = join15(configDir, "opencode.json"); - const opencodeMcpServers = { - [ghPullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl } - }; + const repoConfigPath = join15(process.cwd(), "opencode.json"); + const repoConfig = loadRepoOpenCodeConfig({ repoConfigPath }); + if (repoConfig?.model) { + log.info(`\xBB repo opencode model configured: ${repoConfig.model}`); + } + const opencodeMcpServers = {}; + const repoMcpServers = getRecordProperty({ value: repoConfig, key: "mcp" }); + if (repoMcpServers) { + Object.assign(opencodeMcpServers, repoMcpServers); + } + opencodeMcpServers[ghPullfrogMcpName] = { type: "remote", url: ctx.mcpServerUrl }; const shell = ctx.payload.shell; - const permission = { - edit: "deny", - read: "deny", - bash: shell !== "enabled" ? "deny" : "allow", - webfetch: ctx.payload.web === "disabled" ? "deny" : "allow", - external_directory: "deny" - }; - const config3 = { - mcp: opencodeMcpServers, - permission - }; + const permission = {}; + const repoPermission = getRecordProperty({ value: repoConfig, key: "permission" }); + if (repoPermission) { + Object.assign(permission, repoPermission); + } + permission.edit = "deny"; + permission.read = "deny"; + permission.bash = shell !== "enabled" ? "deny" : "allow"; + permission.webfetch = ctx.payload.web === "disabled" ? "deny" : "allow"; + permission.external_directory = "deny"; + const config3 = {}; + if (repoConfig) { + Object.assign(config3, repoConfig); + } + config3.mcp = opencodeMcpServers; + config3.permission = permission; const configJson = JSON.stringify(config3, null, 2); try { writeFileSync12(configPath, configJson, "utf-8"); @@ -147348,7 +147464,12 @@ async function setupGit(params) { $("git", ["remote", "set-url", "origin", originUrl], { cwd: repoDir }); params.toolState.pushUrl = originUrl; $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - log.info("\xBB git authentication configured"); + if (params.event.is_pr !== true || !params.event.issue_number) { + log.info("\xBB git authentication configured"); + return; + } + const prNumber = params.event.issue_number; + await checkoutPrBranch(prNumber, params); } // utils/time.ts diff --git a/mcp/server.ts b/mcp/server.ts index 4c7b029..101d7bb 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -25,7 +25,6 @@ export type SubagentStatus = "running" | "completed" | "failed"; export type SubagentState = { id: string; - label: string; status: SubagentStatus; mode: string; stdoutFilePath: string; @@ -47,9 +46,8 @@ export interface ToolState { selectedMode?: string; // per-subagent lifecycle tracking (keyed by subagent uuid) subagents: Map; - // only set on subagent shallow copies — routes set_output to the owning subagent. - // never set on the orchestrator's shared state. - selfSubagentId: string | undefined; + // set while a subagent is running — routes set_output to the correct subagent and prevents nesting + activeSubagentId: string | undefined; backgroundProcesses: Map; review?: { id: number; @@ -83,7 +81,7 @@ export function initToolState(params: InitToolStateParams): ToolState { return { progressCommentId: resolvedId, subagents: new Map(), - selfSubagentId: undefined, + activeSubagentId: undefined, backgroundProcesses: new Map(), usageEntries: [], }; @@ -107,6 +105,24 @@ 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"; @@ -188,50 +204,27 @@ function isAddressInUse(error: unknown): boolean { return message.includes("eaddrinuse") || message.includes("address already in use"); } -// subagent tools: file ops, shell, read-only GitHub, upload, set_output. -// no git/checkout (mutates shared state), no dependencies (shared state), -// no GitHub-write (user-facing side effects), no delegation/remote-mutating. -function buildSubagentTools(ctx: ToolContext): Tool[] { - const tools: Tool[] = [ - IssueInfoTool(ctx), - GetIssueCommentsTool(ctx), - GetIssueEventsTool(ctx), - PullRequestInfoTool(ctx), - CommitInfoTool(ctx), - GetReviewCommentsTool(ctx), - ListPullRequestReviewsTool(ctx), - GetCheckSuiteLogsTool(ctx), - UploadFileTool(ctx), - SetOutputTool(ctx), - FileReadTool(ctx), - FileWriteTool(ctx), - FileEditTool(ctx), - FileDeleteTool(ctx), - ListDirectoryTool(ctx), - ]; - - if (ctx.payload.shell === "restricted") { - tools.push(ShellTool(ctx)); - tools.push(KillBackgroundTool(ctx)); - } - - return tools; -} - -// orchestrator gets everything: file ops, shell, git, GitHub, delegation, remote-mutating -function buildOrchestratorTools(ctx: ToolContext): Tool[] { +// tools shared by both orchestrator and subagent servers +function buildCommonTools(ctx: ToolContext): Tool[] { const tools: Tool[] = [ StartDependencyInstallationTool(ctx), AwaitDependencyInstallationTool(ctx), + CreateCommentTool(ctx), + EditCommentTool(ctx), + ReplyToReviewCommentTool(ctx), + IssueTool(ctx), IssueInfoTool(ctx), GetIssueCommentsTool(ctx), GetIssueEventsTool(ctx), + CreatePullRequestReviewTool(ctx), PullRequestInfoTool(ctx), CommitInfoTool(ctx), CheckoutPrTool(ctx), GetReviewCommentsTool(ctx), ListPullRequestReviewsTool(ctx), + ResolveReviewThreadTool(ctx), GetCheckSuiteLogsTool(ctx), + AddLabelsTool(ctx), GitTool(ctx), GitFetchTool(ctx), UploadFileTool(ctx), @@ -241,22 +234,7 @@ function buildOrchestratorTools(ctx: ToolContext): Tool[] { FileEditTool(ctx), FileDeleteTool(ctx), ListDirectoryTool(ctx), - CreateCommentTool(ctx), - EditCommentTool(ctx), - ReplyToReviewCommentTool(ctx), - CreatePullRequestReviewTool(ctx), - ResolveReviewThreadTool(ctx), - IssueTool(ctx), - AddLabelsTool(ctx), ReportProgressTool(ctx), - SelectModeTool(ctx), - DelegateTool(ctx), - AskQuestionTool(ctx), - PushBranchTool(ctx), - PushTagsTool(ctx), - DeleteBranchTool(ctx), - CreatePullRequestTool(ctx), - UpdatePullRequestBodyTool(ctx), ]; // only add ShellTool when shell is "restricted" @@ -271,6 +249,26 @@ function buildOrchestratorTools(ctx: ToolContext): Tool[] { return tools; } +// orchestrator gets common tools + delegation + remote-mutating tools +function buildOrchestratorTools(ctx: ToolContext): Tool[] { + 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[] { + return buildCommonTools(ctx); +} + type McpStartResult = { server: FastMCP; url: string; @@ -393,30 +391,21 @@ export type ManagedMcpServer = { stop: () => Promise; }; -type StartSubagentMcpServerParams = { - ctx: ToolContext; - subagentId: string; -}; - /** * 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. - * selfSubagentId is set on the copy so set_output routes to the correct subagent. * Shared references (subagents Map, usageEntries array, dependencyInstallation) * are intentionally shared for coordination (set_output routing, usage tracking). */ -export async function startSubagentMcpServer( - params: StartSubagentMcpServerParams -): Promise { +export async function startSubagentMcpServer(ctx: ToolContext): Promise { const subagentToolState: ToolState = { - ...params.ctx.toolState, - selfSubagentId: params.subagentId, + ...ctx.toolState, backgroundProcesses: new Map(), }; - const subagentCtx: ToolContext = { ...params.ctx, toolState: subagentToolState }; + const subagentCtx: ToolContext = { ...ctx, toolState: subagentToolState }; const tools = buildSubagentTools(subagentCtx); const startResult = await selectMcpPort(subagentCtx, tools); return { url: startResult.url, stop: () => startResult.server.stop() }; diff --git a/package.json b/package.json index fe67c0e..c234779 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pullfrog/pullfrog", - "version": "0.0.171", + "version": "0.0.169", "type": "module", "files": [ "index.js", diff --git a/post b/post index a0dfaf3..f6757ed 100755 --- a/post +++ b/post @@ -41235,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max"); // package.json var package_default = { name: "@pullfrog/pullfrog", - version: "0.0.171", + version: "0.0.169", type: "module", files: [ "index.js", diff --git a/test/ci.test.ts b/test/ci.test.ts index 48aaf18..23481aa 100644 --- a/test/ci.test.ts +++ b/test/ci.test.ts @@ -65,6 +65,8 @@ const expectedAgentEnvVars = [ "GITHUB_TOKEN", ...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)), "GEMINI_MODEL", + "OPENCODE_MODEL_MAX", + "OPENCODE_MODEL_MINI", "OPENCODE_MODEL", ].sort(); diff --git a/utils/docker.ts b/utils/docker.ts index 2681b31..0084b83 100644 --- a/utils/docker.ts +++ b/utils/docker.ts @@ -120,6 +120,8 @@ const testEnvAllowList = new Set([ "GOOGLE_GENERATIVE_AI_API_KEY", "CURSOR_API_KEY", "OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference + "OPENCODE_MODEL_MINI", // effort-specific OpenCode model override for mini effort + "OPENCODE_MODEL_MAX", // effort-specific OpenCode model override for max effort "GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference "LOG_LEVEL", "DEBUG", diff --git a/utils/setup.ts b/utils/setup.ts index 2e5ba5e..4cd4f2c 100644 --- a/utils/setup.ts +++ b/utils/setup.ts @@ -2,7 +2,8 @@ import { execSync } from "node:child_process"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ShellPermission } from "../external.ts"; +import type { PayloadEvent, ShellPermission } from "../external.ts"; +import { checkoutPrBranch } from "../mcp/checkout.ts"; import type { ToolState } from "../mcp/server.ts"; import { log } from "./cli.ts"; import type { OctokitWithPlugins } from "./github.ts"; @@ -58,7 +59,9 @@ export interface GitContext { postCheckoutScript: string | null; } -export type SetupGitParams = GitContext; +export interface SetupGitParams extends GitContext { + event: PayloadEvent; +} /** * setup git configuration and authentication for the repository. @@ -167,5 +170,16 @@ export async function setupGit(params: SetupGitParams): Promise { // disable credential helpers to prevent prompts and ensure clean auth state $("git", ["config", "--local", "credential.helper", ""], { cwd: repoDir }); - log.info("» git authentication configured"); + // 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); }