Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions apps/web/src/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -477,9 +477,19 @@ You are a strict code reviewer. Check for:

<Prose>
Provider-specific blocks (<InlineCode>claude:</InlineCode>,{" "}
<InlineCode>codex:</InlineCode>, etc.) let you tune model,
reasoning effort, and other settings per tool without duplicating
the prompt.
<InlineCode>codex:</InlineCode>, 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 <InlineCode>approval_policy</InlineCode>,{" "}
<InlineCode>approvals_reviewer</InlineCode>, and{" "}
<InlineCode>web_search</InlineCode> work directly. Existing
camelCase aliases are still normalized for backward
compatibility. Spawn-time controls like{" "}
<InlineCode>fork_context</InlineCode> and{" "}
<InlineCode>fork_turns</InlineCode> 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{" "}
<InlineCode>codex:</InlineCode> today.
</Prose>
</section>

Expand Down
15 changes: 13 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<role>.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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/core/codex.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string, unknown>,
): Record<string, unknown> {
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<string, unknown>,
): Record<string, unknown> {
const cloned = cloneCodexValue(providerConfig);
if (!isObject(cloned)) {
return {};
}

return cloned;
}

export function stripManagedCodexInstructionKeys(
providerConfig: Record<string, unknown>,
): Record<string, unknown> {
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();
}
36 changes: 7 additions & 29 deletions packages/cli/src/core/migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -592,35 +596,9 @@ function readCodexProviderAgents(paths: ScopePaths): ProviderAgentRecord[] {
? roleEntry.description.trim()
: roleName;

const providerConfig: Record<string, unknown> = {};
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",
Expand Down
42 changes: 10 additions & 32 deletions packages/cli/src/sync/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
parseCommandsDir,
renderCommandForProvider,
} from "../core/commands.js";
import { normalizeCodexConfigForToml } from "../core/codex.js";
import {
parseRulesDir,
renderRuleForCursor,
Expand Down Expand Up @@ -1279,45 +1280,22 @@ 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();
}

function buildCodexRoleToml(
developerInstructions: string,
providerConfig: Record<string, unknown>,
): Record<string, unknown> {
const roleToml: Record<string, unknown> = {
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;
}

Expand Down
15 changes: 8 additions & 7 deletions packages/cli/tests/unit/migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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");
});

Expand Down
80 changes: 80 additions & 0 deletions packages/cli/tests/unit/sync-codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading