diff --git a/agents/opencode.ts b/agents/opencode.ts index e0da910..7aefcc4 100644 --- a/agents/opencode.ts +++ b/agents/opencode.ts @@ -80,6 +80,26 @@ type OpenCodeConfig = { */ const PULLFROG_OPENCODE_OUTPUT_LIMIT = 5000; +/** + * upstream opencode hardcodes `thinkingLevel: "high"` as the default for every + * gemini-3 model on the direct google SDK (`provider/transform.ts` `options()`). + * that adds 30-60s of pre-tool-call TTFT and 5-46s of post-tool jabber per turn, + * which is overkill for agentic loops where most steps are tool-routing + * decisions. we override to "medium" for the curated slugs we ship in + * `action/models.ts`; users who want max quality can still pick the `-high` + * variant explicitly. flash stays at "medium" too — low-effort flash is + * visibly worse on harder tasks and the latency savings aren't meaningful + * (flash is already fast). other gemini-3 ids that exist in models.dev but + * aren't in our curated alias map keep the upstream `"high"` default. + * + * keyed by upstream api id (matches the slugs in `action/models.ts`). the + * merge order in opencode `session/llm.ts` is `base ← model.options ← agent.options ← variant`, + * deep-merged — so an explicit `--variant high` still wins, and explicit + * model.options in a user-provided opencode config would also win. + */ +const GEMINI_3_DIRECT_THINKING_LEVEL = "medium"; +const GEMINI_3_DIRECT_API_IDS = ["gemini-3.1-pro-preview", "gemini-3-flash-preview"]; + function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): string { const config: OpenCodeConfig = { permission: { @@ -94,6 +114,20 @@ function buildSecurityConfig(ctx: AgentRunContext, model: string | undefined): s [pullfrogMcpName]: { type: "remote", url: ctx.mcpServerUrl }, }, agent: buildReviewerAgentConfig(), + provider: { + google: { + models: Object.fromEntries( + GEMINI_3_DIRECT_API_IDS.map((id) => [ + id, + { + options: { + thinkingConfig: { thinkingLevel: GEMINI_3_DIRECT_THINKING_LEVEL }, + }, + }, + ]) + ), + }, + }, }; if (model) { diff --git a/agents/postRun.ts b/agents/postRun.ts index 58d5d3e..35c64ea 100644 --- a/agents/postRun.ts +++ b/agents/postRun.ts @@ -1,5 +1,6 @@ import { readFile } from "node:fs/promises"; import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts"; +import { NON_COMMITTING_MODES } from "../modes.ts"; import type { ToolState } from "../toolState.ts"; import { log } from "../utils/cli.ts"; import { @@ -181,8 +182,21 @@ export async function collectPostRunIssues( const failure = await executeStopHook(ctx.stopScript); if (failure) issues.stopHook = failure; } + // dirty-tree gate fires only in modes that legitimately commit. Review / + // IncrementalReview / Plan complete via review submission or a Plan + // comment, not by touching files — any tree dirt is incidental (e.g. a + // tool-installed `node_modules/`) and the worktree is ephemeral, so + // nudging the agent to commit it would produce a spurious PR. see + // `NON_COMMITTING_MODES` in `action/modes.ts`. const status = getGitStatus(); - if (status) issues.dirtyTree = status; + const mode = ctx.toolState.selectedMode; + if (status) { + if (mode && NON_COMMITTING_MODES.has(mode)) { + log.info(`» dirty-tree gate suppressed: mode \`${mode}\` does not commit`); + } else { + issues.dirtyTree = status; + } + } const summaryFilePath = ctx.toolState.summaryFilePath; const summarySeed = ctx.toolState.summarySeed; if (!options.skipSummaryStale && summaryFilePath && summarySeed !== undefined) { diff --git a/modes.ts b/modes.ts index 9bde11a..d713824 100644 --- a/modes.ts +++ b/modes.ts @@ -433,3 +433,18 @@ ${PR_SUMMARY_FORMAT}`, // static export for UI display — uses opencode format as the readable default export const modes: Mode[] = computeModes("opencode"); + +/** + * modes that legitimately never modify the working tree. used by the post-run + * dirty-tree gate to suppress the "commit and push" nudge — those modes + * complete by submitting a review (`Review` / `IncrementalReview`) or by + * posting a Plan comment (`Plan`), not by touching files. any leftover in the + * tree at end-of-run is incidental tool noise (e.g. a `node_modules/` from a + * stray install attempt) on an ephemeral worktree; nudging the agent to + * commit it would produce a spurious PR. + */ +export const NON_COMMITTING_MODES: ReadonlySet = new Set([ + "Review", + "IncrementalReview", + "Plan", +]); diff --git a/prep/installNodeDependencies.ts b/prep/installNodeDependencies.ts index d315217..034bb48 100644 --- a/prep/installNodeDependencies.ts +++ b/prep/installNodeDependencies.ts @@ -135,14 +135,21 @@ export const installNodeDependencies: PrepDefinition = { } } - // get the frozen install command (or fallback to regular install) - const resolved = resolveCommand(agent, "frozen", []) || resolveCommand(agent, "install", []); + // frozen-lockfile install only. eager prep is non-mutating by contract: + // we run it before the agent starts and any artifact it leaves in the + // tree (e.g. a generated `package-lock.json`) trips the dirty-tree + // post-run gate and produces a spurious PR. `frozen` commands + // (`npm ci`, `pnpm install --frozen-lockfile`, etc.) fail cleanly + // without modifying state when there's no lockfile, which is exactly + // what we want — repos that need a non-frozen install must opt in via + // a `setup` lifecycle hook (`action/utils/lifecycle.ts`). + const resolved = resolveCommand(agent, "frozen", []); if (!resolved) { return { language: "node", packageManager, dependenciesInstalled: false, - issues: [`no install command found for ${agent}`], + issues: [`no frozen-install command available for ${agent}`], }; }