Merge pull request #21 from pullfrog/effort

add effort as an input + support parsing from payload
This commit is contained in:
Colin McDonnell
2026-01-12 14:12:42 -08:00
committed by GitHub
19 changed files with 11212 additions and 10821 deletions
+8 -7
View File
@@ -84,6 +84,7 @@ on:
prompt: prompt:
description: 'Agent prompt' description: 'Agent prompt'
type: string type: string
secrets: inherit
permissions: permissions:
id-token: write id-token: write
@@ -105,14 +106,14 @@ jobs:
- name: Run agent - name: Run agent
uses: pullfrog/action@v0 uses: pullfrog/action@v0
with: with:
prompt: ${{ github.event.inputs.prompt }} prompt: ${{ inputs.prompt }}
env:
# feel free to comment out any you won't use # feel free to comment out any you won't use
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
google_api_key: ${{ secrets.GOOGLE_API_KEY }} GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
gemini_api_key: ${{ secrets.GEMINI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
cursor_api_key: ${{ secrets.CURSOR_API_KEY }} CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
``` ```
+7 -19
View File
@@ -1,30 +1,18 @@
name: "Pullfrog Claude Code Action" name: "Pullfrog Action"
description: "Execute Claude Code with a prompt using Anthropic API" description: "Execute coding agents with a prompt"
author: "Pullfrog" author: "Pullfrog"
inputs: inputs:
prompt: prompt:
description: "Prompt to send to Claude Code" description: "Prompt to send to the agent"
required: true required: true
default: "Hello from Claude Code!" effort:
anthropic_api_key: description: "Effort level: nothink (fast), think (default), max (most capable)"
description: "Anthropic API key for Claude Code authentication"
required: false
openai_api_key:
description: "OpenAI API key for Codex authentication"
required: false
google_api_key:
description: "Google API key for Jules authentication"
required: false
gemini_api_key:
description: "Gemini API key for Jules authentication"
required: false
cursor_api_key:
description: "Cursor API key for Cursor authentication"
required: false required: false
default: "think"
runs: runs:
using: "node20" using: "node24"
main: "entry" main: "entry"
branding: branding:
+29 -8
View File
@@ -1,9 +1,24 @@
import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { type Options, query, type SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import type { Effort } from "../external.ts";
import packageJson from "../package.json" with { type: "json" }; import packageJson from "../package.json" with { type: "json" };
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts"; import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
// Model selection based on effort level
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
const claudeEffortModels: Record<Effort, string> = {
nothink: "haiku",
think: "opusplan",
max: "opus",
};
// FUTURE: Consider using Anthropic's "effort" parameter (beta) with Opus 4.5 for all tasks.
// This would allow a single model with effort levels ("low", "medium", "high") controlling
// token spend across responses, tool calls, and thinking. Requires beta header "effort-2025-11-24".
// See: https://platform.claude.com/docs/en/build-with-claude/effort
// This approach could replace model selection if effort proves effective for controlling capability.
export const claude = agent({ export const claude = agent({
name: "claude", name: "claude",
install: async () => { install: async () => {
@@ -14,13 +29,17 @@ export const claude = agent({
executablePath: "cli.js", executablePath: "cli.js",
}); });
}, },
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => { run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
// Ensure API key is NOT in process.env - only pass via SDK's env option // Ensure API key is NOT in process.env - only pass via SDK's env option
delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_API_KEY;
const prompt = addInstructions({ payload, repo }); const prompt = addInstructions({ payload, repo });
log.group("Full prompt", () => log.info(prompt)); log.group("Full prompt", () => log.info(prompt));
// select model based on effort level
const model = claudeEffortModels[effort];
log.info(`Using model: ${model} (effort: ${effort})`);
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys. // SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
// disable native Bash; agents use MCP bash tool which filters secrets. // disable native Bash; agents use MCP bash tool which filters secrets.
// for private repos, native Bash is allowed since secrets are less exposed. // for private repos, native Bash is allowed since secrets are less exposed.
@@ -46,15 +65,17 @@ export const claude = agent({
// Pass secrets via SDK's env option only (not process.env) // Pass secrets via SDK's env option only (not process.env)
// This ensures secrets are only available to Claude Code subprocess, not user code // This ensures secrets are only available to Claude Code subprocess, not user code
const queryOptions: Options = {
...sandboxOptions,
mcpServers,
model,
pathToClaudeCodeExecutable: cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
};
const queryInstance = query({ const queryInstance = query({
prompt, prompt,
options: { options: queryOptions,
...sandboxOptions,
mcpServers,
// model: "claude-opus-4-5",
pathToClaudeCodeExecutable: cliPath,
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
},
}); });
// Stream the results // Stream the results
+54 -16
View File
@@ -1,11 +1,33 @@
import { mkdirSync, writeFileSync } from "node:fs"; import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk"; import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk"; import {
Codex,
type CodexOptions,
type ModelReasoningEffort,
type ThreadEvent,
type ThreadOptions,
} from "@openai/codex-sdk";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts"; import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
// model configuration based on effort level
const codexModel: Record<Effort, string> = {
nothink: "gpt-5.1-codex-mini",
think: "gpt-5.1-codex",
max: "gpt-5.1-codex-max",
} as const;
// reasoning effort configuration based on effort level
// uses modelReasoningEffort parameter from ThreadOptions
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
nothink: "low",
think: undefined, // use default
max: "high",
};
interface WriteCodexConfigParams { interface WriteCodexConfigParams {
tempHome: string; tempHome: string;
mcpServers: Record<string, McpHttpServerConfig>; mcpServers: Record<string, McpHttpServerConfig>;
@@ -60,7 +82,7 @@ export const codex = agent({
executablePath: "bin/codex.js", executablePath: "bin/codex.js",
}); });
}, },
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => { run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
const tempHome = process.env.PULLFROG_TEMP_DIR!; const tempHome = process.env.PULLFROG_TEMP_DIR!;
// create config directory for codex before setting HOME // create config directory for codex before setting HOME
@@ -79,6 +101,14 @@ export const codex = agent({
CODEX_HOME: codexDir, // point Codex to our config directory CODEX_HOME: codexDir, // point Codex to our config directory
}); });
// get model and reasoning effort based on effort level
const model = codexModel[effort];
const modelReasoningEffort = codexReasoningEffort[effort];
log.info(`Using model: ${model}`);
if (modelReasoningEffort) {
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
}
// Configure Codex // Configure Codex
const codexOptions: CodexOptions = { const codexOptions: CodexOptions = {
apiKey, apiKey,
@@ -90,20 +120,28 @@ export const codex = agent({
} }
const codex = new Codex(codexOptions); const codex = new Codex(codexOptions);
const thread = codex.startThread(
payload.sandbox // Build thread options with model and optional model_reasoning_effort
? { const baseThreadOptions = payload.sandbox
approvalPolicy: "never", ? {
sandboxMode: "read-only", model,
networkAccessEnabled: false, approvalPolicy: "never" as const,
} sandboxMode: "read-only" as const,
: { networkAccessEnabled: false,
approvalPolicy: "never", }
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes) : {
sandboxMode: "danger-full-access", model,
networkAccessEnabled: true, approvalPolicy: "never" as const,
} // use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
); sandboxMode: "danger-full-access" as const,
networkAccessEnabled: true,
};
const threadOptions: ThreadOptions = modelReasoningEffort
? { ...baseThreadOptions, modelReasoningEffort }
: baseThreadOptions;
const thread = codex.startThread(threadOptions);
try { try {
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo })); const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
+46 -11
View File
@@ -1,7 +1,8 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
import { import {
@@ -11,6 +12,14 @@ import {
installFromCurl, installFromCurl,
} from "./shared.ts"; } from "./shared.ts";
// effort configuration for Cursor
// only "max" overrides the model; nothink/think use default ("auto")
const cursorEffortModels: Record<Effort, string | null> = {
nothink: null, // use default (auto)
think: null, // use default (auto)
max: "opus-4.5-thinking",
} as const;
// cursor cli event types inferred from stream-json output // cursor cli event types inferred from stream-json output
interface CursorSystemEvent { interface CursorSystemEvent {
type: "system"; type: "system";
@@ -91,10 +100,36 @@ export const cursor = agent({
executableName: "cursor-agent", executableName: "cursor-agent",
}); });
}, },
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => { run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
configureCursorMcpServers({ mcpServers, cliPath }); configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic }); configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
// determine model based on effort level
// respect project's .cursor/cli.json if it specifies a model
const projectCliConfigPath = join(process.cwd(), ".cursor", "cli.json");
let modelOverride: string | null = null;
if (existsSync(projectCliConfigPath)) {
try {
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
if (projectConfig.model) {
log.info(`Using model from project .cursor/cli.json: ${projectConfig.model}`);
} else {
modelOverride = cursorEffortModels[effort];
}
} catch {
modelOverride = cursorEffortModels[effort];
}
} else {
modelOverride = cursorEffortModels[effort];
}
if (modelOverride) {
log.info(`Using model: ${modelOverride} (effort: ${effort})`);
} else if (!existsSync(projectCliConfigPath)) {
log.info(`Using default model (effort: ${effort})`);
}
// track logged model_call_ids to avoid duplicates // track logged model_call_ids to avoid duplicates
// cursor emits each assistant message twice: once without model_call_id, then again with it // cursor emits each assistant message twice: once without model_call_id, then again with it
const loggedModelCallIds = new Set<string>(); const loggedModelCallIds = new Set<string>();
@@ -171,16 +206,16 @@ export const cursor = agent({
// configure sandbox mode if enabled // configure sandbox mode if enabled
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings // in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
// add model flag if we have an override
if (modelOverride) {
baseArgs.push("--model", modelOverride);
}
const cursorArgs = payload.sandbox const cursorArgs = payload.sandbox
? [ ? baseArgs // --force removed in sandbox mode to enforce safety checks
"--print", : [...baseArgs, "--force"];
fullPrompt,
"--output-format",
"stream-json",
"--approve-mcps",
// --force removed in sandbox mode to enforce safety checks
]
: ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps", "--force"];
if (payload.sandbox) { if (payload.sandbox) {
log.info("🔒 sandbox mode enabled: restricting to read-only operations"); log.info("🔒 sandbox mode enabled: restricting to read-only operations");
+42 -11
View File
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import type { Effort } from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { spawn } from "../utils/subprocess.ts"; import { spawn } from "../utils/subprocess.ts";
import { addInstructions } from "./instructions.ts"; import { addInstructions } from "./instructions.ts";
@@ -11,6 +12,15 @@ import {
installFromGithub, installFromGithub,
} from "./shared.ts"; } from "./shared.ts";
// effort configuration: model + thinking level
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
} as const;
// gemini cli event types inferred from stream-json output (NDJSON format) // gemini cli event types inferred from stream-json output (NDJSON format)
interface GeminiInitEvent { interface GeminiInitEvent {
type: "init"; type: "init";
@@ -156,8 +166,12 @@ export const gemini = agent({
...(githubInstallationToken && { githubInstallationToken }), ...(githubInstallationToken && { githubInstallationToken }),
}); });
}, },
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => { run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic }); // get model and thinking level based on effort
const { model, thinkingLevel } = geminiEffortConfig[effort];
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel });
if (!apiKey) { if (!apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent"); throw new Error("google_api_key or gemini_api_key is required for gemini agent");
@@ -172,6 +186,8 @@ export const gemini = agent({
if (payload.sandbox) { if (payload.sandbox) {
// sandbox mode: read-only tools only // sandbox mode: read-only tools only
args = [ args = [
"--model",
model,
"--allowed-tools", "--allowed-tools",
"read_file,list_directory,search_file_content,glob,save_memory,write_todos", "read_file,list_directory,search_file_content,glob,save_memory,write_todos",
"--allowed-mcp-server-names", "--allowed-mcp-server-names",
@@ -183,7 +199,7 @@ export const gemini = agent({
} else { } else {
// normal mode: --yolo for auto-approval // normal mode: --yolo for auto-approval
// for public repos, shell is excluded via settings.json excludeTools // for public repos, shell is excluded via settings.json excludeTools
args = ["--yolo", "--output-format=stream-json", "-p", sessionPrompt]; args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
if (repo.isPublic) { if (repo.isPublic) {
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash"); log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
} }
@@ -276,16 +292,22 @@ export const gemini = agent({
type ConfigureGeminiParams = { type ConfigureGeminiParams = {
mcpServers: ConfigureMcpServersParams["mcpServers"]; mcpServers: ConfigureMcpServersParams["mcpServers"];
isPublicRepo: boolean; isPublicRepo: boolean;
thinkingLevel: string;
}; };
/** /**
* Configure MCP servers for Gemini by writing to settings.json. * Configure Gemini CLI settings by writing to settings.json.
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport. * - MCP servers: uses `httpUrl` for HTTP/streamable transport
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md * - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig
* - For public repos, excludeTools disables native shell
* *
* For public repos, also configures excludeTools to disable native shell. * See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
*/ */
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void { function configureGeminiSettings({
mcpServers,
isPublicRepo,
thinkingLevel,
}: ConfigureGeminiParams): void {
const realHome = homedir(); const realHome = homedir();
const geminiConfigDir = join(realHome, ".gemini"); const geminiConfigDir = join(realHome, ".gemini");
const settingsPath = join(geminiConfigDir, "settings.json"); const settingsPath = join(geminiConfigDir, "settings.json");
@@ -329,17 +351,26 @@ function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGemini
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`); log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
} }
// merge with existing settings, overwriting mcpServers // merge with existing settings, overwriting mcpServers and modelConfig
// for public repos, exclude native shell tool to prevent secret leakage via env
const newSettings: Record<string, unknown> = { const newSettings: Record<string, unknown> = {
...existingSettings, ...existingSettings,
mcpServers: geminiMcpServers, mcpServers: geminiMcpServers,
// configure thinking level via modelConfig
// see: https://ai.google.dev/api/generate-content (ThinkingConfig)
modelConfig: {
generateContentConfig: {
thinkingConfig: {
thinkingLevel,
},
},
},
}; };
// for public repos, exclude native shell tool to prevent secret leakage via env
if (isPublicRepo) { if (isPublicRepo) {
newSettings.excludeTools = ["run_shell_command"]; newSettings.excludeTools = ["run_shell_command"];
} }
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8"); writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`» MCP config written to ${settingsPath}`); log.info(`» Gemini settings written to ${settingsPath}`);
} }
+11 -4
View File
@@ -22,7 +22,15 @@ export const opencode = agent({
installDependencies: true, installDependencies: true,
}); });
}, },
run: async ({ payload, apiKey: _apiKey, apiKeys, mcpServers, cliPath, repo }) => { run: async ({
payload,
apiKey: _apiKey,
apiKeys,
mcpServers,
cliPath,
repo,
effort: _effort,
}) => {
// 1. configure home/config directory // 1. configure home/config directory
const tempHome = process.env.PULLFROG_TEMP_DIR!; const tempHome = process.env.PULLFROG_TEMP_DIR!;
const configDir = join(tempHome, ".config", "opencode"); const configDir = join(tempHome, ".config", "opencode");
@@ -60,10 +68,9 @@ export const opencode = agent({
// add API keys from apiKeys object // add API keys from apiKeys object
for (const [key, value] of Object.entries(apiKeys || {})) { for (const [key, value] of Object.entries(apiKeys || {})) {
const upperKey = key.toUpperCase(); env[key.toUpperCase()] = value;
env[upperKey] = value;
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility // also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
if (upperKey === "GEMINI_API_KEY") { if (key === "GEMINI_API_KEY") {
env.GOOGLE_GENERATIVE_AI_API_KEY = value; env.GOOGLE_GENERATIVE_AI_API_KEY = value;
} }
} }
+8 -1
View File
@@ -6,7 +6,13 @@ import { join } from "node:path";
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk"; import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
import type { show } from "@ark/util"; import type { show } from "@ark/util";
import { type AgentManifest, type AgentName, agentsManifest, type Payload } from "../external.ts"; import {
type AgentManifest,
type AgentName,
agentsManifest,
type Effort,
type Payload,
} from "../external.ts";
import { log } from "../utils/cli.ts"; import { log } from "../utils/cli.ts";
import { getGitHubInstallationToken } from "../utils/github.ts"; import { getGitHubInstallationToken } from "../utils/github.ts";
@@ -40,6 +46,7 @@ export interface AgentConfig {
mcpServers: Record<string, McpHttpServerConfig>; mcpServers: Record<string, McpHttpServerConfig>;
cliPath: string; cliPath: string;
repo: RepoInfo; repo: RepoInfo;
effort: Effort;
} }
/** /**
+92
View File
@@ -0,0 +1,92 @@
# Effort Levels
Pullfrog supports three effort levels that control model selection and reasoning depth:
- **`nothink`** — Fast, minimal reasoning. Best for simple tasks.
- **`think`** — Balanced (default). Good for most tasks.
- **`max`** — Maximum capability. Best for complex tasks requiring deep reasoning.
The effort level can be specified via the `effort` input in `action.yml` or in the payload's `effort` field.
---
## Claude Code
Claude Code uses model selection based on effort level.
| Effort | Model | Description |
|--------|-------|-------------|
| `nothink` | `haiku` | Fast, efficient |
| `think` | `opusplan` | Opus for planning, Sonnet for execution |
| `max` | `opus` | Full Opus |
> **Future direction:** Anthropic's beta `effort` parameter (`low`/`medium`/`high`) could replace model selection, using Opus 4.5 for all tasks with effort controlling token spend. See [Anthropic Effort Docs](https://platform.claude.com/docs/en/build-with-claude/effort).
---
## Codex (OpenAI)
Codex uses both model selection and the `modelReasoningEffort` parameter from `ThreadOptions`.
| Effort | Model | `modelReasoningEffort` | Description |
|--------|-------|------------------------|-------------|
| `nothink` | `gpt-5.1-codex-mini` | `"low"` | Smaller model, reduced reasoning |
| `think` | `gpt-5.1-codex` | default | Standard model, default reasoning |
| `max` | `gpt-5.1-codex-max` | `"high"` | Largest model, maximum reasoning |
Valid values for `modelReasoningEffort`: `"minimal"` | `"low"` | `"medium"` | `"high"`
Reference: [Codex Config Reference](https://developers.openai.com/codex/config-reference/)
---
## Gemini
Gemini uses a combination of model selection and `thinkingLevel` configuration via `settings.json`.
| Effort | Model | `thinkingLevel` | Description |
|--------|-------|-----------------|-------------|
| `nothink` | `gemini-2.5-flash` | `LOW` | Fast model, minimal thinking |
| `think` | `gemini-2.5-flash` | `HIGH` | Fast model, deep thinking |
| `max` | `gemini-2.5-pro` | `HIGH` | Most capable model, deep thinking |
The `thinkingLevel` is configured via:
```json
{
"modelConfig": {
"generateContentConfig": {
"thinkingConfig": {
"thinkingLevel": "LOW"
}
}
}
}
```
Reference: [Gemini Thinking Docs](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels)
---
## Cursor
Cursor uses model selection via the `--model` CLI flag. Project-level configuration in `.cursor/cli.json` takes precedence if a `model` is specified there.
| Effort | Model | Description |
|--------|-------|-------------|
| `nothink` | `auto` (default) | Let Cursor select optimal model |
| `think` | `auto` (default) | Let Cursor select optimal model |
| `max` | `opus-4.5-thinking` | Claude 4.5 Opus with thinking |
**Note:** If the project has `.cursor/cli.json` with a `model` field, that model is used regardless of effort level.
---
## OpenCode
OpenCode does not currently have affordances for effort-level configuration. The effort parameter is ignored.
| Effort | Behavior |
|--------|----------|
| `nothink` | No effect |
| `think` | No effect |
| `max` | No effect |
+10669 -10564
View File
File diff suppressed because one or more lines are too long
+4 -8
View File
@@ -5,9 +5,7 @@
*/ */
import * as core from "@actions/core"; import * as core from "@actions/core";
import { flatMorph } from "@ark/util"; import { Inputs, main } from "./main.ts";
import { agents } from "./agents/index.ts";
import { type Inputs, main } from "./main.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
async function run(): Promise<void> { async function run(): Promise<void> {
@@ -20,12 +18,10 @@ async function run(): Promise<void> {
} }
try { try {
const inputs: Required<Inputs> = { const inputs = Inputs.assert({
prompt: core.getInput("prompt", { required: true }), prompt: core.getInput("prompt", { required: true }),
...flatMorph(agents, (_, agent) => effort: core.getInput("effort") || "think",
agent.apiKeyNames.map((inputKey) => [inputKey, core.getInput(inputKey)]) });
),
};
const result = await main(inputs); const result = await main(inputs);
+14 -4
View File
@@ -20,22 +20,22 @@ export interface AgentManifest {
export const agentsManifest = { export const agentsManifest = {
claude: { claude: {
displayName: "Claude Code", displayName: "Claude Code",
apiKeyNames: ["anthropic_api_key"], apiKeyNames: ["ANTHROPIC_API_KEY"],
url: "https://claude.com/claude-code", url: "https://claude.com/claude-code",
}, },
codex: { codex: {
displayName: "Codex CLI", displayName: "Codex CLI",
apiKeyNames: ["openai_api_key"], apiKeyNames: ["OPENAI_API_KEY"],
url: "https://platform.openai.com/docs/guides/codex", url: "https://platform.openai.com/docs/guides/codex",
}, },
cursor: { cursor: {
displayName: "Cursor CLI", displayName: "Cursor CLI",
apiKeyNames: ["cursor_api_key"], apiKeyNames: ["CURSOR_API_KEY"],
url: "https://cursor.com/", url: "https://cursor.com/",
}, },
gemini: { gemini: {
displayName: "Gemini CLI", displayName: "Gemini CLI",
apiKeyNames: ["google_api_key", "gemini_api_key"], apiKeyNames: ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
url: "https://ai.google.dev/gemini-api/docs", url: "https://ai.google.dev/gemini-api/docs",
}, },
opencode: { opencode: {
@@ -51,6 +51,10 @@ export const AgentName = type.enumerated(...Object.keys(agentsManifest));
export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number]; export type AgentApiKeyName = (typeof agentsManifest)[AgentName]["apiKeyNames"][number];
// effort level type - controls model selection and thinking level
export const Effort = type.enumerated("nothink", "think", "max");
export type Effort = typeof Effort.infer;
// base interface for common payload event fields // base interface for common payload event fields
interface BasePayloadEvent { interface BasePayloadEvent {
issue_number?: number; issue_number?: number;
@@ -279,6 +283,12 @@ export interface Payload extends DispatchOptions {
*/ */
modes: readonly Mode[]; modes: readonly Mode[];
/**
* Effort level for model selection (nothink, think, max)
* Defaults to "think" if not specified
*/
readonly effort?: Effort;
/** /**
* Optional IDs of the issue, PR, or comment that the agent is working on * Optional IDs of the issue, PR, or comment that the agent is working on
*/ */
+30
View File
@@ -0,0 +1,30 @@
import type { Effort, Payload } from "../external.ts";
/**
* Test fixture for Cursor effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=cursor pnpm play cursor-effort.ts
*
* Effort levels:
* - "nothink": auto (default model)
* - "think": auto (default model)
* - "max": opus-4.5-thinking
*
* Note: If project has .cursor/cli.json with "model" specified,
* that takes precedence over effort-based model selection.
*/
const efforts: Effort[] = ["nothink", "think", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "cursor",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
+27
View File
@@ -0,0 +1,27 @@
import type { Effort, Payload } from "../external.ts";
/**
* Test fixture for Gemini effort levels.
* Runs all three effort levels in sequence.
*
* Run with:
* AGENT_OVERRIDE=gemini pnpm play gemini-effort.ts
*
* Effort levels:
* - "nothink": gemini-2.5-flash + LOW thinking
* - "think": gemini-2.5-flash + HIGH thinking
* - "max": gemini-2.5-pro + HIGH thinking
*/
const efforts: Effort[] = ["nothink", "think", "max"];
export default efforts.map((effort) => ({
"~pullfrog": true,
agent: "gemini",
prompt: "What is 2 + 2? Reply with just the number.",
event: {
trigger: "workflow_dispatch",
},
modes: [],
effort,
})) satisfies Payload[];
+36 -57
View File
@@ -7,8 +7,7 @@ import { encode as toonEncode } from "@toon-format/toon";
import { type } from "arktype"; import { type } from "arktype";
import { type Agent, agents } from "./agents/index.ts"; import { type Agent, agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts"; import type { AgentResult } from "./agents/shared.ts";
import type { AgentName, Payload } from "./external.ts"; import { type AgentName, agentsManifest, Effort, type Payload } from "./external.ts";
import { agentsManifest } from "./external.ts";
import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts"; import { ensureProgressCommentUpdated, reportProgress } from "./mcp/comment.ts";
import { createMcpConfigs } from "./mcp/config.ts"; import { createMcpConfigs } from "./mcp/config.ts";
import { startMcpHttpServer } from "./mcp/server.ts"; import { startMcpHttpServer } from "./mcp/server.ts";
@@ -18,29 +17,13 @@ import type { PrepResult } from "./prep/index.ts";
import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts"; import { fetchRepoSettings, fetchWorkflowRunInfo, type RepoSettings } from "./utils/api.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts"; import { reportErrorToComment } from "./utils/errorReport.ts";
import { import { createOctokit, parseRepoContext, setupGitHubInstallationToken } from "./utils/github.ts";
createOctokit,
parseRepoContext,
setupGitHubInstallationToken,
} from "./utils/github.ts";
import { setupGitAuth, setupGitConfig } from "./utils/setup.ts"; import { setupGitAuth, setupGitConfig } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts"; import { Timer } from "./utils/timer.ts";
// runtime validation using agents (needed for ArkType)
// Note: The AgentName type is defined in external.ts, this is the runtime validator
export const AgentInputKey = type.enumerated(
...Object.values(agents).flatMap((agent) => agent.apiKeyNames)
);
export type AgentInputKey = typeof AgentInputKey.infer;
const keyInputDefs = flatMorph(agents, (_, agent) =>
agent.apiKeyNames.map((inputKey) => [inputKey, "string | undefined?"] as const)
);
export const Inputs = type({ export const Inputs = type({
prompt: "string", prompt: "string",
...keyInputDefs, "effort?": Effort,
}); });
export type Inputs = typeof Inputs.infer; export type Inputs = typeof Inputs.infer;
@@ -84,7 +67,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
// phase 3: resolve agent (needs repo settings) // phase 3: resolve agent (needs repo settings)
const agent = resolveAgent({ const agent = resolveAgent({
inputs,
payload, payload,
repoSettings: githubSetup.repoSettings, repoSettings: githubSetup.repoSettings,
}); });
@@ -93,7 +75,6 @@ export async function main(inputs: Inputs): Promise<MainResult> {
// phase 4: validate API key (sync, needs agent) - fail fast before long-running operations // phase 4: validate API key (sync, needs agent) - fail fast before long-running operations
const apiKeySetup = validateApiKey({ const apiKeySetup = validateApiKey({
agent, agent,
inputs,
owner: githubSetup.owner, owner: githubSetup.owner,
name: githubSetup.name, name: githubSetup.name,
}); });
@@ -225,24 +206,19 @@ export async function main(inputs: Inputs): Promise<MainResult> {
} }
/** /**
* Get agents that have matching API keys in the inputs * Check if an agent has API keys available (from process.env)
*/ */
/** function agentHasApiKeys(agent: Agent): boolean {
* Check if an agent has API keys available (inputs or process.env for opencode)
*/
function agentHasApiKeys(agent: Agent, inputs: Inputs): boolean {
if (agent.name === "opencode") { if (agent.name === "opencode") {
// check inputs first, then process.env // opencode accepts any API_KEY from environment
const hasInputKey = Object.keys(inputs).some((key) => key.includes("api_key"));
if (hasInputKey) return true;
return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]); return Object.keys(process.env).some((key) => key.includes("API_KEY") && process.env[key]);
} }
const inputsRecord = inputs as Record<string, string | undefined>; // check if any of the agent's expected keys are in environment
return agent.apiKeyNames.some((inputKey) => inputsRecord[inputKey]); return agent.apiKeyNames.some((envKey) => !!process.env[envKey]);
} }
function getAvailableAgents(inputs: Inputs): Agent[] { function getAvailableAgents(): Agent[] {
return Object.values(agents).filter((agent) => agentHasApiKeys(agent, inputs)); return Object.values(agents).filter((agent) => agentHasApiKeys(agent));
} }
/** /**
@@ -275,7 +251,7 @@ function buildMissingApiKeyError(params: { agent: Agent; owner: string; name: st
} else { } else {
const inputKeys = const inputKeys =
params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames(); params.agent.apiKeyNames.length > 0 ? params.agent.apiKeyNames : getAllPossibleKeyNames();
const secretNames = inputKeys.map((key) => `\`${key.toUpperCase()}\``); const secretNames = inputKeys.map((key) => `\`${key}\``);
secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`; secretNameList = inputKeys.length === 1 ? secretNames[0] : `one of ${secretNames.join(" or ")}`;
} }
@@ -361,16 +337,16 @@ async function initializeGitHub(token: string): Promise<GitHubSetup> {
} }
function resolveAgent({ function resolveAgent({
inputs,
payload, payload,
repoSettings, repoSettings,
}: { }: {
inputs: Inputs;
payload: Payload; payload: Payload;
repoSettings: RepoSettings; repoSettings: RepoSettings;
}): Agent { }): Agent {
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined; const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
log.debug(`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`); log.debug(
`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`
);
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null; const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
if (configuredAgentName) { if (configuredAgentName) {
@@ -388,13 +364,13 @@ function resolveAgent({
} }
// for repo-level defaults, check if agent has matching keys before selecting // for repo-level defaults, check if agent has matching keys before selecting
if (agentHasApiKeys(agent, inputs)) { if (agentHasApiKeys(agent)) {
log.info(`Selected configured agent: ${agent.name}`); log.info(`Selected configured agent: ${agent.name}`);
return agent; return agent;
} }
// fall through to auto-selection // fall through to auto-selection
const availableAgents = getAvailableAgents(inputs); const availableAgents = getAvailableAgents();
log.warning( log.warning(
`Repo default agent ${agent.name} has no matching API keys. Available: ${ `Repo default agent ${agent.name} has no matching API keys. Available: ${
availableAgents.map((a) => a.name).join(", ") || "none" availableAgents.map((a) => a.name).join(", ") || "none"
@@ -402,7 +378,7 @@ function resolveAgent({
); );
} }
const availableAgents = getAvailableAgents(inputs); const availableAgents = getAvailableAgents();
if (availableAgents.length === 0) { if (availableAgents.length === 0) {
throw new Error("no agents available - missing API keys"); throw new Error("no agents available - missing API keys");
} }
@@ -425,8 +401,13 @@ function parsePayload(inputs: Inputs): Payload {
if (!("~pullfrog" in parsedPrompt)) { if (!("~pullfrog" in parsedPrompt)) {
throw new Error(); throw new Error();
} }
return parsedPrompt as Payload; // internal invocation: use effort from payload, fallback to input, default to "think"
return {
...parsedPrompt,
effort: parsedPrompt.effort ?? inputs.effort ?? "think",
} as Payload;
} catch { } catch {
// external invocation: use effort from input
return { return {
"~pullfrog": true, "~pullfrog": true,
agent: null, agent: null,
@@ -435,6 +416,7 @@ function parsePayload(inputs: Inputs): Payload {
trigger: "unknown", trigger: "unknown",
}, },
modes, modes,
effort: inputs.effort ?? "think",
}; };
} }
} }
@@ -447,22 +429,22 @@ async function installAgentCli(params: { agent: Agent; token: string }): Promise
return params.agent.install(); return params.agent.install();
} }
function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> { function collectApiKeys(agent: Agent): Record<string, string> {
const apiKeys: Record<string, string> = {}; const apiKeys: Record<string, string> = {};
const inputsRecord = inputs as Record<string, string | undefined>;
for (const inputKey of agent.apiKeyNames) { // read API keys from environment variables
const value = inputsRecord[inputKey]; for (const envKey of agent.apiKeyNames) {
const value = process.env[envKey];
if (value) { if (value) {
apiKeys[inputKey] = value; apiKeys[envKey] = value;
} }
} }
// for OpenCode: also check process.env for any API_KEY variables // for OpenCode: check process.env for any API_KEY variables
if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) { if (agent.name === "opencode" && Object.keys(apiKeys).length === 0) {
for (const [key, value] of Object.entries(process.env)) { for (const [key, value] of Object.entries(process.env)) {
if (value && typeof value === "string" && key.includes("API_KEY")) { if (value && typeof value === "string" && key.includes("API_KEY")) {
apiKeys[key.toLowerCase()] = value; apiKeys[key] = value;
} }
} }
} }
@@ -470,13 +452,8 @@ function collectApiKeys(agent: Agent, inputs: Inputs): Record<string, string> {
return apiKeys; return apiKeys;
} }
function validateApiKey(params: { function validateApiKey(params: { agent: Agent; owner: string; name: string }): ApiKeySetup {
agent: Agent; const apiKeys = collectApiKeys(params.agent);
inputs: Inputs;
owner: string;
name: string;
}): ApiKeySetup {
const apiKeys = collectApiKeys(params.agent, params.inputs);
if (Object.keys(apiKeys).length === 0) { if (Object.keys(apiKeys).length === 0) {
return { return {
@@ -497,7 +474,8 @@ function validateApiKey(params: {
} }
async function runAgent(ctx: AgentContext): Promise<AgentResult> { async function runAgent(ctx: AgentContext): Promise<AgentResult> {
log.info(`Running ${ctx.agent.name}...`); const effort = ctx.payload.effort ?? "think";
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
// strip context from event // strip context from event
const { context: _context, ...eventWithoutContext } = ctx.payload.event; const { context: _context, ...eventWithoutContext } = ctx.payload.event;
// format: prompt + two newlines + TOON encoded event // format: prompt + two newlines + TOON encoded event
@@ -516,6 +494,7 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
defaultBranch: ctx.repo.default_branch, defaultBranch: ctx.repo.default_branch,
isPublic: !ctx.repo.private, isPublic: !ctx.repo.private,
}, },
effort,
}); });
} }
+12 -3
View File
@@ -5,7 +5,12 @@ import { agentsManifest } from "../external.ts";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { fetchWorkflowRunInfo } from "../utils/api.ts"; import { fetchWorkflowRunInfo } from "../utils/api.ts";
import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts"; import { buildPullfrogFooter, stripExistingFooter } from "../utils/buildPullfrogFooter.ts";
import { createOctokit, getGitHubInstallationToken, parseRepoContext, type OctokitWithPlugins } from "../utils/github.ts"; import {
createOctokit,
getGitHubInstallationToken,
type OctokitWithPlugins,
parseRepoContext,
} from "../utils/github.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
/** /**
@@ -82,7 +87,11 @@ function buildImplementPlanLink(
return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`; return `[Implement plan ➔](${apiUrl}/trigger/${owner}/${repo}/${issueNumber}?action=implement&comment_id=${commentId})`;
} }
async function addFooter(body: string, payload: Payload, octokit?: OctokitWithPlugins): Promise<string> { async function addFooter(
body: string,
payload: Payload,
octokit?: OctokitWithPlugins
): Promise<string> {
const bodyWithoutFooter = stripExistingFooter(body); const bodyWithoutFooter = stripExistingFooter(body);
const footer = await buildCommentFooter({ payload, octokit }); const footer = await buildCommentFooter({ payload, octokit });
return `${bodyWithoutFooter}${footer}`; return `${bodyWithoutFooter}${footer}`;
@@ -494,6 +503,6 @@ export function ReplyToReviewCommentTool(ctx: ToolContext) {
body: result.data.body, body: result.data.body,
in_reply_to_id: result.data.in_reply_to_id, in_reply_to_id: result.data.in_reply_to_id,
}; };
}), }, "reply_to_review_comment"),
}); });
} }
+86 -86
View File
@@ -93,95 +93,95 @@ export function GetReviewCommentsTool(ctx: ToolContext) {
"Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.", "Get all review comments and their replies for a specific pull request review. Returns line-by-line comments that were left on specific code locations, including any threaded replies.",
parameters: GetReviewComments, parameters: GetReviewComments,
execute: execute(async ({ pull_number, review_id }) => { execute: execute(async ({ pull_number, review_id }) => {
// fetch all review threads using graphql // fetch all review threads using graphql
const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, { const response = await ctx.octokit.graphql<GraphQLResponse>(REVIEW_THREADS_QUERY, {
owner: ctx.owner, owner: ctx.owner,
repo: ctx.name, repo: ctx.name,
pullNumber: pull_number, pullNumber: pull_number,
}); });
const pullRequest = response.repository?.pullRequest; const pullRequest = response.repository?.pullRequest;
if (!pullRequest) { if (!pullRequest) {
return { return {
review_id, review_id,
pull_number, pull_number,
comments: [], comments: [],
count: 0, count: 0,
}; };
}
const threadNodes = pullRequest.reviewThreads?.nodes;
if (!threadNodes) {
return {
review_id,
pull_number,
comments: [],
count: 0,
};
}
const allComments: {
id: number;
body: string;
path: string;
line: number | null;
side: "LEFT" | "RIGHT";
start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
user: string | null;
created_at: string;
updated_at: string;
html_url: string;
in_reply_to_id: number | null;
pull_request_review_id: number | null;
}[] = [];
// iterate through all threads (filter out nulls)
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
// filter out null comments
const threadComments = thread.comments.nodes.filter(
(c): c is GraphQLReviewComment => c !== null
);
if (threadComments.length === 0) continue;
// find the root comment (the one with replyTo == null) to determine thread ownership
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
// check if this thread belongs to the target review using the root comment
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
// include all comments from this thread (original + replies)
// side info comes from thread level, not comment level
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: thread.diffSide,
start_side: thread.startDiffSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
});
} }
}
return { const threadNodes = pullRequest.reviewThreads?.nodes;
review_id, if (!threadNodes) {
pull_number, return {
comments: allComments, review_id,
count: allComments.length, pull_number,
}; comments: [],
}), count: 0,
};
}
const allComments: {
id: number;
body: string;
path: string;
line: number | null;
side: "LEFT" | "RIGHT";
start_line: number | null;
start_side: "LEFT" | "RIGHT" | null;
user: string | null;
created_at: string;
updated_at: string;
html_url: string;
in_reply_to_id: number | null;
pull_request_review_id: number | null;
}[] = [];
// iterate through all threads (filter out nulls)
for (const thread of threadNodes) {
if (!thread?.comments?.nodes) continue;
// filter out null comments
const threadComments = thread.comments.nodes.filter(
(c): c is GraphQLReviewComment => c !== null
);
if (threadComments.length === 0) continue;
// find the root comment (the one with replyTo == null) to determine thread ownership
const rootComment = threadComments.find((c) => c.replyTo === null);
if (!rootComment) continue;
// check if this thread belongs to the target review using the root comment
const threadBelongsToReview = rootComment.pullRequestReview?.databaseId === review_id;
if (!threadBelongsToReview) continue;
// include all comments from this thread (original + replies)
// side info comes from thread level, not comment level
for (const comment of threadComments) {
allComments.push({
id: comment.databaseId,
body: comment.body,
path: comment.path,
line: comment.line,
start_line: comment.startLine,
side: thread.diffSide,
start_side: thread.startDiffSide,
user: comment.author?.login ?? null,
created_at: comment.createdAt,
updated_at: comment.updatedAt,
html_url: comment.url,
in_reply_to_id: comment.replyTo?.databaseId ?? null,
pull_request_review_id: comment.pullRequestReview?.databaseId ?? null,
});
}
}
return {
review_id,
pull_number,
comments: allComments,
count: allComments.length,
};
}),
}); });
} }
+11 -1
View File
@@ -2,6 +2,7 @@ import type { StandardSchemaV1 } from "@standard-schema/spec";
import { encode as toonEncode } from "@toon-format/toon"; import { encode as toonEncode } from "@toon-format/toon";
import type { FastMCP, Tool } from "fastmcp"; import type { FastMCP, Tool } from "fastmcp";
import type { ToolContext } from "../main.ts"; import type { ToolContext } from "../main.ts";
import { formatJsonValue, log } from "../utils/cli.ts";
export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef; export const tool = <const params>(toolDef: Tool<any, StandardSchemaV1<params>>) => toolDef;
@@ -36,13 +37,22 @@ export const handleToolError = (error: unknown): ToolResult => {
/** /**
* Helper to wrap a tool execute function with error handling. * Helper to wrap a tool execute function with error handling.
* Captures ctx in closure so tools don't need to handle try/catch. * Captures ctx in closure so tools don't need to handle try/catch.
* @param fn - the function to execute
* @param toolName - optional tool name for error logging
*/ */
export const execute = <T>(fn: (params: T) => Promise<Record<string, any> | string>) => { export const execute = <T>(
fn: (params: T) => Promise<Record<string, any> | string>,
toolName?: string
) => {
return async (params: T): Promise<ToolResult> => { return async (params: T): Promise<ToolResult> => {
try { try {
const result = await fn(params); const result = await fn(params);
return handleToolSuccess(result); return handleToolSuccess(result);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const prefix = toolName ? `[${toolName}]` : "tool";
log.error(`${prefix} error: ${errorMessage}`);
log.debug(`${prefix} params: ${formatJsonValue(params)}`);
return handleToolError(error); return handleToolError(error);
} }
}; };
+26 -21
View File
@@ -3,10 +3,8 @@ import { existsSync, readFileSync } from "node:fs";
import { extname, join, resolve } from "node:path"; import { extname, join, resolve } from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { fromHere } from "@ark/fs"; import { fromHere } from "@ark/fs";
import { flatMorph } from "@ark/util";
import arg from "arg"; import arg from "arg";
import { config } from "dotenv"; import { config } from "dotenv";
import { agents } from "./agents/index.ts";
import type { AgentResult } from "./agents/shared.ts"; import type { AgentResult } from "./agents/shared.ts";
import { type Inputs, main } from "./main.ts"; import { type Inputs, main } from "./main.ts";
import { log } from "./utils/cli.ts"; import { log } from "./utils/cli.ts";
@@ -25,26 +23,9 @@ export async function run(prompt: string): Promise<AgentResult> {
const originalCwd = process.cwd(); const originalCwd = process.cwd();
process.chdir(tempDir); process.chdir(tempDir);
// check if prompt is a pullfrog payload and extract agent const inputs: Inputs = {
// 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 = {
prompt, prompt,
...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<Inputs>;
const result = await main(inputs); const result = await main(inputs);
@@ -205,6 +186,30 @@ Examples:
if (typeof module.default === "string") { if (typeof module.default === "string") {
prompt = module.default; prompt = module.default;
} else if (Array.isArray(module.default)) {
// Array of Payloads - run each in sequence
const payloads = module.default;
log.info(`Running ${payloads.length} payloads in sequence...`);
let allSuccess = true;
for (let i = 0; i < payloads.length; i++) {
const payload = payloads[i];
const label = payload.effort
? `[${i + 1}/${payloads.length}] effort=${payload.effort}`
: `[${i + 1}/${payloads.length}]`;
log.info(`\n${"=".repeat(60)}`);
log.info(`${label}`);
log.info(`${"=".repeat(60)}\n`);
const payloadPrompt = JSON.stringify(payload, null, 2);
const result = await run(payloadPrompt);
if (!result.success) {
allSuccess = false;
log.error(`Payload ${i + 1} failed`);
}
}
process.exit(allSuccess ? 0 : 1);
} else if (typeof module.default === "object") { } else if (typeof module.default === "object") {
// Payload objects (with ~pullfrog) should be stringified // Payload objects (with ~pullfrog) should be stringified
prompt = JSON.stringify(module.default, null, 2); prompt = JSON.stringify(module.default, null, 2);