diff --git a/apps/web/src/app/docs/page.tsx b/apps/web/src/app/docs/page.tsx index e73e838..17ff0b1 100644 --- a/apps/web/src/app/docs/page.tsx +++ b/apps/web/src/app/docs/page.tsx @@ -465,8 +465,8 @@ claude: model: sonnet codex: model: gpt-5-codex - reasoningEffort: medium - webSearch: true + model_reasoning_effort: medium + web_search: true --- You are a strict code reviewer. Check for: @@ -477,9 +477,19 @@ You are a strict code reviewer. Check for: Provider-specific blocks (claude:,{" "} - codex:, etc.) let you tune model, - reasoning effort, and other settings per tool without duplicating - the prompt. + codex:, etc.) let you tune model and + other settings per tool without duplicating the prompt. Codex + config is passed through as a thin layer, so native snake_case + keys like approval_policy,{" "} + approvals_reviewer, and{" "} + web_search work directly. Existing + camelCase aliases are still normalized for backward + compatibility. Spawn-time controls like{" "} + fork_context and{" "} + fork_turns are not part of current + Codex static per-role schemas, though, so current Codex itself + will warn or ignore them if you place them under{" "} + codex: today. diff --git a/packages/cli/README.md b/packages/cli/README.md index 7f1fda8..7d3ec4a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -202,13 +202,24 @@ claude: model: sonnet codex: model: gpt-5-codex - reasoningEffort: medium - webSearch: true + model_reasoning_effort: medium + web_search: true --- You are a strict reviewer... ``` +For Codex, provider-native config keys are passed through to the generated +`.codex/agents/.toml` as a thin layer. Prefer Codex's native snake_case +keys such as `approval_policy`, `approvals_reviewer`, or `web_search`. Existing +camelCase aliases like `reasoningEffort` are still normalized for +backward-compatibility. + +Current `codex-cli 0.121.0` exposes spawn-time controls such as `fork_context` and +`fork_turns` only on the `spawn_agent` tool path, not in static role/config +files, so Codex itself will warn or ignore them if you place them under +`codex:` in canonical frontmatter today. + ## Command schema Canonical commands are markdown files. Frontmatter is optional. When present, diff --git a/packages/cli/package.json b/packages/cli/package.json index 68cae7a..2504366 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "agentloom", - "version": "0.1.12", + "version": "0.1.13", "description": "Unified agent and MCP sync CLI for multi-provider AI tooling", "type": "module", "bin": { diff --git a/packages/cli/src/core/codex.ts b/packages/cli/src/core/codex.ts new file mode 100644 index 0000000..131c151 --- /dev/null +++ b/packages/cli/src/core/codex.ts @@ -0,0 +1,101 @@ +import { isObject } from "./fs.js"; + +const MANAGED_INSTRUCTION_KEYS = new Set([ + "developerInstructions", + "developer_instructions", + "modelInstructionsFile", + "model_instructions_file", +]); + +const LEGACY_CODEX_ALIASES: Record = { + approvalPolicy: "approval_policy", + reasoningEffort: "model_reasoning_effort", + reasoningSummary: "model_reasoning_summary", + sandboxMode: "sandbox_mode", + verbosity: "model_verbosity", + webSearch: "web_search", +}; + +export function normalizeCodexConfigForToml( + providerConfig: Record, +): Record { + const normalized = normalizeCodexValueForToml(providerConfig); + if (!isObject(normalized)) { + return {}; + } + + for (const key of MANAGED_INSTRUCTION_KEYS) { + delete normalized[key]; + } + + return normalized; +} + +export function cloneCodexProviderConfig( + providerConfig: Record, +): Record { + const cloned = cloneCodexValue(providerConfig); + if (!isObject(cloned)) { + return {}; + } + + return cloned; +} + +export function stripManagedCodexInstructionKeys( + providerConfig: Record, +): Record { + const cleaned = cloneCodexProviderConfig(providerConfig); + + for (const key of MANAGED_INSTRUCTION_KEYS) { + delete cleaned[key]; + } + + return cleaned; +} + +function normalizeCodexValueForToml(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => normalizeCodexValueForToml(entry)); + } + + if (!isObject(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + toSnakeCase(key), + normalizeCodexValueForToml(entry), + ]), + ); +} + +function cloneCodexValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => cloneCodexValue(entry)); + } + + if (!isObject(value)) { + return value; + } + + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, cloneCodexValue(entry)]), + ); +} + +function toSnakeCase(key: string): string { + if (Object.prototype.hasOwnProperty.call(LEGACY_CODEX_ALIASES, key)) { + return LEGACY_CODEX_ALIASES[key]; + } + + if (!/[A-Z]/.test(key)) { + return key; + } + + return key + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase(); +} diff --git a/packages/cli/src/core/migration.ts b/packages/cli/src/core/migration.ts index a2e3b81..7b7c2bb 100644 --- a/packages/cli/src/core/migration.ts +++ b/packages/cli/src/core/migration.ts @@ -6,6 +6,10 @@ import TOML from "@iarna/toml"; import matter from "gray-matter"; import YAML from "yaml"; import { buildAgentMarkdown, parseAgentsDir } from "./agents.js"; +import { + cloneCodexProviderConfig, + stripManagedCodexInstructionKeys, +} from "./codex.js"; import { normalizeCommandArgumentsForCanonical, parseCommandsDir, @@ -592,35 +596,9 @@ function readCodexProviderAgents(paths: ScopePaths): ProviderAgentRecord[] { ? roleEntry.description.trim() : roleName; - const providerConfig: Record = {}; - if (typeof roleToml.model === "string") { - providerConfig.model = roleToml.model; - } - if (typeof roleToml.model_reasoning_effort === "string") { - providerConfig.reasoningEffort = roleToml.model_reasoning_effort; - } - if (typeof roleToml.model_reasoning_summary === "string") { - providerConfig.reasoningSummary = roleToml.model_reasoning_summary; - } - if (typeof roleToml.model_verbosity === "string") { - providerConfig.verbosity = roleToml.model_verbosity; - } - if (typeof roleToml.approval_policy === "string") { - providerConfig.approvalPolicy = roleToml.approval_policy; - } - if (typeof roleToml.sandbox_mode === "string") { - providerConfig.sandboxMode = roleToml.sandbox_mode; - } - if (typeof roleToml.web_search === "boolean") { - providerConfig.webSearch = roleToml.web_search; - } - if ( - typeof providerConfig.webSearch !== "boolean" && - isObject(roleToml.tools) && - typeof roleToml.tools.web_search === "boolean" - ) { - providerConfig.webSearch = roleToml.tools.web_search; - } + const providerConfig = stripManagedCodexInstructionKeys( + cloneCodexProviderConfig(roleToml), + ); records.push({ provider: "codex", diff --git a/packages/cli/src/sync/index.ts b/packages/cli/src/sync/index.ts index f5f35e2..1be4319 100644 --- a/packages/cli/src/sync/index.ts +++ b/packages/cli/src/sync/index.ts @@ -21,6 +21,7 @@ import { parseCommandsDir, renderCommandForProvider, } from "../core/commands.js"; +import { normalizeCodexConfigForToml } from "../core/codex.js"; import { parseRulesDir, renderRuleForCursor, @@ -1279,6 +1280,13 @@ function resolveCodexDeveloperInstructions( return providerConfig.developerInstructions.trim(); } + if ( + typeof providerConfig.developer_instructions === "string" && + providerConfig.developer_instructions.trim() !== "" + ) { + return providerConfig.developer_instructions.trim(); + } + return agentBody.trimStart().trimEnd(); } @@ -1286,38 +1294,8 @@ function buildCodexRoleToml( developerInstructions: string, providerConfig: Record, ): Record { - const roleToml: Record = { - developer_instructions: developerInstructions, - }; - - if (typeof providerConfig.model === "string") { - roleToml.model = providerConfig.model; - } - - if (typeof providerConfig.reasoningEffort === "string") { - roleToml.model_reasoning_effort = providerConfig.reasoningEffort; - } - - if (typeof providerConfig.reasoningSummary === "string") { - roleToml.model_reasoning_summary = providerConfig.reasoningSummary; - } - - if (typeof providerConfig.verbosity === "string") { - roleToml.model_verbosity = providerConfig.verbosity; - } - - if (typeof providerConfig.approvalPolicy === "string") { - roleToml.approval_policy = providerConfig.approvalPolicy; - } - - if (typeof providerConfig.sandboxMode === "string") { - roleToml.sandbox_mode = providerConfig.sandboxMode; - } - - if (typeof providerConfig.webSearch === "boolean") { - roleToml.web_search = providerConfig.webSearch; - } - + const roleToml = normalizeCodexConfigForToml(providerConfig); + roleToml.developer_instructions = developerInstructions; return roleToml; } diff --git a/packages/cli/tests/unit/migration.test.ts b/packages/cli/tests/unit/migration.test.ts index 44d19f6..cefa9b4 100644 --- a/packages/cli/tests/unit/migration.test.ts +++ b/packages/cli/tests/unit/migration.test.ts @@ -1454,7 +1454,7 @@ Review changed files and summarize findings. ); writeTextAtomic( path.join(paths.workspaceRoot, ".codex", "agents", "researcher.toml"), - `model = "gpt-5.4"\ndeveloper_instructions = "Use concise bullets."\nmodel_reasoning_effort = "low"\nmodel_reasoning_summary = "auto"\nmodel_verbosity = "high"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\nweb_search = true\n`, + `model = "gpt-5.4"\ndeveloper_instructions = "Use concise bullets."\nmodel_reasoning_effort = "low"\nmodel_reasoning_summary = "auto"\nmodel_verbosity = "high"\napproval_policy = "never"\nsandbox_mode = "workspace-write"\nweb_search = true\napprovals_reviewer = "guardian_subagent"\n`, ); const summary = await migrateProviderStateToCanonical({ @@ -1472,12 +1472,13 @@ Review changed files and summarize findings. "utf8", ); expect(canonical).toContain("model: gpt-5.4"); - expect(canonical).toContain("reasoningEffort: low"); - expect(canonical).toContain("reasoningSummary: auto"); - expect(canonical).toContain("verbosity: high"); - expect(canonical).toContain("approvalPolicy: never"); - expect(canonical).toContain("sandboxMode: workspace-write"); - expect(canonical).toContain("webSearch: true"); + expect(canonical).toContain("model_reasoning_effort: low"); + expect(canonical).toContain("model_reasoning_summary: auto"); + expect(canonical).toContain("model_verbosity: high"); + expect(canonical).toContain("approval_policy: never"); + expect(canonical).toContain("sandbox_mode: workspace-write"); + expect(canonical).toContain("web_search: true"); + expect(canonical).toContain("approvals_reviewer: guardian_subagent"); expect(canonical).toContain("\n\nUse concise bullets.\n"); }); diff --git a/packages/cli/tests/unit/sync-codex.test.ts b/packages/cli/tests/unit/sync-codex.test.ts index ea00c26..bc098de 100644 --- a/packages/cli/tests/unit/sync-codex.test.ts +++ b/packages/cli/tests/unit/sync-codex.test.ts @@ -117,6 +117,86 @@ describe("codex sync", () => { ); }); + it("passes through arbitrary codex role-file keys without a hardcoded allowlist", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-")); + tempDirs.push(root); + + const agentsDir = path.join(root, ".agents", "agents"); + ensureDir(agentsDir); + + writeTextAtomic( + path.join(agentsDir, "researcher.md"), + `---\nname: researcher\ndescription: Research specialist\ncodex:\n model: gpt-5.4-mini\n approvals_reviewer: guardian_subagent\n approval_policy:\n granular:\n request_permissions: true\n sandbox_approval: false\n---\n\nUse structured bullet points.\n`, + ); + + const paths = buildScopePaths(root, "local"); + + await syncFromCanonical({ + paths, + providers: ["codex"], + yes: true, + nonInteractive: true, + }); + + const roleTomlPath = path.join(root, ".codex", "agents", "researcher.toml"); + const roleToml = TOML.parse(fs.readFileSync(roleTomlPath, "utf8")) as { + model?: string; + developer_instructions?: string; + approvals_reviewer?: string; + approval_policy?: { + granular?: { + request_permissions?: boolean; + sandbox_approval?: boolean; + }; + }; + }; + + expect(roleToml.model).toBe("gpt-5.4-mini"); + expect(roleToml.developer_instructions).toBe( + "Use structured bullet points.", + ); + expect(roleToml.approvals_reviewer).toBe("guardian_subagent"); + expect(roleToml.approval_policy?.granular).toEqual({ + request_permissions: true, + sandbox_approval: false, + }); + }); + + it("does not hardcode a denylist for codex keys like fork_context or fork_turns", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-")); + tempDirs.push(root); + + const agentsDir = path.join(root, ".agents", "agents"); + ensureDir(agentsDir); + + writeTextAtomic( + path.join(agentsDir, "researcher.md"), + `---\nname: researcher\ndescription: Research specialist\ncodex:\n model: gpt-5.4-mini\n fork_context: false\n fork_turns: none\n---\n\nUse structured bullet points.\n`, + ); + + const paths = buildScopePaths(root, "local"); + + await syncFromCanonical({ + paths, + providers: ["codex"], + yes: true, + nonInteractive: true, + }); + + const roleTomlPath = path.join(root, ".codex", "agents", "researcher.toml"); + const roleToml = TOML.parse(fs.readFileSync(roleTomlPath, "utf8")) as { + fork_context?: boolean; + fork_turns?: string; + developer_instructions?: string; + }; + + expect(roleToml.fork_context).toBe(false); + expect(roleToml.fork_turns).toBe("none"); + expect(roleToml.developer_instructions).toBe( + "Use structured bullet points.", + ); + }); + it("removes codex role config when codex provider is disabled", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "agentloom-sync-")); tempDirs.push(root);