feat(agents): add centralized GPT apply_patch permission guard

Extract hardcoded GPT apply_patch permission logic into a reusable module
to ensure consistent behavior across all agents. This prevents GPT models
from using the unreliable apply_patch tool while allowing other models.

- Add gpt-apply-patch-guard.ts with GPT_APPLY_PATCH_GUIDANCE and getGptApplyPatchPermission
- Update Hephaestus agent to use centralized permission logic
- Update Sisyphus-Junior agent to use centralized permission logic
- Update all GPT prompt builders to reference shared guidance constant

🤖 Generated with assistance of OhMyOpenCode
This commit is contained in:
YeonGyu-Kim
2026-04-10 10:47:27 +09:00
parent 498d021dc2
commit 2083cb0710
18 changed files with 87 additions and 41 deletions
+7
View File
@@ -0,0 +1,7 @@
import { isGptModel } from "./types"
export const GPT_APPLY_PATCH_GUIDANCE = "Use the `edit` and `write` tools for file changes. Do not use `apply_patch` on GPT models - it is unreliable here and can hang during verification."
export function getGptApplyPatchPermission(model: string): Record<string, "deny"> {
return isGptModel(model) ? { apply_patch: "deny" as const } : {}
}
+3 -2
View File
@@ -1,6 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "../types";
import { isGptModel, isGpt5_4Model, isGpt5_3CodexModel } from "../types";
import { isGpt5_4Model, isGpt5_3CodexModel } from "../types";
import type {
AvailableAgent,
AvailableTool,
@@ -8,6 +8,7 @@ import type {
AvailableCategory,
} from "../dynamic-agent-prompt-builder";
import { categorizeTools, buildAgentIdentitySection } from "../dynamic-agent-prompt-builder";
import { getGptApplyPatchPermission } from "../gpt-apply-patch-guard";
import { buildHephaestusPrompt as buildGptPrompt } from "./gpt";
import { buildHephaestusPrompt as buildGpt53CodexPrompt } from "./gpt-5-3-codex";
@@ -125,7 +126,7 @@ export function createHephaestusAgent(
permission: {
question: "allow",
call_omo_agent: "deny",
...(isGptModel(model) ? { apply_patch: "deny" as const } : {}),
...getGptApplyPatchPermission(model),
} as AgentConfig["permission"],
reasoningEffort: "medium",
};
+2 -1
View File
@@ -1,4 +1,5 @@
/** GPT-5.3 Codex optimized Hephaestus prompt */
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard";
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode } from "../types";
import type {
@@ -448,7 +449,7 @@ ${oracleSection}
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. ${GPT_APPLY_PATCH_GUIDANCE}
### After Implementation (MANDATORY - DO NOT SKIP)
+2 -1
View File
@@ -21,6 +21,7 @@
* 9. <communication> - Output format, tone guidance
*/
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard";
import type {
AvailableAgent,
AvailableTool,
@@ -252,7 +253,7 @@ ${antiPatterns}
1. **Explore**: Fire 2-5 explore/librarian agents in parallel + direct tool reads. Goal: complete understanding, not just enough context.
2. **Plan**: List files to modify, specific changes, dependencies, complexity estimate.
3. **Decide**: Trivial (<10 lines, single file) -> self. Complex (multi-file, >100 lines) -> delegate.
4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. **Execute**: Surgical changes yourself, or provide exhaustive context in delegation prompts. Match existing patterns. Minimal diff. Search the codebase for similar patterns before writing code. Default to ASCII. Add comments only for non-obvious blocks. ${GPT_APPLY_PATCH_GUIDANCE}
5. **Verify**: \`lsp_diagnostics\` on all modified files (zero errors) -> run related tests (\`foo.ts\` -> \`foo.test.ts\`) -> typecheck -> build if applicable (exit 0). Fix only issues your changes caused.
If verification fails, return to step 1 with a materially different approach. After three attempts: stop, revert to last working state, document what you tried, consult Oracle. If Oracle cannot resolve, ask the user.
+2 -1
View File
@@ -1,5 +1,6 @@
/** Generic GPT Hephaestus prompt - fallback for GPT models without a model-specific variant */
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard"
import type {
AvailableAgent,
AvailableTool,
@@ -311,7 +312,7 @@ ${oracleSection}
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. ${GPT_APPLY_PATCH_GUIDANCE}
### After Implementation (MANDATORY - DO NOT SKIP)
+7 -2
View File
@@ -18,6 +18,7 @@ import {
createAgentToolRestrictions,
type PermissionValue,
} from "../../shared/permission-compat"
import { getGptApplyPatchPermission } from "../gpt-apply-patch-guard"
import { buildDefaultSisyphusJuniorPrompt } from "./default"
import { buildGptSisyphusJuniorPrompt } from "./gpt"
@@ -103,7 +104,11 @@ export function createSisyphusJuniorAgentWithOverrides(
merged[tool] = "deny"
}
merged.call_omo_agent = "allow"
const toolsConfig = { permission: { ...merged, ...basePermission } }
const toolsConfig = { permission: { ...merged, ...basePermission } as Record<string, PermissionValue> }
const permission: Record<string, PermissionValue> = {
...toolsConfig.permission,
...getGptApplyPatchPermission(model),
}
const base: AgentConfig = {
description: override?.description ??
@@ -114,7 +119,7 @@ export function createSisyphusJuniorAgentWithOverrides(
maxTokens: 64000,
prompt,
color: override?.color ?? "#20B2AA",
...toolsConfig,
permission,
}
if (override?.top_p !== undefined) {
+2 -1
View File
@@ -8,6 +8,7 @@
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard"
export function buildGpt53CodexSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -92,7 +93,7 @@ Style:
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. ${GPT_APPLY_PATCH_GUIDANCE}
### After Implementation (MANDATORY - DO NOT SKIP)
+2 -1
View File
@@ -11,6 +11,7 @@
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri";
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard";
export function buildGpt54SisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -96,7 +97,7 @@ Style:
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. ${GPT_APPLY_PATCH_GUIDANCE}
5. Do not chain bash commands with separators - each command should be a separate tool call
### After Implementation (MANDATORY - DO NOT SKIP)
+2 -1
View File
@@ -9,6 +9,7 @@
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard"
export function buildGptSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -93,7 +94,7 @@ Style:
1. SEARCH existing codebase for similar patterns/styles
2. Match naming, indentation, import styles, error handling conventions
3. Default to ASCII. Add comments only for non-obvious blocks
4. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
4. ${GPT_APPLY_PATCH_GUIDANCE}
### After Implementation (MANDATORY - DO NOT SKIP)
+3 -2
View File
@@ -11,6 +11,7 @@ import {
} from "./sisyphus/gemini";
import { buildGpt54SisyphusPrompt } from "./sisyphus/gpt-5-4";
import { buildTaskManagementSection } from "./sisyphus/default";
import { getGptApplyPatchPermission } from "./gpt-apply-patch-guard";
const MODE: AgentMode = "primary";
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
@@ -499,7 +500,7 @@ export function createSisyphusAgent(
permission: {
question: "allow",
call_omo_agent: "deny",
apply_patch: "deny",
...getGptApplyPatchPermission(model),
} as AgentConfig["permission"],
reasoningEffort: "medium",
};
@@ -539,7 +540,7 @@ export function createSisyphusAgent(
const permission = {
question: "allow",
call_omo_agent: "deny",
...(isGptModel(model) ? { apply_patch: "deny" as const } : {}),
...getGptApplyPatchPermission(model),
} as AgentConfig["permission"];
const base = {
description:
+2 -1
View File
@@ -21,6 +21,7 @@
* 8. <style> - Tone (prose) + output contract + progress updates
*/
import { GPT_APPLY_PATCH_GUIDANCE } from "../gpt-apply-patch-guard";
import type {
AvailableAgent,
AvailableTool,
@@ -310,7 +311,7 @@ Every implementation task follows this cycle. No exceptions.
Skills: if ANY available skill's domain overlaps with the task, load it NOW via \`skill\` tool and include it in \`load_skills\`. When the connection is even remotely plausible, load the skill - the cost of loading an irrelevant skill is near zero, the cost of missing a relevant one is high.
4. EXECUTE_OR_SUPERVISE -
If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. Use the \`edit\` and \`write\` tools for file changes. Do not use \`apply_patch\` on GPT models - it is unreliable here and can hang during verification.
If self: surgical changes, match existing patterns, minimal diff. Never suppress type errors. Never commit unless asked. Bugfix rule: fix minimally, never refactor while fixing. ${GPT_APPLY_PATCH_GUIDANCE}
If delegated: exhaustive 6-section prompt per \`<delegation>\` protocol. Session continuity for follow-ups.
5. VERIFY -
@@ -1,16 +1,10 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../../../shared"
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
import type { AvailableModelsInfo } from "./model-resolution-types"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
function getOpenCodeConfigDir(): string {
function getUserConfigDir(): string {
const xdgConfig = process.env.XDG_CONFIG_HOME
if (xdgConfig) return join(xdgConfig, "opencode")
return join(homedir(), ".config", "opencode")
@@ -24,7 +18,7 @@ function getOpenCodeConfigDir(): string {
* warnings in doctor.
*/
function loadCustomProviderNames(): string[] {
const configDir = getOpenCodeConfigDir()
const configDir = getUserConfigDir()
const candidatePaths = [
join(configDir, "opencode.json"),
join(configDir, "opencode.jsonc"),
+7 -7
View File
@@ -69,10 +69,10 @@ export function createManagers(args: {
pluginConfig.background_task,
{
tmuxConfig,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[index] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
onSubagentSessionCreated: async (event: SubagentSessionCreatedEvent) => {
log("[create-managers] onSubagentSessionCreated callback received", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
})
@@ -88,7 +88,7 @@ export function createManagers(args: {
})
if (pluginConfig.openclaw) {
await openclawRuntimeDispatch.dispatchOpenClawEvent({
await openclawRuntimeDispatch.dispatchOpenClawEvent({
config: pluginConfig.openclaw,
rawEvent: "session.created",
context: {
@@ -99,11 +99,11 @@ export function createManagers(args: {
})
}
log("[index] onSubagentSessionCreated callback completed")
log("[create-managers] onSubagentSessionCreated callback completed")
},
onShutdown: async () => {
await tmuxSessionManager.cleanup().catch((error) => {
log("[index] tmux cleanup error during shutdown:", error)
log("[create-managers] tmux cleanup error during shutdown:", error)
})
},
enableParentSessionNotifications: backgroundNotificationHookEnabled,
@@ -6,13 +6,17 @@ import type { PackageJson } from "../types"
import { INSTALLED_PACKAGE_JSON_CANDIDATES } from "../constants"
import { findPackageJsonUp } from "./package-json-locator"
function readPackageVersion(packageJsonPath: string): string | null {
const content = fs.readFileSync(packageJsonPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
return pkg.version ?? null
}
export function getCachedVersion(): string | null {
for (const candidate of INSTALLED_PACKAGE_JSON_CANDIDATES) {
try {
if (fs.existsSync(candidate)) {
const content = fs.readFileSync(candidate, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
return readPackageVersion(candidate)
}
} catch {
// ignore; try next candidate
@@ -23,9 +27,7 @@ export function getCachedVersion(): string | null {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pkgPath = findPackageJsonUp(currentDir)
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
return readPackageVersion(pkgPath)
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from current directory:", err)
@@ -35,9 +37,7 @@ export function getCachedVersion(): string | null {
const execDir = path.dirname(fs.realpathSync(process.execPath))
const pkgPath = findPackageJsonUp(execDir)
if (pkgPath) {
const content = fs.readFileSync(pkgPath, "utf-8")
const pkg = JSON.parse(content) as PackageJson
if (pkg.version) return pkg.version
return readPackageVersion(pkgPath)
}
} catch (err) {
log("[auto-update-checker] Failed to resolve version from execPath:", err)
+4 -1
View File
@@ -94,7 +94,10 @@ export async function pollDiscordReplies(
headers: { Authorization: `Bot ${replyListener.discordBotToken}` },
},
)
} catch {
} catch (error) {
logReplyListenerMessage(
`WARN: Failed to acknowledge Discord message ${message.id}: ${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
state.errors += 1
+1
View File
@@ -15,6 +15,7 @@ import {
updateSessionAgent,
} from "./features/claude-code-session-state"
describe("createPluginInterface - command.execute.before", () => {
let testDir = ""
+2 -1
View File
@@ -6,6 +6,7 @@ import { getAgentConfigKey } from "../shared/agent-display-names"
import { getSessionModel, setSessionModel } from "../shared/session-model-state"
import { getMainSessionID, setSessionAgent, subagentSessions } from "../features/claude-code-session-state"
import { applyUltraworkModelOverrideOnMessage } from "./ultrawork-model-override"
import { NATIVE_LOOP_TRIGGERED_FLAG } from "./command-execute-before"
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
import type { CreatedHooks } from "../create-hooks"
@@ -223,7 +224,7 @@ export function createChatMessageHandler(args: {
.catch(() => {})
}
if (hooks.ralphLoop) {
if (hooks.ralphLoop && output.message[NATIVE_LOOP_TRIGGERED_FLAG] !== true) {
const parts = output.parts
const promptText =
parts
+27 -1
View File
@@ -1,4 +1,5 @@
import type { CreatedHooks } from "../create-hooks"
import { parseRalphLoopArguments } from "../hooks/ralph-loop/command-arguments"
type CommandExecuteBeforeInput = {
command: string
@@ -8,8 +9,11 @@ type CommandExecuteBeforeInput = {
type CommandExecuteBeforeOutput = {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
message?: Record<string, unknown>
}
const NATIVE_LOOP_TRIGGERED_FLAG = "__omoNativeLoopTriggered"
function hasPartsOutput(value: unknown): value is CommandExecuteBeforeOutput {
if (typeof value !== "object" || value === null) return false
const record = value as Record<string, unknown>
@@ -28,12 +32,34 @@ export function createCommandExecuteBeforeHandler(args: {
return async (input, output): Promise<void> => {
await hooks.autoSlashCommand?.["command.execute.before"]?.(input, output)
const normalizedCommand = input.command.toLowerCase()
const sessionID = input.sessionID
if (hooks.ralphLoop && sessionID) {
if (normalizedCommand === "ralph-loop" || normalizedCommand === "ulw-loop") {
const parsedArguments = parseRalphLoopArguments(input.arguments || "")
hooks.ralphLoop.startLoop(sessionID, parsedArguments.prompt, {
ultrawork: normalizedCommand === "ulw-loop",
maxIterations: parsedArguments.maxIterations,
completionPromise: parsedArguments.completionPromise,
strategy: parsedArguments.strategy,
})
output.message ??= {}
output.message[NATIVE_LOOP_TRIGGERED_FLAG] = true
} else if (normalizedCommand === "cancel-ralph") {
hooks.ralphLoop.cancelLoop(sessionID)
output.message ??= {}
output.message[NATIVE_LOOP_TRIGGERED_FLAG] = true
}
}
if (
hooks.startWork
&& input.command.toLowerCase() === "start-work"
&& normalizedCommand === "start-work"
&& hasPartsOutput(output)
) {
await hooks.startWork["command.execute.before"]?.(input, output)
}
}
}
export { NATIVE_LOOP_TRIGGERED_FLAG }