diff --git a/agents/instructions.ts b/agents/instructions.ts index 87e1114..25408a1 100644 --- a/agents/instructions.ts +++ b/agents/instructions.ts @@ -3,6 +3,90 @@ import type { Payload } from "../external.ts"; import { ghPullfrogMcpName } from "../external.ts"; import { getModes } from "../modes.ts"; +/** + * Extract only essential fields from event data to reduce token usage. + * Removes verbose GitHub API metadata (user objects, repository metadata, etc.) + * and keeps only the fields agents actually need. + */ +function extractEssentialEventData(event: Payload["event"]): Record { + const trigger = event.trigger; + const essential: Record = { trigger }; + + // common fields + if ("issue_number" in event) { + essential.issue_number = event.issue_number; + } + if ("branch" in event && event.branch) { + essential.branch = event.branch; + } + + // trigger-specific fields + switch (trigger) { + case "issue_comment_created": + if ("comment_id" in event) essential.comment_id = event.comment_id; + if ("comment_body" in event) essential.comment_body = event.comment_body; + // include issue title/body if available in context (but not the entire context object) + if ("context" in event && event.context && typeof event.context === "object") { + const ctx = event.context as Record; + if (ctx.issue && typeof ctx.issue === "object") { + const issue = ctx.issue as Record; + if (issue.title) essential.issue_title = issue.title; + if (issue.body) essential.issue_body = issue.body; + } + } + break; + + case "issues_opened": + case "issues_assigned": + case "issues_labeled": + if ("issue_title" in event) essential.issue_title = event.issue_title; + if ("issue_body" in event) essential.issue_body = event.issue_body; + break; + + case "pull_request_opened": + case "pull_request_review_requested": + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("pr_body" in event) essential.pr_body = event.pr_body; + if ("branch" in event) essential.branch = event.branch; + break; + + case "pull_request_review_submitted": + if ("review_id" in event) essential.review_id = event.review_id; + if ("review_body" in event) essential.review_body = event.review_body; + if ("review_state" in event) essential.review_state = event.review_state; + if ("branch" in event) essential.branch = event.branch; + break; + + case "pull_request_review_comment_created": + if ("comment_id" in event) essential.comment_id = event.comment_id; + if ("comment_body" in event) essential.comment_body = event.comment_body; + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("branch" in event) essential.branch = event.branch; + break; + + case "check_suite_completed": + if ("pr_title" in event) essential.pr_title = event.pr_title; + if ("pr_body" in event) essential.pr_body = event.pr_body; + if ("branch" in event) essential.branch = event.branch; + if ("check_suite" in event) { + essential.check_suite = { + id: event.check_suite.id, + head_sha: event.check_suite.head_sha, + head_branch: event.check_suite.head_branch, + status: event.check_suite.status, + conclusion: event.check_suite.conclusion, + }; + } + break; + + case "workflow_dispatch": + if ("inputs" in event) essential.inputs = event.inputs; + break; + } + + return essential; +} + export const addInstructions = (payload: Payload) => { let encodedEvent = ""; @@ -10,7 +94,9 @@ export const addInstructions = (payload: Payload) => { if (eventKeys.length === 1 && eventKeys[0] === "trigger") { // no meaningful event data to encode } else { - encodedEvent = toonEncode(payload.event); + // extract only essential fields to reduce token usage + const essentialEvent = extractEssentialEventData(payload.event); + encodedEvent = toonEncode(essentialEvent); } return ` @@ -82,7 +168,22 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g **GitHub CLI prohibition**: Do not use the \`gh\` CLI under any circumstances. Use the corresponding tool from ${ghPullfrogMcpName} instead. -**Authentication**: Do not attempt to configure git credentials, generate tokens, or handle GitHub authentication manually. The ${ghPullfrogMcpName} server handles all authentication internally. +**Git operations**: All git operations must use ${ghPullfrogMcpName} MCP tools to ensure proper authentication and commit attribution. Do NOT use git commands directly (e.g., \`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) - these will use incorrect credentials and attribute commits to the wrong author. + +**Available git MCP tools**: +- \`${ghPullfrogMcpName}/create_branch\` - Create a new branch from a base branch +- \`${ghPullfrogMcpName}/commit_files\` - Stage and commit files with proper authentication +- \`${ghPullfrogMcpName}/push_branch\` - Push a branch to the remote repository +- \`${ghPullfrogMcpName}/create_pull_request\` - Create a PR from the current branch + +**Workflow for making code changes**: +1. Use file operations to create/modify files +2. Use \`${ghPullfrogMcpName}/create_branch\` to create a new branch +3. Use \`${ghPullfrogMcpName}/commit_files\` to commit your changes +4. Use \`${ghPullfrogMcpName}/push_branch\` to push the branch +5. Use \`${ghPullfrogMcpName}/create_pull_request\` to create a PR + +**Do not attempt to configure git credentials manually** - the ${ghPullfrogMcpName} server handles all authentication internally. **Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionableβ€”do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question." diff --git a/agents/opencode.ts b/agents/opencode.ts index 58ecc84..bcf75c9 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -121,35 +121,65 @@ type OpenCodeEvent = let finalOutput = ""; let accumulatedTokens: { input: number; output: number } = { input: 0, output: 0 }; let tokensLogged = false; +const toolCallTimings = new Map(); +let currentStepId: string | null = null; +let currentStepType: string | null = null; +let stepHistory: Array<{ stepId: string; stepType: string; toolCalls: string[] }> = []; const messageHandlers = { - init: (_event: OpenCodeInitEvent) => { + init: (event: OpenCodeInitEvent) => { // initialization event - reset state + log.info( + `πŸ”΅ OpenCode init: session_id=${event.session_id || "unknown"}, model=${event.model || "unknown"}` + ); finalOutput = ""; accumulatedTokens = { input: 0, output: 0 }; tokensLogged = false; }, message: (event: OpenCodeMessageEvent) => { - // update finalOutput but don't log here - text handler will log - if (event.role === "assistant" && event.content?.trim() && !event.delta) { + if (event.role === "assistant" && event.content?.trim()) { const message = event.content.trim(); if (message) { - finalOutput = message; + if (event.delta) { + // delta messages are streaming thoughts/reasoning + log.info( + `πŸ’­ OpenCode thinking: ${message.substring(0, 300)}${message.length > 300 ? "..." : ""}` + ); + } else { + // complete messages + log.info( + `πŸ’¬ OpenCode message (${event.role}): ${message.substring(0, 100)}${message.length > 100 ? "..." : ""}` + ); + finalOutput = message; + } } + } else if (event.role === "user") { + log.info( + `πŸ’¬ OpenCode message (${event.role}): ${event.content?.substring(0, 100) || ""}${event.content && event.content.length > 100 ? "..." : ""}` + ); } }, text: (event: OpenCodeTextEvent) => { // log from text events only to avoid duplicates if (event.part?.text?.trim()) { const message = event.part.text.trim(); + log.info( + `πŸ“ OpenCode text output: ${message.substring(0, 200)}${message.length > 200 ? "..." : ""}` + ); log.box(message, { title: "OpenCode" }); finalOutput = message; } }, - step_start: (_event: OpenCodeStepStartEvent) => { - // step start - no logging needed + step_start: (event: OpenCodeStepStartEvent) => { + const stepType = event.part?.type || "unknown"; + const stepId = event.part?.id || "unknown"; + currentStepId = stepId; + currentStepType = stepType; + stepHistory.push({ stepId, stepType, toolCalls: [] }); }, step_finish: async (event: OpenCodeStepFinishEvent) => { + const stepId = event.part?.id || "unknown"; + // accumulate tokens from step_finish events (they come here, not in result) const eventTokens = event.part?.tokens; if (eventTokens) { @@ -160,9 +190,30 @@ const messageHandlers = { accumulatedTokens.input += inputTokens; accumulatedTokens.output += outputTokens; } + + // clear current step + if (currentStepId === stepId) { + currentStepId = null; + currentStepType = null; + } }, tool_use: (event: OpenCodeToolUseEvent) => { - if (event.tool_name) { + if (event.tool_name && event.tool_id) { + toolCallTimings.set(event.tool_id, Date.now()); + const paramsStr = event.parameters + ? JSON.stringify(event.parameters).substring(0, 500) + : "{}"; + const stepContext = currentStepId + ? ` (step=${currentStepType || "unknown"}, stepId=${currentStepId.substring(0, 20)}...)` + : ""; + log.info(`πŸ”§ OpenCode tool_use: ${event.tool_name}${stepContext}, id=${event.tool_id}`); + log.info(` Parameters: ${paramsStr}${paramsStr.length >= 500 ? "..." : ""}`); + + // track tool call in current step + if (stepHistory.length > 0) { + stepHistory[stepHistory.length - 1].toolCalls.push(event.tool_name); + } + log.toolCall({ toolName: event.tool_name, input: event.parameters || {}, @@ -170,20 +221,54 @@ const messageHandlers = { } }, tool_result: (event: OpenCodeToolResultEvent) => { + if (event.tool_id) { + const toolStartTime = toolCallTimings.get(event.tool_id); + if (toolStartTime) { + const toolDuration = Date.now() - toolStartTime; + toolCallTimings.delete(event.tool_id); + const status = event.status || "unknown"; + const stepContext = currentStepId ? ` (step=${currentStepType || "unknown"})` : ""; + const outputPreview = + typeof event.output === "string" + ? event.output.substring(0, 500) + : JSON.stringify(event.output).substring(0, 500); + log.info( + `πŸ”§ OpenCode tool_result${stepContext}: id=${event.tool_id}, status=${status}, duration=${toolDuration}ms` + ); + if (outputPreview && outputPreview !== "{}" && outputPreview !== "null") { + log.info(` Output: ${outputPreview}${outputPreview.length >= 500 ? "..." : ""}`); + } + if (toolDuration > 5000) { + log.warning( + `⚠️ Tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing` + ); + } + } + } if (event.status === "error") { const errorMsg = typeof event.output === "string" ? event.output : JSON.stringify(event.output); - log.warning(`Tool call failed: ${errorMsg}`); + log.warning(`❌ Tool call failed: ${errorMsg}`); } }, result: async (event: OpenCodeResultEvent) => { + const status = event.status || "unknown"; + const duration = event.stats?.duration_ms || 0; + const toolCalls = event.stats?.tool_calls || 0; + log.info( + `🏁 OpenCode result: status=${status}, duration=${duration}ms, tool_calls=${toolCalls}` + ); + if (event.status === "error") { - log.error(`OpenCode CLI failed: ${JSON.stringify(event)}`); + log.error(`❌ OpenCode CLI failed: ${JSON.stringify(event)}`); } else { // log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish) const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0; const outputTokens = event.stats?.output_tokens || accumulatedTokens.output || 0; const totalTokens = event.stats?.total_tokens || inputTokens + outputTokens; + log.info( + `πŸ“Š OpenCode final stats: input=${inputTokens}, output=${outputTokens}, total=${totalTokens}, tool_calls=${toolCalls}, duration=${duration}ms` + ); if ((inputTokens > 0 || outputTokens > 0) && !tokensLogged) { await log.summaryTable([ @@ -210,34 +295,15 @@ export const opencode = agent({ installDependencies: true, }); }, - run: async ({ payload, apiKey, apiKeys, mcpServers, cliPath }) => { + run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath }) => { // 1. configure home/config directory const tempHome = process.env.PULLFROG_TEMP_DIR!; const configDir = join(tempHome, ".config", "opencode"); mkdirSync(configDir, { recursive: true }); - if (!apiKey) { - throw new Error("at least one API key is required for opencode agent"); - } - - // 2. build env vars from all available API keys (map to OpenCode's expected env var names) - const apiKeyEnv: Record = {}; - const envVarMap: Record = { - anthropic_api_key: "ANTHROPIC_API_KEY", - openai_api_key: "OPENAI_API_KEY", - google_api_key: "GOOGLE_GENERATIVE_AI_API_KEY", - gemini_api_key: "GOOGLE_GENERATIVE_AI_API_KEY", - }; - for (const [inputKey, value] of Object.entries(apiKeys || {})) { - const envVarName = envVarMap[inputKey] || inputKey.toUpperCase(); - apiKeyEnv[envVarName] = value; - } - - // 3. initialize MCP servers and sandbox configureOpenCodeMcpServers({ mcpServers }); configureOpenCodeSandbox({ sandbox: payload.sandbox ?? false }); - // 4. prepare prompt and args const prompt = addInstructions(payload); const args = ["run", "--format", "json", prompt]; @@ -246,25 +312,41 @@ export const opencode = agent({ } // 6. set up environment - const packageDir = join(cliPath, "..", ".."); - setupProcessAgentEnv({ HOME: tempHome, ...apiKeyEnv }); - // don't pass GITHUB_TOKEN to opencode - it may try to use it incorrectly + setupProcessAgentEnv({ HOME: tempHome }); + + // build env vars: start with process.env (includes all API_KEY vars loaded by config()) + // exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token + // then override with apiKeys and HOME const env: Record = { - PATH: process.env.PATH || "", + ...(Object.fromEntries( + Object.entries(process.env).filter( + ([key, value]) => value !== undefined && key !== "GITHUB_TOKEN" + ) + ) as Record), HOME: tempHome, - LOG_LEVEL: process.env.LOG_LEVEL || "", - NODE_ENV: process.env.NODE_ENV || "", - ...apiKeyEnv, }; - // 7. spawn and stream JSON output + // add/override API keys from apiKeys object (uppercase keys) + for (const [key, value] of Object.entries(apiKeys || {})) { + env[key.toUpperCase()] = value; + } + + // run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir) + const repoDir = process.cwd(); + + log.info(`πŸš€ Starting OpenCode CLI: ${cliPath} ${args.join(" ")}`); + log.info(`πŸ“ Working directory: ${repoDir}`); + const startTime = Date.now(); + let lastActivityTime = startTime; + let eventCount = 0; + let output = ""; const result = await spawn({ cmd: cliPath, args, - cwd: packageDir, + cwd: repoDir, env, - timeout: 300000, + timeout: 600000, // 10 minutes timeout to prevent infinite hangs stdio: ["ignore", "pipe", "pipe"], onStdout: async (chunk) => { const text = chunk.toString(); @@ -280,9 +362,39 @@ export const opencode = agent({ try { const event = JSON.parse(trimmed) as OpenCodeEvent; + eventCount++; + const timeSinceLastActivity = Date.now() - lastActivityTime; + if (timeSinceLastActivity > 10000) { + const activeToolCalls = toolCallTimings.size; + const toolCallInfo = + activeToolCalls > 0 + ? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})` + : " (OpenCode may be processing internally - LLM calls, planning, etc.)"; + log.warning( + `⚠️ No activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)` + ); + } + lastActivityTime = Date.now(); const handler = messageHandlers[event.type as keyof typeof messageHandlers]; if (handler) { await handler(event as never); + } else { + // log unhandled event types for visibility (but don't spam) + if ( + event.type && + ![ + "init", + "message", + "text", + "step_start", + "step_finish", + "tool_use", + "tool_result", + "result", + ].includes(event.type) + ) { + log.debug(`πŸ“‹ OpenCode event (unhandled): type=${event.type}`); + } } } catch { // non-JSON lines are ignored @@ -297,6 +409,9 @@ export const opencode = agent({ }, }); + const duration = Date.now() - startTime; + log.info(`βœ… OpenCode CLI completed in ${duration}ms with exit code ${result.exitCode}`); + // 8. log tokens if they weren't logged yet (fallback if result event wasn't emitted) if (!tokensLogged && (accumulatedTokens.input > 0 || accumulatedTokens.output > 0)) { const totalTokens = accumulatedTokens.input + accumulatedTokens.output; diff --git a/external.ts b/external.ts index a2b33c2..62d0146 100644 --- a/external.ts +++ b/external.ts @@ -40,7 +40,7 @@ export const agentsManifest = { }, opencode: { displayName: "OpenCode", - apiKeyNames: ["anthropic_api_key", "openai_api_key", "google_api_key", "gemini_api_key"], + apiKeyNames: [], // empty array means OpenCode accepts any API_KEY from environment url: "https://opencode.ai", }, } as const satisfies Record; diff --git a/fixtures/basic.txt b/fixtures/basic.txt index a13899a..4e5df9b 100644 --- a/fixtures/basic.txt +++ b/fixtures/basic.txt @@ -1 +1 @@ -Use the debug_shell_command MCP tool to run `git status` and show me the output \ No newline at end of file +Use the debug_shell_command MCP tool to run `git status` and tell me what agent is running \ No newline at end of file diff --git a/main.ts b/main.ts index e5e4672..6ede00a 100644 --- a/main.ts +++ b/main.ts @@ -112,9 +112,14 @@ export async function main(inputs: Inputs): Promise { * Get agents that have matching API keys in the inputs */ function getAvailableAgents(inputs: Inputs): (typeof agents)[AgentName][] { - return Object.values(agents).filter((agent) => - agent.apiKeyNames.some((inputKey) => inputs[inputKey]) - ); + return Object.values(agents).filter((agent) => { + // for OpenCode, check if any API_KEY variable exists in inputs + if (agent.name === "opencode") { + return Object.keys(inputs).some((key) => key.includes("api_key")); + } + // for other agents, check apiKeyNames + return agent.apiKeyNames.some((inputKey) => inputs[inputKey]); + }); } /** @@ -141,14 +146,21 @@ async function throwMissingApiKeyError({ const apiUrl = process.env.API_URL || "https://pullfrog.com"; const settingsUrl = `${apiUrl}/console/${repoContext.owner}/${repoContext.name}`; - const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames(); - const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); - const secretNameList = - inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; - const githubRepoUrl = `https://github.com/${repoContext.owner}/${repoContext.name}`; const githubSecretsUrl = `${githubRepoUrl}/settings/secrets/actions`; + // for OpenCode, use a generic message since it accepts any API key + const isOpenCode = agent?.name === "opencode"; + let secretNameList: string; + if (isOpenCode) { + secretNameList = + "any API key (e.g., `OPENCODE_API_KEY`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)"; + } else { + const inputKeys = agent?.apiKeyNames || getAllPossibleKeyNames(); + const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); + secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; + } + let message = `${ agent === null ? "Pullfrog has no agent configured and no API keys are available in the environment." @@ -255,7 +267,10 @@ async function resolveAgent( } // for repo-level defaults, check if agent has matching keys before selecting - const hasMatchingKey = agent.apiKeyNames.some((inputKey) => inputs[inputKey]); + const hasMatchingKey = + agent.name === "opencode" + ? Object.keys(inputs).some((key) => key.includes("api_key")) + : agent.apiKeyNames.some((inputKey) => inputs[inputKey]); if (!hasMatchingKey) { log.warning( `Repo default agent ${agentName} has no matching API keys. Available agents: ${ @@ -366,6 +381,17 @@ async function validateApiKey(ctx: MainContext): Promise { } } + // for OpenCode: if no keys found in inputs, check process.env for any API_KEY variables + if (ctx.agentName === "opencode" && Object.keys(apiKeys).length === 0) { + for (const [key, value] of Object.entries(process.env)) { + if (value && typeof value === "string" && key.includes("API_KEY")) { + // convert env var name back to input key format (lowercase with underscores) + const inputKey = key.toLowerCase(); + apiKeys[inputKey] = value; + } + } + } + if (Object.keys(apiKeys).length === 0) { await throwMissingApiKeyError({ agent: ctx.agent, diff --git a/mcp/git.ts b/mcp/git.ts new file mode 100644 index 0000000..fb87e15 --- /dev/null +++ b/mcp/git.ts @@ -0,0 +1,154 @@ +import { type } from "arktype"; +import { log } from "../utils/cli.ts"; +import { containsSecrets } from "../utils/secrets.ts"; +import { $ } from "../utils/shell.ts"; +import { contextualize, tool } from "./shared.ts"; + +export const CreateBranch = type({ + branchName: type.string.describe( + "The name of the branch to create (e.g., 'pullfrog/123-fix-bug')" + ), + baseBranch: type.string.describe("The base branch to create from (e.g., 'main')").default("main"), +}); + +export const CreateBranchTool = tool({ + name: "create_branch", + description: + "Create a new git branch from the specified base branch. The branch will be created locally and pushed to the remote repository.", + parameters: CreateBranch, + execute: contextualize(async ({ branchName, baseBranch }) => { + // validate branch name for secrets + if (containsSecrets(branchName)) { + throw new Error( + "Branch creation blocked: secrets detected in branch name. " + + "Please remove any sensitive information (API keys, tokens, passwords) before creating a branch." + ); + } + + log.info(`Creating branch ${branchName} from ${baseBranch}`); + + // fetch base branch to ensure we're up to date + $("git", ["fetch", "origin", baseBranch, "--depth=1"]); + + // checkout base branch + $("git", ["checkout", baseBranch]); + + // create and checkout new branch + $("git", ["checkout", "-b", branchName]); + + // push branch to remote (set upstream) + $("git", ["push", "-u", "origin", branchName]); + + log.info(`Successfully created and pushed branch ${branchName}`); + + return { + success: true, + branchName, + baseBranch, + message: `Branch ${branchName} created from ${baseBranch} and pushed to remote`, + }; + }), +}); + +export const CommitFiles = type({ + message: type.string.describe("The commit message"), + files: type.string + .array() + .describe( + "Array of file paths to commit (relative to repo root). If empty, commits all staged changes." + ), +}); + +export const CommitFilesTool = tool({ + name: "commit_files", + description: + "Stage and commit files with a commit message. If files array is empty, commits all staged changes. The commit will be attributed to the correct bot account.", + parameters: CommitFiles, + execute: contextualize(async ({ message, files }) => { + // validate commit message for secrets + if (containsSecrets(message)) { + throw new Error( + "Commit blocked: secrets detected in commit message. " + + "Please remove any sensitive information (API keys, tokens, passwords) before committing." + ); + } + + // validate files for secrets if provided + if (files.length > 0) { + for (const file of files) { + try { + // try to read file content - if it exists, check for secrets + const content = $("cat", [file], { log: false }); + if (containsSecrets(content)) { + throw new Error( + `Commit blocked: secrets detected in file ${file}. ` + + "Please remove any sensitive information (API keys, tokens, passwords) before committing." + ); + } + } catch (error) { + // if error is about secrets, re-throw it + if (error instanceof Error && error.message.includes("Commit blocked")) { + throw error; + } + // if file doesn't exist (cat fails), that's ok - it will be created by git add + // other errors are also ok - git add will handle them + } + } + } + + const currentBranch = $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); + log.info(`Committing files on branch ${currentBranch}`); + + // stage files if provided, otherwise stage all changes + if (files.length > 0) { + $("git", ["add", ...files]); + } else { + $("git", ["add", "."]); + } + + // commit with message + $("git", ["commit", "-m", message]); + + const commitSha = $("git", ["rev-parse", "HEAD"], { log: false }); + log.info(`Successfully committed: ${commitSha.substring(0, 7)}`); + + return { + success: true, + commitSha, + branch: currentBranch, + message: `Committed ${files.length > 0 ? files.length + " file(s)" : "all changes"} with message: ${message}`, + }; + }), +}); + +export const PushBranch = type({ + branchName: type.string + .describe("The branch name to push (defaults to current branch)") + .optional(), + force: type.boolean.describe("Force push (use with caution)").default(false), +}); + +export const PushBranchTool = tool({ + name: "push_branch", + description: + "Push the current branch (or specified branch) to the remote repository. Never force push unless explicitly requested.", + parameters: PushBranch, + execute: contextualize(async ({ branchName, force }) => { + const branch = branchName || $("git", ["rev-parse", "--abbrev-ref", "HEAD"], { log: false }); + + if (force) { + log.warning(`Force pushing branch ${branch} - this will overwrite remote history`); + $("git", ["push", "--force", "origin", branch]); + } else { + log.info(`Pushing branch ${branch} to remote`); + $("git", ["push", "origin", branch]); + } + + return { + success: true, + branch, + force, + message: `Successfully pushed branch ${branch} to remote`, + }; + }), +}); diff --git a/mcp/issueComments.ts b/mcp/issueComments.ts index 6c2acbd..f66ce57 100644 --- a/mcp/issueComments.ts +++ b/mcp/issueComments.ts @@ -7,7 +7,8 @@ export const GetIssueComments = type({ export const GetIssueCommentsTool = tool({ name: "get_issue_comments", - description: "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", + description: + "Get all comments for a GitHub issue. Returns all comments including the issue body and all subsequent discussion comments.", parameters: GetIssueComments, execute: contextualize(async ({ issue_number }, ctx) => { const comments = await ctx.octokit.paginate(ctx.octokit.rest.issues.listComments, { @@ -32,4 +33,3 @@ export const GetIssueCommentsTool = tool({ }; }), }); - diff --git a/mcp/server.ts b/mcp/server.ts index 551086c..975e3b3 100644 --- a/mcp/server.ts +++ b/mcp/server.ts @@ -11,6 +11,7 @@ import { ReportProgressTool, } from "./comment.ts"; import { DebugShellCommandTool } from "./debug.ts"; +import { CommitFilesTool, CreateBranchTool, PushBranchTool } from "./git.ts"; import { IssueTool } from "./issue.ts"; import { GetIssueCommentsTool } from "./issueComments.ts"; import { GetIssueEventsTool } from "./issueEvents.ts"; @@ -87,6 +88,9 @@ export async function startMcpHttpServer( GetCheckSuiteLogsTool, DebugShellCommandTool, AddLabelsTool, + CreateBranchTool, + CommitFilesTool, + PushBranchTool, ]; // only include ReportProgressTool if progress comment is not disabled diff --git a/mcp/shared.ts b/mcp/shared.ts index 1fae2ad..5fd66e4 100644 --- a/mcp/shared.ts +++ b/mcp/shared.ts @@ -42,10 +42,11 @@ export function isProgressCommentDisabled(): boolean { export const tool = (toolDef: Tool>) => toolDef; /** - * Sanitize JSON schema to remove problematic fields that Gemini CLI can't handle + * Sanitize JSON schema to remove problematic fields that Gemini CLI/API can't handle * - Removes $schema field (causes "no schema with key or ref" errors) * - Converts $defs to definitions (draft-07 compatibility) * - Removes any draft-2020-12 specific features + * - Converts any_of with enum values to direct STRING enum (Google API requirement) */ function sanitizeSchema(schema: any): any { if (!schema || typeof schema !== "object") { @@ -56,6 +57,43 @@ function sanitizeSchema(schema: any): any { return schema.map(sanitizeSchema); } + // handle any_of with enum values - convert to direct STRING enum for Google API + // Google API requires: {type: "string", enum: [...]} not {anyOf: [{enum: [...]}, {enum: [...]}]} + if (schema.anyOf && Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { + const enumValues: string[] = []; + let allAreEnumObjects = true; + + for (const item of schema.anyOf) { + if (item && typeof item === "object" && Array.isArray(item.enum)) { + // collect enum values (only strings) + const stringEnums = item.enum.filter((v: any) => typeof v === "string"); + if (stringEnums.length > 0) { + enumValues.push(...stringEnums); + } else { + allAreEnumObjects = false; + break; + } + } else { + allAreEnumObjects = false; + break; + } + } + + // if all any_of items are enum objects with string values, convert to direct STRING enum + if (allAreEnumObjects && enumValues.length > 0) { + const uniqueEnums = [...new Set(enumValues)]; + // preserve other properties from the original schema (like description) + const result: any = { + type: "string", + enum: uniqueEnums, + }; + if (schema.description) { + result.description = schema.description; + } + return result; + } + } + const sanitized: any = {}; for (const [key, value] of Object.entries(schema)) { @@ -64,6 +102,11 @@ function sanitizeSchema(schema: any): any { continue; } + // skip any_of if we already converted it above + if (key === "anyOf" && schema.anyOf) { + continue; + } + // convert $defs to definitions for draft-07 compatibility if (key === "$defs") { sanitized.definitions = sanitizeSchema(value); @@ -120,8 +163,10 @@ function sanitizeTool>(tool: T): T { } export const addTools = (server: FastMCP, tools: Tool[]) => { - // only sanitize schemas for gemini agent (it has issues with draft-2020-12 schemas) - const shouldSanitize = mcpInitContext?.agentName === "gemini"; + // 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 = + mcpInitContext?.agentName === "gemini" || mcpInitContext?.agentName === "opencode"; for (const tool of tools) { const processedTool = shouldSanitize ? sanitizeTool(tool) : tool; diff --git a/modes.ts b/modes.ts index e0b16c9..856deae 100644 --- a/modes.ts +++ b/modes.ts @@ -13,22 +13,6 @@ export interface GetModesParams { const reportProgressInstruction = `Use ${ghPullfrogMcpName}/report_progress to share progress and results. Continue calling it as you make progress - it will update the same comment. Never create additional comments manually.`; export function getModes({ disableProgressComment }: GetModesParams): Mode[] { - const progressStep = disableProgressComment ? "" : `\n\n6. ${reportProgressInstruction}`; - const finalProgressStep = disableProgressComment - ? "" - : `\n\n9. Call report_progress one final time with a summary of the results and a link to any artifacts created, like PRs or branches. - - If relevant, include links to the issue or comment that triggered the PR. - - If you created a PR, ALWAYS include a "View PR" link. e.g.: - \`\`\`md - [View PR βž”](https://github.com/org/repo/pull/123) - \`\`\` - - If you created a branch without a PR, ALWAYS include a "Create PR" link and a link to the branch. e.g.: - - \`\`\`md - [\`pullfrog/branch-name\`](https://github.com/pullfrog/scratch/tree/pullfrog/branch-name) β€’ [Create PR βž”](https://github.com/pullfrog/scratch/compare/main...pullfrog/branch-name?quick_pull=1&title=&body=) - \`\`\` -`; - return [ { name: "Build", @@ -37,17 +21,35 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] { prompt: `Follow these steps: 1. If the request requires understanding the codebase structure, dependencies, or conventions, gather relevant context. Read AGENTS.md if it exists, understand how to install dependencies, run tests, run builds, and make changes according to best practices). Skip this step if the prompt is trivial and self-contained. -2. Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. +2. Create a branch using ${ghPullfrogMcpName}/create_branch. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit directly to main, master, or production. Do NOT use git commands directly - always use ${ghPullfrogMcpName} MCP tools for git operations. 3. Understand the requirements and any existing plan -4. Make the necessary code changes. Create intermediate commits if called for. +4. Make the necessary code changes using file operations. Then use ${ghPullfrogMcpName}/commit_files to commit your changes, and ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands like \`git commit\` or \`git push\` directly. -5. Test your changes to ensure they work correctly${progressStep} +5. Test your changes to ensure they work correctly -7. When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). +6. ${reportProgressInstruction} -8. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), just make changes the changes in a branch and push them.${finalProgressStep}`, +7. When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123"). + +8. By default, create a PR with an informative title and body. However, if the user explicitly requests a branch without a PR (e.g. "implement X in a new branch", "don't create a PR", "branch only"), you still need to use ${ghPullfrogMcpName}/create_pull_request to ensure commits are properly attributed - you can note in the PR description that it's branch-only if needed. + +9. Call report_progress one final time ONLY if you haven't already included all the important information (PR links, branch links, summary) in a previous report_progress call. If you already called report_progress with complete information including PR links after creating the PR, you do NOT need to call it again. Only make a final call if you need to add missing information. When making the final call, ensure it includes: + - A summary of what was accomplished + - Links to any artifacts created (PRs, branches, issues) + - If you created a PR, ALWAYS include the PR link. e.g.: + \`\`\`md + [View PR βž”](https://github.com/org/repo/pull/123) + \`\`\` + - If you created a branch without a PR, ALWAYS include a "Create PR" link and a link to the branch. e.g.: + + \`\`\`md + [\`pullfrog/branch-name\`](https://github.com/pullfrog/scratch/tree/pullfrog/branch-name) β€’ [Create PR βž”](https://github.com/pullfrog/scratch/compare/main...pullfrog/branch-name?quick_pull=1&title=&body=) + \`\`\` + + **IMPORTANT**: Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task. Please review the PR." If your previous report_progress call already contains all the necessary information and links, skip the final call entirely. +`, }, { name: "Address Reviews", @@ -66,7 +68,10 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] { 6. Test your changes to ensure they work correctly.${disableProgressComment ? "" : `\n\n7. ${reportProgressInstruction}`} -8. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one.${disableProgressComment ? "" : "\n\n9. Call report_progress one final time with a summary of all changes made."}`, +8. When done, commit and push your changes to the existing PR branch. Do not create a new branch or PR - you are updating an existing one. + +9. Call report_progress one final time ONLY if you haven't already included a complete summary in a previous report_progress call. If you already called report_progress with complete information, you do NOT need to call it again. Only make a final call if you need to add missing information. **IMPORTANT**: Do NOT overwrite a good comment with details with a generic message. +`, }, { name: "Review", @@ -104,13 +109,17 @@ export function getModes({ disableProgressComment }: GetModesParams): Mode[] { 1. Perform the requested task. Only take action if you have high confidence that you understand what is being asked. If you are not sure, ask for clarification. Take stock of the tools at your disposal.${disableProgressComment ? "" : "\n\n2. When creating comments, always use report_progress. Do not use create_issue_comment."} 2. If the task involves making code changes: - - Create a branch for your work. The branch name should be prefixed with "pullfrog/". The rest of the name should reflect the exact changes you are making. It should be specific to avoid collisions with other branches. Never commit to directly to main, master, or production. - - Make the necessary code changes. Create intermediate commits if called for. + - Create a branch using ${ghPullfrogMcpName}/create_branch. Branch names should be prefixed with "pullfrog/" and reflect the exact changes you are making. Never commit directly to main, master, or production. + - Use file operations to create/modify files with your changes. + - Use ${ghPullfrogMcpName}/commit_files to commit your changes, then ${ghPullfrogMcpName}/push_branch to push the branch. Do NOT use git commands directly (\`git commit\`, \`git push\`, \`git checkout\`, \`git branch\`) as these will use incorrect credentials. - Test your changes to ensure they work correctly. - - When you are done, create a final commit. If relevant, indicate which issue the PR addresses somewhere in the commit message (e.g. "Fixes #123"). Create a PR with an informative title and body. If relevant, include links to the issue or comment that triggered the PR.${disableProgressComment ? "" : `\n\n3. ${reportProgressInstruction}\n\n4. When finished with the task, use report_progress one final time to update the comment with a summary of the results and links to any created issues, PRs, etc.`}`, + - When you are done, use ${ghPullfrogMcpName}/create_pull_request to create a PR. If relevant, indicate which issue the PR addresses in the PR body (e.g. "Fixes #123"). Include links to the issue or comment that triggered the PR in the PR body. + +3. ${reportProgressInstruction} + +4. When finished with the task, use report_progress one final time ONLY if you haven't already included all the important information (summary, links to PRs/issues) in a previous report_progress call. If you already called report_progress with complete information including links after creating artifacts, you do NOT need to call it again. **IMPORTANT**: Do NOT overwrite a good comment with links/details with a generic message like "I have completed the task."`, }, ]; } -// default modes for backward compatibility export const modes: Mode[] = getModes({ disableProgressComment: undefined }); diff --git a/play.ts b/play.ts index 8e79c35..523bc4a 100644 --- a/play.ts +++ b/play.ts @@ -27,12 +27,23 @@ export async function run(prompt: string): Promise { // check if prompt is a pullfrog payload and extract agent // note: agent from payload will be used by determineAgent with highest precedence // we don't need to extract it here since main() will parse the payload - const inputs: Required = { + const inputs = { prompt, - ...flatMorph(agents, (_, agent) => - agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]) - ), - }; + ...flatMorph(agents, (_, agent) => { + // for OpenCode, scan all API_KEY environment variables + if (agent.name === "opencode") { + const opencodeKeys: Array<[string, string | undefined]> = []; + for (const [key, value] of Object.entries(process.env)) { + if (value && typeof value === "string" && key.includes("API_KEY")) { + opencodeKeys.push([key.toLowerCase(), value]); + } + } + return opencodeKeys; + } + // for other agents, use apiKeyNames + return agent.apiKeyNames.map((inputKey) => [inputKey, process.env[inputKey.toUpperCase()]]); + }), + } as Required; const result = await main(inputs); diff --git a/utils/secrets.ts b/utils/secrets.ts index 38a87fa..076b3f1 100644 --- a/utils/secrets.ts +++ b/utils/secrets.ts @@ -20,6 +20,16 @@ function getAllSecrets(): string[] { } } + // for OpenCode: also scan all API_KEY environment variables (since apiKeyNames is empty) + const opencodeAgent = agentsManifest.opencode; + if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) { + for (const [key, value] of Object.entries(process.env)) { + if (value && typeof value === "string" && key.includes("API_KEY")) { + secrets.push(value); + } + } + } + // add GitHub installation token try { const token = getGitHubInstallationToken(); diff --git a/utils/subprocess.ts b/utils/subprocess.ts index d3c9c6d..72ddd1e 100644 --- a/utils/subprocess.ts +++ b/utils/subprocess.ts @@ -92,13 +92,16 @@ export async function spawn(options: SpawnOptions): Promise { }); }); - child.on("error", (_error) => { + child.on("error", (error) => { const durationMs = Date.now() - startTime; if (timeoutId) { clearTimeout(timeoutId); } + // log spawn errors for debugging + console.error(`[spawn] Process spawn error: ${error.message}`); + resolve({ stdout: stdoutBuffer, stderr: stderrBuffer,