Fixes
This commit is contained in:
+17
-11
@@ -5,13 +5,19 @@ 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";
|
||||||
|
|
||||||
// effort levels for Claude Code
|
// Model selection based on effort level
|
||||||
// Claude Code automatically selects the best model based on effort
|
// Note: nothink uses Haiku for speed, think uses Sonnet for balance, max uses Opus for capability
|
||||||
const claudeEffortLevels: Record<Effort, "low" | "medium" | "high"> = {
|
const claudeEffortModels: Record<Effort, string> = {
|
||||||
nothink: "low",
|
nothink: "claude-haiku-4-5-20250929",
|
||||||
think: "medium",
|
think: "claude-sonnet-4-5-20250929",
|
||||||
max: "high",
|
max: "claude-opus-4-5-20250929",
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
|
// 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",
|
||||||
@@ -30,9 +36,9 @@ export const claude = agent({
|
|||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// get effort level - Claude Code automatically selects the best model
|
// select model based on effort level
|
||||||
const effortLevel = claudeEffortLevels[effort];
|
const model = claudeEffortModels[effort];
|
||||||
log.info(`Using effort: ${effortLevel}`);
|
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.
|
||||||
@@ -62,7 +68,7 @@ export const claude = agent({
|
|||||||
const queryOptions: Options = {
|
const queryOptions: Options = {
|
||||||
...sandboxOptions,
|
...sandboxOptions,
|
||||||
mcpServers,
|
mcpServers,
|
||||||
effort: effortLevel,
|
model,
|
||||||
pathToClaudeCodeExecutable: cliPath,
|
pathToClaudeCodeExecutable: cliPath,
|
||||||
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
env: createAgentEnv({ ANTHROPIC_API_KEY: apiKey }),
|
||||||
};
|
};
|
||||||
|
|||||||
+27
-12
@@ -1,19 +1,32 @@
|
|||||||
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 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
|
// reasoning effort configuration based on effort level
|
||||||
// uses model_reasoning_effort parameter for o1 models
|
// uses modelReasoningEffort parameter from ThreadOptions
|
||||||
const codexReasoningEffort: Record<Effort, string | undefined> = {
|
const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||||
nothink: "low",
|
nothink: "low",
|
||||||
think: undefined, // use default
|
think: undefined, // use default
|
||||||
max: "xhigh",
|
max: "high",
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
interface WriteCodexConfigParams {
|
interface WriteCodexConfigParams {
|
||||||
tempHome: string;
|
tempHome: string;
|
||||||
@@ -88,12 +101,12 @@ export const codex = agent({
|
|||||||
CODEX_HOME: codexDir, // point Codex to our config directory
|
CODEX_HOME: codexDir, // point Codex to our config directory
|
||||||
});
|
});
|
||||||
|
|
||||||
// get reasoning effort based on effort level
|
// get model and reasoning effort based on effort level
|
||||||
|
const model = codexModel[effort];
|
||||||
const modelReasoningEffort = codexReasoningEffort[effort];
|
const modelReasoningEffort = codexReasoningEffort[effort];
|
||||||
|
log.info(`Using model: ${model}`);
|
||||||
if (modelReasoningEffort) {
|
if (modelReasoningEffort) {
|
||||||
log.info(`Using model_reasoning_effort: ${modelReasoningEffort}`);
|
log.info(`Using modelReasoningEffort: ${modelReasoningEffort}`);
|
||||||
} else {
|
|
||||||
log.info(`Using default reasoning effort`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Codex
|
// Configure Codex
|
||||||
@@ -108,22 +121,24 @@ export const codex = agent({
|
|||||||
|
|
||||||
const codex = new Codex(codexOptions);
|
const codex = new Codex(codexOptions);
|
||||||
|
|
||||||
// Build thread options with optional model_reasoning_effort
|
// Build thread options with model and optional model_reasoning_effort
|
||||||
const baseThreadOptions = payload.sandbox
|
const baseThreadOptions = payload.sandbox
|
||||||
? {
|
? {
|
||||||
|
model,
|
||||||
approvalPolicy: "never" as const,
|
approvalPolicy: "never" as const,
|
||||||
sandboxMode: "read-only" as const,
|
sandboxMode: "read-only" as const,
|
||||||
networkAccessEnabled: false,
|
networkAccessEnabled: false,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
model,
|
||||||
approvalPolicy: "never" as const,
|
approvalPolicy: "never" as const,
|
||||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
||||||
sandboxMode: "danger-full-access" as const,
|
sandboxMode: "danger-full-access" as const,
|
||||||
networkAccessEnabled: true,
|
networkAccessEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const threadOptions = modelReasoningEffort
|
const threadOptions: ThreadOptions = modelReasoningEffort
|
||||||
? { ...baseThreadOptions, model_reasoning_effort: modelReasoningEffort }
|
? { ...baseThreadOptions, modelReasoningEffort }
|
||||||
: baseThreadOptions;
|
: baseThreadOptions;
|
||||||
|
|
||||||
const thread = codex.startThread(threadOptions);
|
const thread = codex.startThread(threadOptions);
|
||||||
|
|||||||
+46
-11
@@ -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, effort: _effort }) => {
|
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");
|
||||||
|
|||||||
+36
-19
@@ -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,12 +12,13 @@ import {
|
|||||||
installFromGithub,
|
installFromGithub,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
|
|
||||||
// model configuration based on effort level
|
// effort configuration: model + thinking level
|
||||||
// auto for default, flash for fast/low-cost, pro for max capability
|
// thinkingLevel is set via settings.json modelConfig.generateContentConfig.thinkingConfig
|
||||||
const geminiModels = {
|
// see: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels
|
||||||
nothink: "gemini-2.5-flash",
|
const geminiEffortConfig: Record<Effort, { model: string; thinkingLevel: string }> = {
|
||||||
think: "gemini-2.5-pro", // auto selection
|
nothink: { model: "gemini-2.5-flash", thinkingLevel: "LOW" },
|
||||||
max: "gemini-2.5-pro",
|
think: { model: "gemini-2.5-flash", thinkingLevel: "HIGH" },
|
||||||
|
max: { model: "gemini-2.5-pro", thinkingLevel: "HIGH" },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// gemini cli event types inferred from stream-json output (NDJSON format)
|
// gemini cli event types inferred from stream-json output (NDJSON format)
|
||||||
@@ -165,16 +167,16 @@ export const gemini = agent({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// get model based on effort level
|
|
||||||
const model = geminiModels[effort];
|
|
||||||
log.info(`Using model: ${model}`);
|
|
||||||
|
|
||||||
const sessionPrompt = addInstructions({ payload, repo });
|
const sessionPrompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||||
|
|
||||||
@@ -290,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");
|
||||||
@@ -343,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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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` | `claude-haiku-4-5-20250929` | Fast, efficient (Haiku) |
|
||||||
|
| `think` | `claude-sonnet-4-5-20250929` | Balanced (Sonnet) |
|
||||||
|
| `max` | `claude-opus-4-5-20250929` | Maximum capability (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 |
|
||||||
@@ -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[];
|
||||||
@@ -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[];
|
||||||
+12
-3
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export async function run(prompt: string): Promise<AgentResult> {
|
|||||||
|
|
||||||
const inputs: Inputs = {
|
const inputs: Inputs = {
|
||||||
prompt,
|
prompt,
|
||||||
effort: "nothink",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await main(inputs);
|
const result = await main(inputs);
|
||||||
@@ -187,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);
|
||||||
|
|||||||
Reference in New Issue
Block a user