From 64294638a69e25ca0c7a4e2102e2349973161f71 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:31:04 +0900 Subject: [PATCH 01/61] chore(release): open dev at 2.76.0 before releasing 2.75.0 (#6351) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 23e7615e415..42f4b24ce4c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -2645,7 +2645,7 @@ dependencies = [ [[package]] name = "opencodex-desktop" -version = "2.75.0" +version = "2.76.0" dependencies = [ "base64 0.22.1", "dbus", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6aa1932c8f5..c788432f9f3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencodex-desktop" -version = "2.75.0" +version = "2.76.0" description = "OpenCodex desktop shell" authors = ["OpenCodex contributors"] license = "MIT" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 256d52efe12..688678b4f69 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCodex", - "version": "2.75.0", + "version": "2.76.0", "identifier": "com.opencodex.desktop", "build": { "frontendDist": "../ui", diff --git a/package.json b/package.json index 138fd7eb09f..b2a3048d0fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.75.0", + "version": "2.76.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 0328373fb88fe0d019b29ae278e153d7fed4bcc7 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 16:29:18 +0900 Subject: [PATCH 02/61] perf(responses): reuse measured entry strings for the state snapshot (#6360) Carries the snapshot half of #4732: keep the per-entry strings measured by selectSnapshotEntries and join them, so a snapshot write serializes entries once instead of twice. Output is byte-identical; a pinned-bytes regression test covers it. Co-authored-by: WU, CHI-LUNG --- scripts/test-layout/layout.json | 1 + src/responses/state.ts | 5 +- src/responses/state/snapshot-select.ts | 31 ++++++--- tests/fixtures/test-layout-expected.json | 1 + .../responses-state-snapshot-select.test.ts | 66 +++++++++++++++++++ 5 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 tests/responses/responses-state-snapshot-select.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index b4d27b9425e..d910dc0ae31 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1502,6 +1502,7 @@ "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", + "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", diff --git a/src/responses/state.ts b/src/responses/state.ts index 21241e9faef..892ba556e09 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -19,7 +19,7 @@ import { writeResponseSpillDurably, } from "./spill-store"; import { collectReferencedSpillFileNames, snapshotReferencedSpillFileNames } from "./state/spill-inspect"; -import { selectSnapshotEntries } from "./state/snapshot-select"; +import { selectSnapshotEntries, snapshotPayload } from "./state/snapshot-select"; import { clientCarriedPrefixLength, providerIssuedIdentity } from "./state/replay-fingerprint"; export type { ResponseStateTempRecoveryResult, ResponseStateTempRecoveryOptions } from "./state/temp-recovery"; export type { ResponseSpillDirInspection } from "./spill-store"; @@ -705,8 +705,7 @@ async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise try { for (let attempt = 0; attempt < attemptLimit; attempt += 1) { const revision = stateRevision; - const entries = selectSnapshotEntries(states, snapshotTotalBytes(), SNAPSHOT_ENTRY_MAX_BYTES); - const payload = JSON.stringify({ version: 2, states: entries }); + const payload = snapshotPayload(selectSnapshotEntries(states, snapshotTotalBytes(), SNAPSHOT_ENTRY_MAX_BYTES)); const payloadBytes = Buffer.byteLength(payload, "utf8"); const payloadDigest = Bun.hash(payload).toString(36); // A mutation does not always change what gets persisted: entries past the diff --git a/src/responses/state/snapshot-select.ts b/src/responses/state/snapshot-select.ts index 51bcc5a2c84..73f503ec3af 100644 --- a/src/responses/state/snapshot-select.ts +++ b/src/responses/state/snapshot-select.ts @@ -1,30 +1,34 @@ import type { StoredResponseState } from "../state"; /** - * Pick the snapshot entries that fit the byte budgets, in `states` order. + * Pick the snapshot entries that fit the byte budgets, in `states` order, and + * return them already serialized. * * Bounded stubs and tombstones are selected before residents: they are the * only durable references a spill file has, and demotion is oldest-first, so a * single newest-first pass would let resident payloads consume the whole * budget ahead of them. Residents then fill what remains, newest-first so the * most recent chains survive both legacy snapshot caps. + * + * Each entry has to be stringified once anyway to measure it, so the kept + * strings are returned instead of the entries: serializing the whole selection + * again would walk up to the full snapshot budget a second time per write. */ export function selectSnapshotEntries( states: ReadonlyMap, totalMaxBytes: number, residentEntryMaxBytes: number, -): Array<[string, unknown]> { +): string[] { const ordered = [...states].reverse(); - const persisted = new Map(); + const persisted = new Map(); let total = 0; // UTF-8 bytes, not UTF-16 code units: multibyte items otherwise slip past // both snapshot caps at up to 2x the intended size. - const sizeOf = (entry: [string, unknown]): number => Buffer.byteLength(JSON.stringify(entry), "utf8"); for (const [id, state] of ordered) { if (state.kind === "resident") continue; const { sizeBytes: _sizeBytes, ...smallState } = state; - const entry: [string, unknown] = [id, smallState]; - const size = sizeOf(entry); + const entry = JSON.stringify([id, smallState]); + const size = Buffer.byteLength(entry, "utf8"); if (total + size > totalMaxBytes) continue; total += size; persisted.set(id, entry); @@ -32,18 +36,27 @@ export function selectSnapshotEntries( for (const [id, state] of ordered) { if (state.kind !== "resident") continue; const { sizeBytes: _sizeBytes, kind: _kind, ...resident } = state; - const entry: [string, unknown] = [id, resident]; - const size = sizeOf(entry); + const entry = JSON.stringify([id, resident]); + const size = Buffer.byteLength(entry, "utf8"); if (size > residentEntryMaxBytes) continue; if (total + size > totalMaxBytes) break; total += size; persisted.set(id, entry); } // Emit in map order so reload and count eviction keep the same relative order as `states`. - const entries: Array<[string, unknown]> = []; + const entries: string[] = []; for (const [id] of states) { const kept = persisted.get(id); if (kept) entries.push(kept); } return entries; } + +/** + * The snapshot file body for serialized entries. Byte-identical to + * `JSON.stringify({ version: 2, states: entries.map(e => JSON.parse(e)) })`: compact + * JSON.stringify renders an array element exactly as it renders that value alone. + */ +export function snapshotPayload(serializedEntries: readonly string[]): string { + return '{"version":2,"states":[' + serializedEntries.join(",") + "]}"; +} diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 83170e64b6b..c66dbf5ece8 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1515,6 +1515,7 @@ "responses-spill-inspection.test.ts": "responses", "responses-spill-orphan-sweep.test.ts": "responses", "responses-spill-shutdown-clock.test.ts": "responses", + "responses-state-snapshot-select.test.ts": "responses", "responses-state-snapshot-stubs.test.ts": "responses", "responses-state-write-amplification.test.ts": "responses", "responses-state.test.ts": "responses", diff --git a/tests/responses/responses-state-snapshot-select.test.ts b/tests/responses/responses-state-snapshot-select.test.ts new file mode 100644 index 00000000000..0cd6ca86e85 --- /dev/null +++ b/tests/responses/responses-state-snapshot-select.test.ts @@ -0,0 +1,66 @@ +/** + * The snapshot writer reuses the per-entry strings it already produced to measure each + * entry, instead of serializing the whole selection a second time. That is only safe if + * the file bytes stay exactly what \`JSON.stringify({ version: 2, states })\` wrote before: + * the unchanged-payload digest skip and every reader depend on them. The expected + * strings below were produced by the previous implementation. + */ +import { describe, expect, test } from "bun:test"; +import type { StoredResponseState } from "../../src/responses/state"; +import { selectSnapshotEntries, snapshotPayload } from "../../src/responses/state/snapshot-select"; + +const RESIDENT_ENTRY_MAX_BYTES = 200; + +function fixtureStates(): Map { + const rows: Array<[string, unknown]> = [ + ["resp_old_resident", { + kind: "resident", + createdAt: 1, + items: [{ type: "message", content: 'quote " backslash \\ newline \n tab \t' }], + providerOutputStart: 1, + sizeBytes: 999, + }], + ["resp_spill", { kind: "spill", createdAt: 2, clientThreadId: "thr_1", spill: { file: "a.json", bytes: 10 }, sizeBytes: 5000 }], + ["resp_failed", { kind: "spill-failed", createdAt: 3, sizeBytes: 7 }], + ["resp_multibyte", { + kind: "resident", + createdAt: 4, + items: ["한글 ✓ 🚀", { nested: { a: undefined, b: null, c: [1.5, -0, 1e21] } }], + sizeBytes: 1, + }], + ["resp_oversized", { kind: "resident", createdAt: 5, items: ["x".repeat(300)], sizeBytes: 1 }], + ["resp_newest", { kind: "resident", createdAt: 6, items: ["newest"], sizeBytes: 1 }], + ]; + return new Map(rows as Array<[string, StoredResponseState]>); +} + +function payload(states: Map, totalMaxBytes: number): string { + return snapshotPayload(selectSnapshotEntries(states, totalMaxBytes, RESIDENT_ENTRY_MAX_BYTES)); +} + +describe("responses-state snapshot payload bytes", () => { + test("matches the previous whole-tree serialization when everything fits", () => { + expect(payload(fixtureStates(), 10_000)).toBe( + '{"version":2,"states":[["resp_old_resident",{"createdAt":1,"items":[{"type":"message","content":"quote \\" backslash \\\\ newline \\n tab \\t"}],"providerOutputStart":1}],["resp_spill",{"kind":"spill","createdAt":2,"clientThreadId":"thr_1","spill":{"file":"a.json","bytes":10}}],["resp_failed",{"kind":"spill-failed","createdAt":3}],["resp_multibyte",{"createdAt":4,"items":["한글 ✓ 🚀",{"nested":{"b":null,"c":[1.5,0,1e+21]}}]}],["resp_newest",{"createdAt":6,"items":["newest"]}]]}', + ); + }); + + test("matches the previous serialization when the byte budget cuts residents off", () => { + // Stubs take 160 bytes, the newest resident 50; the multibyte resident's 102 UTF-8 + // bytes then overflow 300, which ends the newest-first resident pass. + expect(payload(fixtureStates(), 300)).toBe( + '{"version":2,"states":[["resp_spill",{"kind":"spill","createdAt":2,"clientThreadId":"thr_1","spill":{"file":"a.json","bytes":10}}],["resp_failed",{"kind":"spill-failed","createdAt":3}],["resp_newest",{"createdAt":6,"items":["newest"]}]]}', + ); + }); + + test("an empty store still writes the version envelope", () => { + expect(payload(new Map(), 10_000)).toBe('{"version":2,"states":[]}'); + }); + + test("joining the kept strings equals serializing the parsed entries as one tree", () => { + for (const budget of [0, 120, 300, 10_000]) { + const kept = selectSnapshotEntries(fixtureStates(), budget, RESIDENT_ENTRY_MAX_BYTES); + expect(snapshotPayload(kept)).toBe(JSON.stringify({ version: 2, states: kept.map(entry => JSON.parse(entry)) })); + } + }); +}); From a6114b62ed1b65dede802359ccd373d913979b24 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 17:16:23 +0900 Subject: [PATCH 03/61] feat(codex): pick the model for each LazyCodex agent role (carry #6262) (#6366) Carries #6262 by @LilMGenius: a role model picker for omo (Codex / LazyCodex) agent roles. It adds a Codex-tab section, GET/PUT /api/codex-agent-roles and ocx agent roles. It is gated on LazyCodex detection (the omo@sisyphuslabs plugin plus the lazycodex-install.json receipt). The edit changes only the root model pin and optionally mirrors it to ~/.omo/omo.jsonc. The maintainer follow-up refuses an invalid role TOML and keeps filesystem paths out of write errors. Co-authored-by: LilMGenius --- .../261001_omo_lazycodex_carry/000_plan.md | 62 ++++++ .../010_wp1_role_model_picker.md | 23 +++ .../020_wp2_role_auto_assign.md | 20 ++ .../030_wp3_delegation_suggest.md | 18 ++ .../src/content/docs/guides/integrations.md | 36 +++- .../src/content/docs/reference/cli/agents.md | 13 +- gui/src/i18n/de.ts | 18 ++ gui/src/i18n/en.ts | 18 ++ gui/src/i18n/fr.ts | 18 ++ gui/src/i18n/ja.ts | 18 ++ gui/src/i18n/ko.ts | 18 ++ gui/src/i18n/ru.ts | 18 ++ gui/src/i18n/tr.ts | 18 ++ gui/src/i18n/vi.ts | 18 ++ gui/src/i18n/zh-TW.ts | 18 ++ gui/src/i18n/zh.ts | 18 ++ gui/src/main.tsx | 1 + gui/src/pages/Integrations.tsx | 2 + .../integrations/LazyCodexRoleModels.tsx | 169 ++++++++++++++++ gui/src/styles/lazycodex-role-models.css | 10 + gui/tests/lazycodex-role-models.test.tsx | 189 ++++++++++++++++++ scripts/test-layout/layout.json | 4 + .../ocx/references/01_management_surface.md | 23 ++- src/cli/agent.ts | 46 +++++ src/cli/capabilities.ts | 16 ++ src/clients/lazycodex.ts | 48 +++++ src/clients/omo-role-models.ts | 111 ++++++++++ src/codex/agent-role-models.ts | 136 +++++++++++++ src/codex/prompt-layers/toml-edit.ts | 2 +- src/codex/subagent-model-fallback.ts | 99 ++++++--- src/lib/jsonc.ts | 13 ++ src/server/management-api.ts | 7 + .../management/codex-agent-role-routes.ts | 84 ++++++++ src/server/management/route-registry.ts | 3 + src/server/management/sibling-guard.ts | 1 + structure/clients/integrations.md | 35 ++++ structure/subagents.md | 31 +++ tests/cli/cli-headless-parity.test.ts | 16 ++ tests/clients/lazycodex-detection.test.ts | 63 ++++++ tests/clients/omo-role-models.test.ts | 97 +++++++++ tests/fixtures/test-layout-expected.json | 4 + tests/routing/codex-agent-role-models.test.ts | 129 ++++++++++++ tests/server/codex-agent-role-routes.test.ts | 170 ++++++++++++++++ 43 files changed, 1830 insertions(+), 31 deletions(-) create mode 100644 devlog/_plan/261001_omo_lazycodex_carry/000_plan.md create mode 100644 devlog/_plan/261001_omo_lazycodex_carry/010_wp1_role_model_picker.md create mode 100644 devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md create mode 100644 devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md create mode 100644 gui/src/pages/integrations/LazyCodexRoleModels.tsx create mode 100644 gui/src/styles/lazycodex-role-models.css create mode 100644 gui/tests/lazycodex-role-models.test.tsx create mode 100644 src/clients/lazycodex.ts create mode 100644 src/clients/omo-role-models.ts create mode 100644 src/codex/agent-role-models.ts create mode 100644 src/server/management/codex-agent-role-routes.ts create mode 100644 tests/clients/lazycodex-detection.test.ts create mode 100644 tests/clients/omo-role-models.test.ts create mode 100644 tests/routing/codex-agent-role-models.test.ts create mode 100644 tests/server/codex-agent-role-routes.test.ts diff --git a/devlog/_plan/261001_omo_lazycodex_carry/000_plan.md b/devlog/_plan/261001_omo_lazycodex_carry/000_plan.md new file mode 100644 index 00000000000..c75a98d7e01 --- /dev/null +++ b/devlog/_plan/261001_omo_lazycodex_carry/000_plan.md @@ -0,0 +1,62 @@ +# 261001 omo (Codex / LazyCodex) carry series + +LilMGenius opened three stacked PRs that let opencodex manage the model of each Codex agent role installed by LazyCodex (#6262), size roles and auto-assign models (#6269), and suggest a delegation model on the Subagents page (#6274). They could not land as fork PRs: the readiness gate needs the author's local-validation attestation, fork CI never ran its test shards, and `hygiene` flagged an unsponsored management-API surface. This unit lands the same work as three maintainer carry PRs in dependency order, each crediting the author, each merged only after its exact head passes hosted CI. + +## Loop spec + +- **Loop archetype:** satisfy-spec (verifier defines done: exact-head required CI green, then squash merge). +- **Trigger:** maintainer request (2026-10-01) to research omo and merge LilMGenius's work via cxc-loop. +- **Goal:** #6262, #6269 and #6274 content on `dev`, originals closed with credit. +- **Non-goals:** no behavior changes beyond the contributor heads; no Pi omo or OpenCode omo changes; no #6348 or other JEV work; no release or promotion. **No local test suites or typecheck** (maintainer instruction); hosted CI is the only execution evidence. +- **Verifier:** `gh pr checks --required` on the exact head SHA (reads every required job of that head, including test shards, typecheck, structure, privacy, file-size ratchet, hygiene, enforce-target). Local: `git diff --check` and `git merge-tree --write-tree origin/dev HEAD` (textual union only). +- **Stop condition:** all three carries merged and originals closed, or a blocker that needs maintainer direction. +- **Memory artifact:** this unit (000–030 docs) and the session goalplan `lilmgenius-omo-codex-lazycodex-role-model-series`. +- **Expected terminal outcomes:** DONE (three merges); BLOCKED (CI failure needing design change, union conflict that changes behavior); NEEDS_HUMAN (security-review objection). +- **Escalation condition:** a required job fails for a reason that is not a mechanical carry fix; a behavior decision beyond the contributor heads; a security-review objection. Shared gate 4 compatibility repairs (including a byte-for-byte ratchet move) are pre-authorized and do not escalate. +- **Resource bounds:** none set by the user beyond the host goal. + +## Research + +- **omo variants** (maintainer review 2026-09-30 01:47 on #6262): Pi omo (senpi, `~/.omo/agent`, existing omo tab), OpenCode omo (oh-my-opencode, untouched), Codex omo (LazyCodex). The series is now scoped to LazyCodex; Pi omo has no diff against `dev`. +- **LazyCodex detection verified against LazyCodex source** (`~/developer/codex/161_lazycodex`): the installer writes `lazycodex-install.json` into the plugin root (`plugins/omo/scripts/install-flow.mjs:4` `INSTALL_SNAPSHOT_FILE`, `plugins/omo/dist/cli/index.js:99259`); the plugin is installed with `codex plugin add omo@sisyphuslabs` (README). `src/clients/lazycodex.ts` requires `plugins."omo@sisyphuslabs".enabled = true` and a receipt under `plugins/cache/sisyphuslabs/omo//`, matching the Codex plugin cache layout. +- **Open review threads** re-checked at heads 6262 `ada7ec14b1`, 6269 `6c97ca9a4f`, 6274 `b180fcec0a` (architect D2–D4): unreadable `omo.jsonc` (handled by `readOmoRoleModels` → `unreadable`), escaped quoted keys (decoded, undecodable refused), multiline closing quotes (scanner consumes them), mirror retry (`retryMirror`), DelegationSuggest live region (mounted polite region). All fixed with regressions; threads are resolved on landing with a pointer. +- **Topology:** strictly stacked 6262 ⊂ 6269 ⊂ 6274, merge-base `961a4b569` (26 behind `dev` at `0328373fb8`); `git merge-tree` of 6274 vs `dev` is clean. Layer sizes: L1 18 commits / 39 files, L2 19 / 43, L3 12 / 38. All 49 commits authored by `LilMGenius `. +- **Ratchet:** no touched file is in `tests/fixtures/file-size-baseline.json` caps; i18n catalogs are exempt. Uncapped files fail at 2,000 lines; `src/server/management/agent-settings-routes.ts` reaches 1,941 at 6274 (watch in wp3). + +## Work-phase map (dependency order) + +| WP | Doc | Content | Depends | +|---|---|---|---| +| wp0 | this unit | docs-only roadmap | — | +| wp1 | 010 | carry `961a4b569..6262`, PR, CI, merge | wp0 | +| wp2 | 020 | carry `6262..6269` onto merged dev, PR, CI, merge | wp1 | +| wp3 | 030 | carry `6269..6274` onto merged dev, PR, CI, merge, close originals | wp2 | + +## Decisions (architect Godel `01a0f664-d0ea-7000-894a-50e8a319b548`) + +- D1 sequential layer cherry-pick onto landed dev — **accepted**: each PR diff is its own layer; author identity stays on every commit; squash body adds `Co-authored-by: LilMGenius `. +- D2/D3/D4 findings already fixed — **accepted**; no extra code; resolve threads with pointers on landing. +- D5 union gates (ratchet 2,000-line ceiling, test-layout maps, i18n parity, structure ownership, route registry) — **accepted**; enforced by hosted CI, watched by hand at carry time. +- D6 preserve recent dev changes in shared files — **accepted**; conflicts resolved by keeping both sides, never wholesale contributor tree. +- D7 merge-dev-into-contributor-head alternative — **rejected**: post-squash parent overlap makes later layers re-carry earlier commits. +- D8 #6274 scope is general Codex delegation, not LazyCodex-only — **accepted as intended**: the author states it configures Codex delegation defaults; the maintainer asked to merge the whole series. +- Reflection: see 001_reflection.md. + +## Security review note + +## Gates shared by every carry (r2, after architect reflection) + +1. **Pinned inputs.** Replay immutable full-SHA ranges only: wp1 `961a4b569512bd568106c4ea67a21a346e002779..ada7ec14b1b089f461e43e208d54750591fd26f9`, wp2 `ada7ec14b1b089f461e43e208d54750591fd26f9..6c97ca9a4fdec6c59901fa115be341d9cc590a15`, wp3 `6c97ca9a4fdec6c59901fa115be341d9cc590a15..b180fcec0a4c3ff63b9f230acc79e5f029bb6cea`. Before replay assert `git rev-parse refs/omo/` still equals the upper SHA; a moved contributor head stops the carry for re-plan. +2. **Fresh base.** `git fetch origin dev` immediately before branching; wp2/wp3 assert `git merge-base --is-ancestor origin/dev`. +3. **Conflict reconciliation.** Keep both sides semantically: one entry per key in i18n catalogs, layout maps and route registry; no duplicated registrations; never take the contributor file wholesale. +4. **Compatibility repairs are allowed** when hosted CI fails for a mechanical union reason (test-layout registration, ratchet 2,000-line ceiling via a byte-for-byte move into a sibling module, i18n key parity, structure ownership). Such a repair is a separate commit named "fix(carry): …", listed in the PR body, and does not change behavior. Anything else escalates. +5. **CI receipt.** Record in the PR body/comment and in this unit: head SHA, base SHA, `gh pr checks --required` output with every required job `pass`, and a coverage assertion that the expected jobs actually ran and succeeded at that head — every `test` shard, `typecheck`, structure, privacy scan, file-size ratchet, `hygiene`, `enforce-target`, and GUI lint/tests when `gui/` changed; a job that is absent, skipped or cancelled fails this gate. Also record the CI run id and attempt for the `pull_request` event at that head. Immediately before merge, re-read the head SHA and required checks; merge with `gh pr merge --squash --match-head-commit `. +6. **Publication.** PR template fully filled; GUI screenshots linked from the contributor's existing pr-assets (no image committed to the branch); squash body ends with `Co-authored-by: LilMGenius `; maintainer-integration record (MAINTAINERS.md "maintainer integration": actor lidge-jun admin, exact-head CI evidence) posted as a PR comment. +7. **Security review.** Before merge, an independent read-only security reviewer (fresh subagent, not the architect) reviews the exact carry head for its surfaces — management-API route admission and sibling guard, file writes under `$CODEX_HOME/agents` and `~/.omo/omo.jsonc` (path validation, atomicity, no secret logging), and the loopback self chat-completion (admission header, response bounds). Its verdict (PASS / FINDINGS) is bound to the head SHA and recorded in the PR comment and in this unit. FINDINGS block merge until fixed or explicitly dispositioned; an unresolved objection is NEEDS_HUMAN. Owner authorization (2026-10-01) covers the merge decision, not the review. +8. **D8 evidence.** #6274 author comment 2026-09-30T07:35: "Delegation suggest configures Codex delegation defaults, so it stays unscoped from the omo variants"; the maintainer's 2026-10-01 instruction covers the whole series. +9. **Maintainer-objection gate.** The originals carry two `CHANGES_REQUESTED` reviews by lidge-jun (2026-09-30 01:44 stack split, withdrawn by the 01:47 review; 01:47 omo-variant scoping). The author addressed the variant scoping in `e9ea34661`, `7c81640d0`, `b91537a75`, `d7a4f91a7` (#6262) and the matching #6269/#6274 commits. When each carry PR opens, dismiss both reviews on its original with a message citing those commits and the carry PR; immediately before each merge run `scripts/ci/assert-mergeable-review.sh --maintainer-integration ` and require exit 0. Any other maintainer objection is NEEDS_HUMAN. +10. **Base binding.** `pull_request` CI tests the merge of head and base at trigger time. Immediately before merge: `git fetch origin dev`; if `origin/dev` moved past the base recorded in the CI receipt, merge `origin/dev` into the carry branch (no force push), push, and repeat gates 5, 7 and 9 on the new head; for gate 7 the security reviewer inspects the incoming dev delta and re-attests the new head (a short "no security impact" verdict bound to that SHA suffices). Merge only when the receipt base equals current `origin/dev`. + +## Security review note (surfaces) + +Surfaces: management API routes (`/api/codex-agent-roles`, auto-assign, `/api/injection-model/suggest`), file writes under `$CODEX_HOME/agents` and `~/.omo/omo.jsonc`, and a loopback self chat-completion (`src/lib/local-chat-completion.ts`). No credential storage or OAuth change. Sibling instances refuse writes. Each carry PR states this and requests the MAINTAINERS.md security review explicitly. diff --git a/devlog/_plan/261001_omo_lazycodex_carry/010_wp1_role_model_picker.md b/devlog/_plan/261001_omo_lazycodex_carry/010_wp1_role_model_picker.md new file mode 100644 index 00000000000..dfc97ea1c3e --- /dev/null +++ b/devlog/_plan/261001_omo_lazycodex_carry/010_wp1_role_model_picker.md @@ -0,0 +1,23 @@ +# wp1 — carry #6262 (LazyCodex role model picker) + +Source: LilMGenius:feat/codex-role-models head `ada7ec14b1`, layer `961a4b569..ada7ec14b1` (18 commits, 39 files, +1636/-31). + +## Steps + +1. `git switch -c codex/omo-lazycodex-role-models origin/dev` (this branch also carries this unit's docs). +2. Assert `git rev-parse refs/omo/6262` = `ada7ec14b1b089f461e43e208d54750591fd26f9`, then `git cherry-pick 961a4b569512bd568106c4ea67a21a346e002779..ada7ec14b1b089f461e43e208d54750591fd26f9` oldest first (gate 1). On conflict reconcile per gate 3: `dev` additions in i18n catalogs, `scripts/test-layout/layout.json`, `tests/fixtures/test-layout-expected.json`, `structure/clients/integrations.md`; `src/lib/jsonc.ts` (the series only modifies it; it exists unchanged on `origin/dev` and at the pinned base, blob `dda3c392`. An earlier accidental trial cherry-pick in the main checkout hit a modify/delete conflict only because that checkout's local `dev` was stale at `9177663665`; the trial was aborted and the checkout restored). +3. `git diff --check origin/dev...HEAD`; `git merge-tree --write-tree origin/dev HEAD` must be clean. +4. Push; open PR to `dev` titled `feat(codex): pick the model for each LazyCodex agent role (carry #6262)` with the PR template filled, the three-variant table and screenshots from #6262 (LilMGenius pr-assets SHA `d0a29e1c` links), "Carried from #6262 by @LilMGenius", `Co-authored-by: LilMGenius `, Verification "local suites not run (maintainer instruction); hosted CI at exact head", security-review request, and the maintainer-integration record. +5. Wait for every required check on the exact head; fix mechanical carry failures only (escalate otherwise). +6. Squash-merge with the co-author trailer; resolve #6262 open threads with a pointer; leave #6262 open until wp3 closes all three. + +## Files (layer L1) + +docs-site guides/integrations.md, reference/cli/agents.md; gui i18n ×10, main.tsx, pages/Integrations.tsx, pages/integrations/LazyCodexRoleModels.tsx, styles/lazycodex-role-models.css, tests/lazycodex-role-models.test.tsx; scripts/test-layout/layout.json; skills/ocx/references/01_management_surface.md; src/cli/agent.ts, cli/capabilities.ts, clients/lazycodex.ts, clients/omo-role-models.ts, codex/agent-role-models.ts, codex/prompt-layers/toml-edit.ts, codex/subagent-model-fallback.ts, lib/jsonc.ts, server/management-api.ts, server/management/codex-agent-role-routes.ts, route-registry.ts, sibling-guard.ts; structure/clients/integrations.md, structure/subagents.md; tests cli-headless-parity, lazycodex-detection, omo-role-models, codex-agent-role-models, codex-agent-role-routes; tests/fixtures/test-layout-expected.json. + +## Acceptance + +- Exact-head required checks all `pass` (`gh pr checks --required`). +- PR diff equals layer L1 plus this unit's docs and any listed "fix(carry)" compatibility commits (no #6269/#6274 files). All shared gates (1–10) in 000_plan.md apply. +- Co-author trailer present in the squash commit on `dev`. +- Conditional paths are covered by the carried tests: no LazyCodex → GET returns no roles and PUT 409 (`tests/server/codex-agent-role-routes.test.ts`); unreadable mirror → `unreadable` (`tests/clients/omo-role-models.test.ts`); escaped/multiline keys (`tests/routing/codex-agent-role-models.test.ts`). diff --git a/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md b/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md new file mode 100644 index 00000000000..2964ac84381 --- /dev/null +++ b/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md @@ -0,0 +1,20 @@ +# wp2 — carry #6269 (role auto-assign) + +Source: LilMGenius:feat/codex-role-auto-assign head `6c97ca9a4f`, layer `ada7ec14b1..6c97ca9a4f` (19 commits, 43 files, +2206/-174). + +## Steps + +1. After wp1 merges: `git fetch origin dev && git switch -c codex/omo-lazycodex-role-auto-assign origin/dev`. +2. Assert `git rev-parse refs/omo/6269` = `6c97ca9a4fdec6c59901fa115be341d9cc590a15` and the wp1 squash SHA is an ancestor of `origin/dev`, then `git cherry-pick ada7ec14b1b089f461e43e208d54750591fd26f9..6c97ca9a4fdec6c59901fa115be341d9cc590a15` (gates 1–3), reconciling in particular `src/server/management/agent-settings-routes.ts` (dev native picker-order acceptance), `src/config/schema/config-schema.ts` and `src/types/config.ts` (dev Codex-credit additions). +3. Line-count check for uncapped files near 2,000 (`agent-settings-routes.ts`). +4. PR `feat(codex): auto-assign LazyCodex role models by sizing each role (carry #6269)`; same template/credit/security/integration records; screenshots from #6269 body. +5. Exact-head CI, squash merge, resolve threads. + +## Files (layer L2) + +As listed by `git diff --name-only refs/omo/6262 refs/omo/6269`: role-auto-assign/sizing modules, local-chat-completion, codex-role-auto-assign routes, LazyCodexRoleAutoAssign.tsx, config schema/types (`codexRoleTiers`), i18n ×10, docs, tests (codex-role-auto-assign, codex-role-sizing, local-chat-completion, codex-role-auto-assign-routes, lazycodex-role-auto-assign GUI), layout maps. + +## Acceptance + +- Exact-head required checks pass; diff equals L2 plus listed "fix(carry)" commits. All shared gates (1–10) apply; branch only after wp1 squash SHA is an ancestor of origin/dev. +- Without LazyCodex, `POST /api/codex-agent-roles/auto-assign` returns 409 before calling the sizing model (`tests/server/codex-role-auto-assign-routes.test.ts`); oversized sizing response is cut while streaming (`tests/lib/local-chat-completion.test.ts`). diff --git a/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md b/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md new file mode 100644 index 00000000000..7240b6fe7b3 --- /dev/null +++ b/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md @@ -0,0 +1,18 @@ +# wp3 — carry #6274 (delegation suggest) and close the originals + +Source: LilMGenius:feat/subagent-default-sizing head `b180fcec0a4c3ff63b9f230acc79e5f029bb6cea`, layer `6c97ca9a4fdec6c59901fa115be341d9cc590a15..b180fcec0a4c3ff63b9f230acc79e5f029bb6cea` (12 commits, 38 files, +961/-46). + +## Steps + +1. After wp2 merges: `git fetch origin dev`; assert wp2's squash SHA is an ancestor of `origin/dev` and `git rev-parse refs/omo/6274` = `b180fcec0a4c3ff63b9f230acc79e5f029bb6cea` (gate 1, gate 2). Branch `codex/omo-delegation-suggest` from `origin/dev`; `git cherry-pick 6c97ca9a4fdec6c59901fa115be341d9cc590a15..b180fcec0a4c3ff63b9f230acc79e5f029bb6cea`. +2. Ratchet watch: `src/server/management/agent-settings-routes.ts` must stay under 2,000 lines after the union; if not, apply shared gate 4 (byte-for-byte move of the suggest handler into a sibling module, separate "fix(carry)" commit). +3. PR `feat(subagents): suggest a delegation model by sizing the work (carry #6274)` with shared gates 5–7: template, screenshots from #6274's pr-assets, co-author trailer, security review, maintainer-integration record. Scope note (D8, gate 8): applies to Codex delegation defaults generally, not only LazyCodex. +4. Exact-head CI receipt with coverage assertion (gate 5), then `gh pr merge --squash --match-head-commit `. +5. Close #6262, #6269, #6274 with a comment linking each carry PR and merge SHA, thanking @LilMGenius; resolve remaining threads with pointers. + +## Acceptance + +- Exact-head required checks pass with the gate 5 coverage assertion; diff equals L3 plus any listed "fix(carry)" compatibility commits. +- The polite live region stays mounted and announces running and completion text (`gui/tests/subagents-delegation-suggest.test.tsx`). +- `--apply` skips an already-matching setting and the suggest route is read-only until `PUT /api/injection-model` (`tests/codex-integration/injection-model-suggest-routes.test.ts`). +- Originals closed with links; goalplan criteria c-1 to c-4 met with evidence. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 4cb6ead058c..95926d210fb 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -21,11 +21,17 @@ file, and removes it again. Seventeen clients work this way, each with a switch: | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | -| omo | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | +| omo (Pi / senpi) | `~/.omo/agent/models.json` | JSON | new sessions | loopback placeholder | | Cline CLI | `~/.cline/data/settings/providers.json` and sibling `models.json` | JSON pair | after stopping and restarting Cline | loopback placeholder | | Kilo | first existing `kilo.jsonc`, `kilo.json`, `opencode.jsonc`, `opencode.json`, or `config.json` under `~/.config/kilo` | JSONC | new sessions | `OPENCODEX_KILO_API_KEY` | | Factory Droid | `~/.factory/settings.json` (`%USERPROFILE%\.factory\settings.json` on Windows) | JSON | immediately via file watching | none — keyless loopback | +"omo" names three products that share the `~/.omo` folder. The **omo** tab manages Pi-based omo +(the senpi engine) through `~/.omo/agent/models.json`, as in the table above. Codex-based omo +(LazyCodex) gets its own controls on the Codex tab, described in +[omo (Codex / LazyCodex) role models](#omo-codex--lazycodex-role-models). OpenCode-based omo +(oh-my-opencode) keeps its own config under OpenCode; opencodex does not read or write it. + Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows the full roster so you can enable additional models. @@ -521,6 +527,34 @@ key) in the app's API key field. The app sends it as `Authorization: Bearer`, wh `/v1/chat/completions` accepts as proxy admission and never forwards upstream; see the [authentication matrix](/reference/proxy-formats/#authentication-matrix). +## omo (Codex / LazyCodex) role models + +When LazyCodex is installed, the Codex tab shows an **omo (Codex / LazyCodex)** section listing +every Codex agent role found in `$CODEX_HOME/agents/*.toml`, with the model each one is pinned +to. Codex runs a role on that pin no matter which model the parent asks for, so this is where a +role's model is actually decided. LazyCodex counts as installed when the `omo@sisyphuslabs` +Codex plugin is enabled in `$CODEX_HOME/config.toml` and an installed copy under +`$CODEX_HOME/plugins/cache/sisyphuslabs/omo/` carries its `lazycodex-install.json`. A `~/.omo` +folder on its own does not count, because Pi-based omo creates it too. Without LazyCodex the +section is hidden and the command line reports it as not installed. Pick a model on a row and +press Save: + +- opencodex rewrites only the root `model = "..."` line of that role's file. The role's + instructions, comments, and other keys are left exactly as they were. A role with no pin gets + one added near the top of the file. +- The same value is written to `codex.agents..model` in `~/.omo/omo.jsonc`, which + LazyCodex 5.1.1 and later reads. If that file does not exist it is not created. If it contains + comments it is left untouched, because saving would remove them; the tab says so, and you can + set the value there by hand. + +Nothing happens until you press Save; syncing or restarting opencodex never changes a role file. +New Codex sessions pick up the change. The same controls exist on the command line: + +```bash +ocx agent roles +ocx agent roles set explorer xai/grok-4.5 +``` + ## Kilo Kilo CLI, VS Code, and JetBrains share one global config. This integration writes diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 933e2fc6baa..0f14578987d 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -7,7 +7,7 @@ These commands control agent policy and routing, inspect the live proxy, and con ## Agent policy -### `ocx agent ...` +### `ocx agent ...` Manage the headless multi-agent roster, effort caps, prompt injection, fallback, and sidecar settings. Use `status` for the current policy. See [Sub-agent surfaces](/guides/sub-agent-surface/) for how @@ -17,6 +17,17 @@ surface modes, delegation, effort, and fallback behavior fit together. ocx agent subagents set ark/model-a,openai/gpt-5.5 ``` +`ocx agent roles` is for omo (Codex / LazyCodex). It lists each Codex agent role in +`$CODEX_HOME/agents` with its model pin and whether `~/.omo/omo.jsonc` can be updated, or says +LazyCodex is not installed, in which case `set` is refused. `ocx agent roles set ` rewrites only +that role's root `model` line and mirrors the value into omo.jsonc at +`codex.agents..model`. A missing omo.jsonc, or one containing comments, is left unchanged +and the command says so. See [omo (Codex / LazyCodex) role models](/guides/integrations/#omo-codex--lazycodex-role-models). + +```bash +ocx agent roles set explorer xai/grok-4.5 +``` + `ocx agent sidecar web --list` and `ocx agent sidecar vision --list` print the models the server currently offers for each sidecar — the exact filtered set the dashboard picker shows (picker-visible rows plus the login-entitled Luna/Haiku auth slots, intersected with executor diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 204aa1cf580..92b18c06e88 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1666,6 +1666,24 @@ export const de: Record = { "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", "integrations.semantics.omo": "Verwaltet ausschließlich providers.opencodex in der models.json von omo — ~/.omo/agent, sofern nicht OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR oder PI_CODING_AGENT_DIR sie umleitet. Ihre übrigen Provider bleiben unverändert. Gilt ab neuen Sitzungen.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Modelle der Codex-Agentenrollen", + "integrations.lazycodexRoles.hint": "Wählen Sie das Modell, mit dem Codex jede Agentenrolle ausführt. Beim Speichern ändert sich nur die model-Zeile in der Datei dieser Rolle unter $CODEX_HOME/agents. In omo.jsonc wird die Auswahl für LazyCodex nur übernommen, wenn sich die Datei sicher neu schreiben lässt.", + "integrations.lazycodexRoles.role": "Rolle", + "integrations.lazycodexRoles.current": "Aktuelles Modell", + "integrations.lazycodexRoles.model": "Neues Modell", + "integrations.lazycodexRoles.modelFor": "Modell für {role}", + "integrations.lazycodexRoles.none": "Kein Modell festgelegt", + "integrations.lazycodexRoles.choose": "Modell wählen", + "integrations.lazycodexRoles.empty": "Keine Codex-Agentenrollen gefunden.", + "integrations.lazycodexRoles.loadFailed": "Codex-Agentenrollen konnten nicht geladen werden.", + "integrations.lazycodexRoles.saveFailed": "Das Modell für {role} konnte nicht gespeichert werden.", + "integrations.lazycodexRoles.saved": "{role} läuft jetzt mit {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} läuft jetzt mit {model}. omo.jsonc wurde nicht gefunden, daher wurde nur die Rollendatei geändert.", + "integrations.lazycodexRoles.omoComments": "{role} läuft laut Rollendatei jetzt mit {model}. omo.jsonc blieb unverändert, weil das Speichern seine Kommentare entfernen würde; setzen Sie codex.agents.{role}.model dort von Hand.", + "integrations.lazycodexRoles.omoInvalid": "{role} läuft laut Rollendatei jetzt mit {model}. omo.jsonc blieb unverändert, weil es kein JSON-Objekt mit einem codex.agents-Objekt ist.", + "integrations.lazycodexRoles.omoFailed": "{role} läuft laut Rollendatei jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Passen Sie codex.agents in omo.jsonc von Hand an, damit beide übereinstimmen.", "integrations.semantics.cline": "Verwaltet OpenCodex in providers.json und models.json der Cline CLI. Beenden Sie Cline vor Änderungen oder Synchronisierung und starten Sie es danach neu. Rückgängig stellt beide Originaldateien wieder her. Ihr Standardanbieter bleibt unverändert; wählen Sie OpenCodex in Cline.", "integrations.semantics.kilo": "Verwaltet nur provider.opencodex in Kilos globaler Konfiguration — die erste vorhandene Datei unter kilo.jsonc, kilo.json, opencode.jsonc, opencode.json oder config.json in ~/.config/kilo (XDG_CONFIG_HOME verschiebt dieses Verzeichnis; ist keine vorhanden, wird kilo.jsonc erstellt). Andere Schlüssel bleiben unverändert. Anwenden schreibt die ganze Datei neu, daher bleiben Kommentare und nachgestellte Kommas nicht erhalten. Wählen Sie opencodex/ in Kilo.", "integrations.semantics.droid": "Fügt Factory Droid in settings.json OpenCodex-Modelle hinzu. Deaktivieren entfernt nur verwaltete Einträge; Rückgängig stellt die gespeicherte Datei wieder her.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index eb0b4655184..6427b7e277b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2269,6 +2269,24 @@ export const en = { "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", "integrations.semantics.omo": "Manages only providers.opencodex in omo's models.json — ~/.omo/agent unless OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR or PI_CODING_AGENT_DIR redirects it. Your other providers stay unchanged. Applies to new sessions.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex agent role models", + "integrations.lazycodexRoles.hint": "Pick the model Codex runs for each agent role. Saving changes only the model line in that role's file under $CODEX_HOME/agents, and mirrors the choice into omo.jsonc for LazyCodex only when that file can be safely rewritten.", + "integrations.lazycodexRoles.role": "Role", + "integrations.lazycodexRoles.current": "Current model", + "integrations.lazycodexRoles.model": "New model", + "integrations.lazycodexRoles.modelFor": "Model for {role}", + "integrations.lazycodexRoles.none": "No model pin", + "integrations.lazycodexRoles.choose": "Choose a model", + "integrations.lazycodexRoles.empty": "No Codex agent roles were found.", + "integrations.lazycodexRoles.loadFailed": "Could not load Codex agent roles.", + "integrations.lazycodexRoles.saveFailed": "Could not save the model for {role}.", + "integrations.lazycodexRoles.saved": "{role} now runs on {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} now runs on {model}. omo.jsonc was not found, so only the role file changed.", + "integrations.lazycodexRoles.omoComments": "{role} now runs on {model} in its role file. omo.jsonc was left unchanged because saving would remove its comments; set codex.agents.{role}.model there by hand.", + "integrations.lazycodexRoles.omoInvalid": "{role} now runs on {model} in its role file. omo.jsonc was left unchanged because it is not a JSON object with a codex.agents object.", + "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model} in its role file, but omo.jsonc could not be written.", + "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contains comments, so saving here changes only the role files. Edit codex.agents in omo.jsonc by hand to keep it in step.", "integrations.semantics.cline": "Manages OpenCodex in Cline CLI providers.json and models.json. Stop Cline before changing or syncing these files, then restart. Undo restores both originals. Your default provider stays unchanged; select OpenCodex in Cline.", "integrations.semantics.kilo": "Manages only provider.opencodex in Kilo's global config — the first existing file among kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, or config.json under ~/.config/kilo (XDG_CONFIG_HOME relocates that directory; kilo.jsonc is created when none exist). Other keys stay unchanged. Apply rewrites the whole file, so comments and trailing commas are not preserved. Select opencodex/ in Kilo.", "integrations.semantics.droid": "Adds OpenCodex custom models to Factory Droid settings.json. Disable removes only managed rows; undo restores the saved file.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index c6cdb4e76e6..fb37e358d76 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2196,6 +2196,24 @@ export const fr: Record = { "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", "integrations.semantics.omo": "Gère uniquement providers.opencodex dans le models.json d'omo — ~/.omo/agent, sauf redirection par OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR ou PI_CODING_AGENT_DIR. Vos autres fournisseurs restent inchangés. S'applique aux nouvelles sessions.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Modèles des rôles d’agent Codex", + "integrations.lazycodexRoles.hint": "Choisissez le modèle que Codex utilise pour chaque rôle d’agent. L’enregistrement ne modifie que la ligne model du fichier de ce rôle dans $CODEX_HOME/agents, et ne reporte le choix dans omo.jsonc pour LazyCodex que si ce fichier peut être réécrit sans risque.", + "integrations.lazycodexRoles.role": "Rôle", + "integrations.lazycodexRoles.current": "Modèle actuel", + "integrations.lazycodexRoles.model": "Nouveau modèle", + "integrations.lazycodexRoles.modelFor": "Modèle pour {role}", + "integrations.lazycodexRoles.none": "Aucun modèle épinglé", + "integrations.lazycodexRoles.choose": "Choisir un modèle", + "integrations.lazycodexRoles.empty": "Aucun rôle d’agent Codex trouvé.", + "integrations.lazycodexRoles.loadFailed": "Impossible de charger les rôles d’agent Codex.", + "integrations.lazycodexRoles.saveFailed": "Impossible d’enregistrer le modèle de {role}.", + "integrations.lazycodexRoles.saved": "{role} utilise désormais {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} utilise désormais {model}. omo.jsonc est introuvable, seul le fichier du rôle a donc été modifié.", + "integrations.lazycodexRoles.omoComments": "{role} utilise désormais {model} dans son fichier de rôle. omo.jsonc n’a pas été modifié, car l’enregistrement supprimerait ses commentaires ; définissez codex.agents.{role}.model à la main.", + "integrations.lazycodexRoles.omoInvalid": "{role} utilise désormais {model} dans son fichier de rôle. omo.jsonc n’a pas été modifié, car ce n’est pas un objet JSON contenant un objet codex.agents.", + "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model} dans son fichier de rôle, mais omo.jsonc n’a pas pu être écrit.", + "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : l’enregistrement ici ne modifie donc que les fichiers de rôle. Modifiez codex.agents dans omo.jsonc à la main pour rester synchronisé.", "integrations.semantics.cline": "Gère OpenCodex dans providers.json et models.json de Cline CLI. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux fichiers originaux. Votre fournisseur par défaut reste inchangé ; sélectionnez OpenCodex dans Cline.", "integrations.semantics.kilo": "Gère uniquement provider.opencodex dans la configuration globale de Kilo — le premier fichier existant parmi kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json sous ~/.config/kilo (XDG_CONFIG_HOME déplace ce répertoire ; kilo.jsonc est créé si aucun n'existe). Les autres clés restent inchangées. Appliquer réécrit tout le fichier : commentaires et virgules finales ne sont pas conservés. Sélectionnez opencodex/ dans Kilo.", "integrations.semantics.droid": "Ajoute les modèles personnalisés OpenCodex au settings.json de Factory Droid. Désactiver retire seulement les entrées gérées ; Annuler restaure le fichier sauvegardé.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 36d2bfcbe27..754e1a98389 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2099,6 +2099,24 @@ export const ja: Record = { "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", "integrations.semantics.omo": "omo の models.json にある providers.opencodex のみを管理します。場所は ~/.omo/agent で、OMO_CODING_AGENT_DIR・SENPI_CODING_AGENT_DIR・PI_CODING_AGENT_DIR のいずれかが設定されている場合はそちらが優先されます。他のプロバイダーは変更しません。新しいセッションから適用されます。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex エージェントロールのモデル", + "integrations.lazycodexRoles.hint": "Codex が各エージェントロールで使うモデルを選びます。保存すると $CODEX_HOME/agents にあるそのロールのファイルの model 行だけが変わり、omo.jsonc を安全に書き換えられる場合に限り LazyCodex 用に同じ値を反映します。", + "integrations.lazycodexRoles.role": "ロール", + "integrations.lazycodexRoles.current": "現在のモデル", + "integrations.lazycodexRoles.model": "新しいモデル", + "integrations.lazycodexRoles.modelFor": "{role} のモデル", + "integrations.lazycodexRoles.none": "モデル指定なし", + "integrations.lazycodexRoles.choose": "モデルを選択", + "integrations.lazycodexRoles.empty": "Codex エージェントロールが見つかりません。", + "integrations.lazycodexRoles.loadFailed": "Codex エージェントロールを読み込めませんでした。", + "integrations.lazycodexRoles.saveFailed": "{role} のモデルを保存できませんでした。", + "integrations.lazycodexRoles.saved": "{role} は {model} で動作します。", + "integrations.lazycodexRoles.omoAbsent": "{role} は {model} で動作します。omo.jsonc が見つからないため、ロールファイルだけを変更しました。", + "integrations.lazycodexRoles.omoComments": "ロールファイルでは {role} が {model} で動作します。保存するとコメントが消えるため omo.jsonc は変更していません。codex.agents.{role}.model を手動で設定してください。", + "integrations.lazycodexRoles.omoInvalid": "ロールファイルでは {role} が {model} で動作します。omo.jsonc は codex.agents オブジェクトを持つ JSON オブジェクトではないため変更していません。", + "integrations.lazycodexRoles.omoFailed": "ロールファイルでは {role} が {model} で動作しますが、omo.jsonc に書き込めませんでした。", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが変わります。omo.jsonc の codex.agents は手動で合わせてください。", "integrations.semantics.cline": "Cline CLI の providers.json と models.json の OpenCodex 項目を管理します。変更・同期前に Cline を終了し、完了後に再起動してください。元に戻すと両方の元ファイルが復元されます。既定のプロバイダーは変わりません。Cline で OpenCodex を選択してください。", "integrations.semantics.kilo": "Kilo のグローバル設定(~/.config/kilo 配下で最初に存在する kilo.jsonc、kilo.json、opencode.jsonc、opencode.json、config.json。XDG_CONFIG_HOME はこのディレクトリを移動し、いずれも存在しなければ kilo.jsonc が作成されます)の provider.opencodex のみを管理します。他のキーは変更しません。適用時にファイル全体を書き直すため、コメントと末尾カンマは保持されません。Kilo で opencodex/<モデル> を選択してください。", "integrations.semantics.droid": "Factory Droid の settings.json に OpenCodex カスタムモデルを追加します。無効化すると管理対象の項目だけが削除され、元に戻すと保存したファイルが復元されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 73fad35e2d9..57dfaafed4a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1702,6 +1702,24 @@ export const ko: Record = { "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", "integrations.semantics.omo": "omo의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.omo/agent이며 OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR, PI_CODING_AGENT_DIR 중 설정된 값이 있으면 그쪽이 우선합니다. 다른 프로바이더는 그대로 유지됩니다. 새 세션부터 적용됩니다.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 에이전트 역할 모델", + "integrations.lazycodexRoles.hint": "Codex가 에이전트 역할마다 쓰는 모델을 고릅니다. 저장하면 $CODEX_HOME/agents에 있는 그 역할 파일의 model 줄만 바뀌고, omo.jsonc를 안전하게 다시 쓸 수 있을 때만 LazyCodex용으로 같은 값을 반영합니다.", + "integrations.lazycodexRoles.role": "역할", + "integrations.lazycodexRoles.current": "현재 모델", + "integrations.lazycodexRoles.model": "새 모델", + "integrations.lazycodexRoles.modelFor": "{role} 모델", + "integrations.lazycodexRoles.none": "지정된 모델 없음", + "integrations.lazycodexRoles.choose": "모델 선택", + "integrations.lazycodexRoles.empty": "Codex 에이전트 역할을 찾지 못했습니다.", + "integrations.lazycodexRoles.loadFailed": "Codex 에이전트 역할을 불러오지 못했습니다.", + "integrations.lazycodexRoles.saveFailed": "{role}의 모델을 저장하지 못했습니다.", + "integrations.lazycodexRoles.saved": "이제 {role}은(는) {model}로 실행됩니다.", + "integrations.lazycodexRoles.omoAbsent": "이제 {role}은(는) {model}로 실행됩니다. omo.jsonc가 없어서 역할 파일만 바꿨습니다.", + "integrations.lazycodexRoles.omoComments": "역할 파일에서 {role}은(는) 이제 {model}로 실행됩니다. 저장하면 주석이 사라지므로 omo.jsonc는 바꾸지 않았습니다. 그곳의 codex.agents.{role}.model은 직접 설정하세요.", + "integrations.lazycodexRoles.omoInvalid": "역할 파일에서 {role}은(는) 이제 {model}로 실행됩니다. omo.jsonc가 codex.agents 객체를 가진 JSON 객체가 아니어서 바꾸지 않았습니다.", + "integrations.lazycodexRoles.omoFailed": "역할 파일에서 {role}은(는) 이제 {model}로 실행되지만, omo.jsonc에는 쓰지 못했습니다.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어서 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 codex.agents는 직접 맞춰 주세요.", "integrations.semantics.cline": "Cline CLI의 providers.json과 models.json에서 OpenCodex 항목을 관리합니다. 변경·동기화 전에 Cline을 종료하고 완료 후 다시 실행하세요. 되돌리기는 두 파일의 원본을 복원합니다. 기본 프로바이더는 유지되므로 Cline에서 OpenCodex를 선택하세요.", "integrations.semantics.kilo": "Kilo 전역 설정(~/.config/kilo에서 먼저 존재하는 kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, config.json. XDG_CONFIG_HOME는 이 디렉터리를 이동하며, 아무것도 없으면 kilo.jsonc가 생성됨)의 provider.opencodex만 관리합니다. 다른 키는 그대로 둡니다. 적용 시 파일 전체를 다시 쓰므로 주석과 후행 쉼표는 보존되지 않습니다. Kilo에서 opencodex/<모델>을 선택하세요.", "integrations.semantics.droid": "Factory Droid의 settings.json에 OpenCodex 사용자 지정 모델을 추가합니다. 비활성화하면 관리 항목만 제거하고, 되돌리기는 저장한 파일을 복원합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 6a592b041a7..22c19a0bb92 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2190,6 +2190,24 @@ export const ru: Record = { "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", "integrations.semantics.omo": "Управляет только providers.opencodex в models.json omo — ~/.omo/agent, если только OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR или PI_CODING_AGENT_DIR не перенаправляет путь. Остальные провайдеры остаются без изменений. Применяется к новым сессиям.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Модели ролей агентов Codex", + "integrations.lazycodexRoles.hint": "Выберите модель, на которой Codex запускает каждую роль агента. При сохранении меняется только строка model в файле этой роли в $CODEX_HOME/agents, а в omo.jsonc для LazyCodex выбор копируется, только если этот файл можно безопасно перезаписать.", + "integrations.lazycodexRoles.role": "Роль", + "integrations.lazycodexRoles.current": "Текущая модель", + "integrations.lazycodexRoles.model": "Новая модель", + "integrations.lazycodexRoles.modelFor": "Модель для {role}", + "integrations.lazycodexRoles.none": "Модель не закреплена", + "integrations.lazycodexRoles.choose": "Выберите модель", + "integrations.lazycodexRoles.empty": "Роли агентов Codex не найдены.", + "integrations.lazycodexRoles.loadFailed": "Не удалось загрузить роли агентов Codex.", + "integrations.lazycodexRoles.saveFailed": "Не удалось сохранить модель для {role}.", + "integrations.lazycodexRoles.saved": "{role} теперь работает на {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} теперь работает на {model}. Файл omo.jsonc не найден, поэтому изменён только файл роли.", + "integrations.lazycodexRoles.omoComments": "В файле роли {role} теперь работает на {model}. omo.jsonc не изменён, потому что сохранение удалило бы его комментарии; задайте codex.agents.{role}.model вручную.", + "integrations.lazycodexRoles.omoInvalid": "В файле роли {role} теперь работает на {model}. omo.jsonc не изменён: это не JSON-объект с объектом codex.agents.", + "integrations.lazycodexRoles.omoFailed": "В файле роли {role} теперь работает на {model}, но записать omo.jsonc не удалось.", + "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь меняет только файлы ролей. Поправьте codex.agents в omo.jsonc вручную, чтобы они совпадали.", "integrations.semantics.cline": "Управляет OpenCodex в файлах providers.json и models.json Cline CLI. Закройте Cline перед изменением или синхронизацией и запустите снова после завершения. Отмена восстанавливает оба исходных файла. Провайдер по умолчанию не меняется; выберите OpenCodex в Cline.", "integrations.semantics.kilo": "Управляет только provider.opencodex в глобальной конфигурации Kilo — первый существующий файл среди kilo.jsonc, kilo.json, opencode.jsonc, opencode.json или config.json в ~/.config/kilo (XDG_CONFIG_HOME переносит этот каталог; если файла нет, создаётся kilo.jsonc). Остальные ключи не меняются. Применение перезаписывает весь файл, поэтому комментарии и завершающие запятые не сохраняются. Выберите opencodex/<модель> в Kilo.", "integrations.semantics.droid": "Добавляет модели OpenCodex в settings.json Factory Droid. Отключение удаляет только управляемые записи; отмена восстанавливает сохранённый файл.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index f555706cce0..b4267243dc7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2208,6 +2208,24 @@ export const tr: Record = { "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", "integrations.semantics.omo": "Yalnızca omo'nun models.json dosyasındaki providers.opencodex girdisini yönetir — OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR veya PI_CODING_AGENT_DIR yönlendirmediği sürece ~/.omo/agent. Diğer sağlayıcılarınız değişmeden kalır. Yeni oturumlardan itibaren geçerlidir.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex ajan rolü modelleri", + "integrations.lazycodexRoles.hint": "Codex'in her ajan rolünde kullanacağı modeli seçin. Kaydetmek yalnızca $CODEX_HOME/agents altındaki o rol dosyasının model satırını değiştirir ve omo.jsonc güvenle yeniden yazılabiliyorsa seçimi LazyCodex için oraya da yansıtır.", + "integrations.lazycodexRoles.role": "Rol", + "integrations.lazycodexRoles.current": "Geçerli model", + "integrations.lazycodexRoles.model": "Yeni model", + "integrations.lazycodexRoles.modelFor": "{role} için model", + "integrations.lazycodexRoles.none": "Sabitlenmiş model yok", + "integrations.lazycodexRoles.choose": "Bir model seçin", + "integrations.lazycodexRoles.empty": "Codex ajan rolü bulunamadı.", + "integrations.lazycodexRoles.loadFailed": "Codex ajan rolleri yüklenemedi.", + "integrations.lazycodexRoles.saveFailed": "{role} için model kaydedilemedi.", + "integrations.lazycodexRoles.saved": "{role} artık {model} ile çalışıyor.", + "integrations.lazycodexRoles.omoAbsent": "{role} artık {model} ile çalışıyor. omo.jsonc bulunamadığı için yalnızca rol dosyası değişti.", + "integrations.lazycodexRoles.omoComments": "{role} rol dosyasında artık {model} ile çalışıyor. Kaydetmek yorumlarını sileceği için omo.jsonc değiştirilmedi; codex.agents.{role}.model değerini orada elle ayarlayın.", + "integrations.lazycodexRoles.omoInvalid": "{role} rol dosyasında artık {model} ile çalışıyor. omo.jsonc, codex.agents nesnesi içeren bir JSON nesnesi olmadığı için değiştirilmedi.", + "integrations.lazycodexRoles.omoFailed": "{role} rol dosyasında artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", + "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorum içerdiği için burada kaydetmek yalnızca rol dosyalarını değiştirir. Eşleşmeleri için omo.jsonc içindeki codex.agents değerini elle düzenleyin.", "integrations.semantics.cline": "Cline CLI providers.json ve models.json dosyalarındaki OpenCodex girdilerini yönetir. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, ardından yeniden başlatın. Geri al iki özgün dosyayı da geri yükler. Varsayılan sağlayıcınız değişmez; Cline içinde OpenCodex’i seçin.", "integrations.semantics.kilo": "Yalnızca Kilo’nun genel yapılandırmasındaki provider.opencodex öğesini yönetir — ~/.config/kilo altındaki ilk bulunan kilo.jsonc, kilo.json, opencode.jsonc, opencode.json veya config.json (XDG_CONFIG_HOME bu dizini taşır; hiçbiri yoksa kilo.jsonc oluşturulur). Diğer anahtarlar değişmez. Uygula dosyanın tamamını yeniden yazar; yorumlar ve sondaki virgüller korunmaz. Kilo’da opencodex/ seçin.", "integrations.semantics.droid": "Factory Droid settings.json dosyasına OpenCodex özel modellerini ekler. Devre dışı bırakma yalnızca yönetilen girdileri kaldırır; Geri al kaydedilen dosyayı geri yükler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 53909f52acf..0b779d69786 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2224,6 +2224,24 @@ export const vi: Record = { "integrations.semantics.aside": "Chỉ quản lý providers.opencodex trong ~/.aside/u//models.json của profile này. Các provider khác của bạn không thay đổi. Hãy thoát hoàn toàn và mở lại Aside sau khi áp dụng.", "integrations.semantics.raycast": "Thêm một provider OpenCodex vào providers.yaml của Raycast để mọi model được định tuyến xuất hiện trong bộ chọn model AI của Raycast. Yêu cầu Raycast Pro.", "integrations.semantics.omo": "Chỉ quản lý providers.opencodex trong models.json của omo — ~/.omo/agent, trừ khi OMO_CODING_AGENT_DIR, SENPI_CODING_AGENT_DIR hoặc PI_CODING_AGENT_DIR chuyển hướng. Các nhà cung cấp khác của bạn không thay đổi. Áp dụng cho phiên mới.", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Mô hình cho vai trò tác tử Codex", + "integrations.lazycodexRoles.hint": "Chọn mô hình Codex dùng cho từng vai trò tác tử. Khi lưu, chỉ dòng model trong tệp của vai trò đó ở $CODEX_HOME/agents thay đổi; lựa chọn chỉ được ghi sang omo.jsonc cho LazyCodex khi tệp đó có thể được ghi lại an toàn.", + "integrations.lazycodexRoles.role": "Vai trò", + "integrations.lazycodexRoles.current": "Mô hình hiện tại", + "integrations.lazycodexRoles.model": "Mô hình mới", + "integrations.lazycodexRoles.modelFor": "Mô hình cho {role}", + "integrations.lazycodexRoles.none": "Chưa ghim mô hình", + "integrations.lazycodexRoles.choose": "Chọn mô hình", + "integrations.lazycodexRoles.empty": "Không tìm thấy vai trò tác tử Codex nào.", + "integrations.lazycodexRoles.loadFailed": "Không tải được các vai trò tác tử Codex.", + "integrations.lazycodexRoles.saveFailed": "Không lưu được mô hình cho {role}.", + "integrations.lazycodexRoles.saved": "{role} giờ chạy trên {model}.", + "integrations.lazycodexRoles.omoAbsent": "{role} giờ chạy trên {model}. Không tìm thấy omo.jsonc nên chỉ tệp vai trò được thay đổi.", + "integrations.lazycodexRoles.omoComments": "Trong tệp vai trò, {role} giờ chạy trên {model}. omo.jsonc được giữ nguyên vì lưu sẽ xóa các chú thích của nó; hãy tự đặt codex.agents.{role}.model ở đó.", + "integrations.lazycodexRoles.omoInvalid": "Trong tệp vai trò, {role} giờ chạy trên {model}. omo.jsonc được giữ nguyên vì nó không phải đối tượng JSON có đối tượng codex.agents.", + "integrations.lazycodexRoles.omoFailed": "Trong tệp vai trò, {role} giờ chạy trên {model}, nhưng không ghi được omo.jsonc.", + "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích nên lưu ở đây chỉ thay đổi các tệp vai trò. Hãy tự sửa codex.agents trong omo.jsonc để hai nơi khớp nhau.", "integrations.semantics.cline": "Quản lý OpenCodex trong providers.json và models.json của Cline CLI. Hãy dừng Cline trước khi thay đổi hoặc đồng bộ các tệp này, rồi khởi động lại. Hoàn tác sẽ khôi phục cả hai bản gốc. Nhà cung cấp mặc định của bạn không thay đổi; hãy chọn OpenCodex trong Cline.", "integrations.semantics.kilo": "Chỉ quản lý provider.opencodex trong cấu hình toàn cục của Kilo — tệp tồn tại đầu tiên trong kilo.jsonc, kilo.json, opencode.jsonc, opencode.json hoặc config.json dưới ~/.config/kilo (XDG_CONFIG_HOME chuyển thư mục này; nếu không tệp nào tồn tại, kilo.jsonc sẽ được tạo). Các khóa khác giữ nguyên. Áp dụng ghi lại toàn bộ tệp nên chú thích và dấu phẩy cuối không được giữ. Chọn opencodex/ trong Kilo.", "integrations.semantics.droid": "Thêm các mô hình OpenCodex vào settings.json của Factory Droid. Tắt chỉ xóa các mục được quản lý; Hoàn tác khôi phục tệp đã lưu.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index c30d4386ae8..0882126f59d 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3011,6 +3011,24 @@ export const zhTW: Record = { "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", "integrations.semantics.omo": "僅管理 omo 的 models.json 中的 providers.opencodex,路徑為 ~/.omo/agent,若設定了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 則以其為準。你的其他供應商維持不變。對新工作階段生效。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 代理角色模型", + "integrations.lazycodexRoles.hint": "為每個代理角色選擇 Codex 使用的模型。儲存時只會修改 $CODEX_HOME/agents 中該角色檔案的 model 行;只有在 omo.jsonc 可以安全改寫時,才會把選擇同步到供 LazyCodex 讀取的 omo.jsonc。", + "integrations.lazycodexRoles.role": "角色", + "integrations.lazycodexRoles.current": "目前模型", + "integrations.lazycodexRoles.model": "新模型", + "integrations.lazycodexRoles.modelFor": "{role} 的模型", + "integrations.lazycodexRoles.none": "未固定模型", + "integrations.lazycodexRoles.choose": "選擇模型", + "integrations.lazycodexRoles.empty": "找不到 Codex 代理角色。", + "integrations.lazycodexRoles.loadFailed": "無法載入 Codex 代理角色。", + "integrations.lazycodexRoles.saveFailed": "無法儲存 {role} 的模型。", + "integrations.lazycodexRoles.saved": "{role} 現在使用 {model}。", + "integrations.lazycodexRoles.omoAbsent": "{role} 現在使用 {model}。找不到 omo.jsonc,因此只修改了角色檔案。", + "integrations.lazycodexRoles.omoComments": "角色檔案中 {role} 現在使用 {model}。儲存會刪除 omo.jsonc 的註解,因此未修改它;請手動設定其中的 codex.agents.{role}.model。", + "integrations.lazycodexRoles.omoInvalid": "角色檔案中 {role} 現在使用 {model}。omo.jsonc 不是包含 codex.agents 物件的 JSON 物件,因此未修改。", + "integrations.lazycodexRoles.omoFailed": "角色檔案中 {role} 現在使用 {model},但無法寫入 omo.jsonc。", + "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有註解,因此在這裡儲存只會修改角色檔案。請手動調整 omo.jsonc 中的 codex.agents 以保持一致。", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 項目。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個檔案的原始內容。預設供應商保持不變,請在 Cline 中選擇 OpenCodex。", "integrations.semantics.kilo": "僅管理 Kilo 全域設定——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 會移動該目錄;若都不存在則建立 kilo.jsonc)——中的 provider.opencodex。其他鍵保持不變。套用會重寫整個檔案,因此不會保留註解與尾隨逗號。請在 Kilo 中選擇 opencodex/<模型>。", "integrations.semantics.droid": "將 OpenCodex 自訂模型加入 Factory Droid 的 settings.json。停用只移除受管理項目;復原會還原已儲存的檔案。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 36577edd129..79d92a3f21b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1683,6 +1683,24 @@ export const zh: Record = { "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", "integrations.semantics.omo": "仅管理 omo 的 models.json 中的 providers.opencodex,路径为 ~/.omo/agent,若设置了 OMO_CODING_AGENT_DIR、SENPI_CODING_AGENT_DIR 或 PI_CODING_AGENT_DIR 则以其为准。你的其他提供商保持不变。对新会话生效。", + "integrations.lazycodexRoles.title": "omo (Codex / LazyCodex) · Codex 智能体角色模型", + "integrations.lazycodexRoles.hint": "为每个智能体角色选择 Codex 使用的模型。保存时只修改 $CODEX_HOME/agents 中该角色文件的 model 行;只有在 omo.jsonc 可以安全改写时,才会把选择同步到供 LazyCodex 读取的 omo.jsonc。", + "integrations.lazycodexRoles.role": "角色", + "integrations.lazycodexRoles.current": "当前模型", + "integrations.lazycodexRoles.model": "新模型", + "integrations.lazycodexRoles.modelFor": "{role} 的模型", + "integrations.lazycodexRoles.none": "未固定模型", + "integrations.lazycodexRoles.choose": "选择模型", + "integrations.lazycodexRoles.empty": "未找到 Codex 智能体角色。", + "integrations.lazycodexRoles.loadFailed": "无法加载 Codex 智能体角色。", + "integrations.lazycodexRoles.saveFailed": "无法保存 {role} 的模型。", + "integrations.lazycodexRoles.saved": "{role} 现在使用 {model}。", + "integrations.lazycodexRoles.omoAbsent": "{role} 现在使用 {model}。未找到 omo.jsonc,因此只修改了角色文件。", + "integrations.lazycodexRoles.omoComments": "角色文件中 {role} 现在使用 {model}。保存会删除 omo.jsonc 的注释,因此未修改它;请手动设置其中的 codex.agents.{role}.model。", + "integrations.lazycodexRoles.omoInvalid": "角色文件中 {role} 现在使用 {model}。omo.jsonc 不是包含 codex.agents 对象的 JSON 对象,因此未修改。", + "integrations.lazycodexRoles.omoFailed": "角色文件中 {role} 现在使用 {model},但无法写入 omo.jsonc。", + "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", + "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有注释,因此在这里保存只会修改角色文件。请手动调整 omo.jsonc 中的 codex.agents 以保持一致。", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 项目。修改或同步前请退出 Cline,完成后重新启动。撤销会恢复两个文件的原始内容。默认提供商保持不变,请在 Cline 中选择 OpenCodex。", "integrations.semantics.kilo": "仅管理 Kilo 全局配置——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 会移动该目录;若都不存在则创建 kilo.jsonc)——中的 provider.opencodex。其他键保持不变。应用会重写整个文件,因此不会保留注释和尾随逗号。请在 Kilo 中选择 opencodex/<模型>。", "integrations.semantics.droid": "将 OpenCodex 自定义模型添加到 Factory Droid 的 settings.json。停用仅移除托管条目;撤销会恢复已保存的文件。", diff --git a/gui/src/main.tsx b/gui/src/main.tsx index a0ee419b435..49db60f99e5 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -15,6 +15,7 @@ import "./styles/sidebar-brand.css"; import "./styles/fast-rows-setting.css"; import "./styles/claude-desktop-mode-picker.css"; import "./styles/claude-first-party-bindings.css"; +import "./styles/lazycodex-role-models.css"; import "./styles/claude-desktop-picker.css"; import "./styles/anthropic-reset-grants.css"; import "./styles/star-onboarding.css"; diff --git a/gui/src/pages/Integrations.tsx b/gui/src/pages/Integrations.tsx index b7d7d86fc23..afa63151f70 100644 --- a/gui/src/pages/Integrations.tsx +++ b/gui/src/pages/Integrations.tsx @@ -9,6 +9,7 @@ import Grok from "./Grok"; import CursorIntegrationPage from "./integrations/CursorIntegrationPage"; import IntegrationsOverview from "./integrations/IntegrationsOverview"; import AsideProfilesPage from "./integrations/AsideProfilesPage"; +import LazyCodexRoleModels from "./integrations/LazyCodexRoleModels"; import FileIntegrationPage, { type FileIntegrationClientId, } from "./integrations/FileIntegrationPage"; @@ -192,6 +193,7 @@ export default function Integrations({ apiBase, machineApiBase = apiBase, connec > {t("integrations.codex.openService")} + )} {definition.id === "claude" && } diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx new file mode 100644 index 00000000000..892a7132009 --- /dev/null +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -0,0 +1,169 @@ +import { useCallback, useState } from "react"; +import { useDataSurface } from "../../data-surface"; +import { readJsonOrThrow } from "../../fetch-json"; +import { useT, type TKey } from "../../i18n/shared"; +import { Notice, Select, type SelectOption } from "../../ui"; + +interface RoleRow { + role: string; + model: string | null; + omoJsoncModel: string | null; +} + +type OmoFileState = "absent" | "comments" | "invalid" | "present"; +type OmoWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid" | "write_failed"; + +interface RoleModels { + detected: boolean; + roles: RoleRow[]; + omoJsonc: { state: OmoFileState } | null; + available: string[]; +} + +const OMO_WRITE_NOTICE: Partial> = { + absent: "integrations.lazycodexRoles.omoAbsent", + skipped_comments: "integrations.lazycodexRoles.omoComments", + invalid: "integrations.lazycodexRoles.omoInvalid", + write_failed: "integrations.lazycodexRoles.omoFailed", +}; + +export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: string; active: boolean }) { + const t = useT(); + const [drafts, setDrafts] = useState>({}); + const [mirrorRetries, setMirrorRetries] = useState>({}); + const [pending, setPending] = useState(null); + const [result, setResult] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); + + const load = useCallback(async (signal: AbortSignal): Promise => { + const roles = await readJsonOrThrow<{ lazycodex?: { detected?: boolean }; roles?: RoleRow[]; omoJsonc?: { state: OmoFileState } | null }>( + await fetch(`${apiBase}/api/codex-agent-roles`, { signal }), + t("integrations.lazycodexRoles.loadFailed"), + ); + if (roles?.lazycodex?.detected !== true) return { detected: false, roles: [], omoJsonc: null, available: [] }; + const models = await readJsonOrThrow<{ available?: string[] }>( + await fetch(`${apiBase}/api/subagent-models`, { signal }), + t("integrations.lazycodexRoles.loadFailed"), + ); + return { detected: true, roles: roles.roles ?? [], omoJsonc: roles.omoJsonc ?? null, available: models?.available ?? [] }; + }, [apiBase, t]); + + const resource = useDataSurface(`lazycodex-role-models:${apiBase}`, [apiBase], load, { + isEmpty: data => data.roles.length === 0, + enabled: active, + }); + const data = resource.state.data; + + const save = async (role: string, model: string) => { + if (pending !== null) return; + setPending(role); + setResult(null); + try { + const response = await fetch(`${apiBase}/api/codex-agent-roles/${encodeURIComponent(role)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model }), + }); + const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( + response, + t("integrations.lazycodexRoles.saveFailed", { role }), + ); + const omoStatus = payload?.omoJsonc?.status; + const notice = omoStatus ? OMO_WRITE_NOTICE[omoStatus] : undefined; + setResult(notice + ? { tone: "warn", text: t(notice, { role, model }) } + : { tone: "ok", text: t("integrations.lazycodexRoles.saved", { role, model }) }); + setDrafts(current => Object.fromEntries(Object.entries(current).filter(([key]) => key !== role))); + setMirrorRetries(current => { + const { [role]: _previous, ...rest } = current; + return omoStatus === "write_failed" ? { ...rest, [role]: model } : rest; + }); + await resource.refresh(); + } catch { + setResult({ tone: "err", text: t("integrations.lazycodexRoles.saveFailed", { role }) }); + } finally { + setPending(null); + } + }; + + const optionsFor = (row: RoleRow, draft: string): SelectOption[] => { + const values = [...new Set([...(row.model ? [row.model] : []), ...(data?.available ?? [])])]; + return [ + ...(draft === "" ? [{ value: "", label: t("integrations.lazycodexRoles.choose") }] : []), + ...values.map(value => ({ value, label: value })), + ]; + }; + + if (resource.state.kind === "failed-cold") { + return ( + + {t("integrations.lazycodexRoles.loadFailed")}{" "} + + + ); + } + if (!data?.detected) return null; + + return ( +
+

{t("integrations.lazycodexRoles.title")}

+

{t("integrations.lazycodexRoles.hint")}

+ {data.omoJsonc?.state === "comments" && {t("integrations.lazycodexRoles.omoCommentsState")}} + {result && {result.text}} + {data.roles.length === 0 ? ( +

{t("integrations.lazycodexRoles.empty")}

+ ) : ( +
+ + + + + + + + + + {data.roles.map(row => { + const draft = drafts[row.role] ?? row.model ?? ""; + const changed = draft !== "" && draft !== row.model; + const retryMirror = !changed && draft !== "" && mirrorRetries[row.role] === draft; + return ( + + + + + + ); + })} + +
{t("integrations.lazycodexRoles.role")}{t("integrations.lazycodexRoles.current")}{t("integrations.lazycodexRoles.model")}
{row.role} + {row.model + ? {row.model} + : {t("integrations.lazycodexRoles.none")}} + +
+
+
+ )} +
+ ); +} diff --git a/gui/src/styles/lazycodex-role-models.css b/gui/src/styles/lazycodex-role-models.css new file mode 100644 index 00000000000..47de491d670 --- /dev/null +++ b/gui/src/styles/lazycodex-role-models.css @@ -0,0 +1,10 @@ +/* omo (Codex / LazyCodex) role models on the Codex integration tab. Existing tokens only. */ +.lazycodex-role-models { margin: 14px 0; } +.lazycodex-role-models .tbl td { vertical-align: middle; } +.lazycodex-role-models-edit { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.lazycodex-role-models-edit > :first-child { flex: 1 1 220px; min-width: 0; } +/* Overrides the shared 460px .tbl floor, which pushed the picker off-screen; the picker shows the pin. */ +@media (max-width: 760px) { + .lazycodex-role-models .tbl { min-width: 0; } + .lazycodex-role-models .tbl :is(th, td):nth-child(2) { display: none; } +} diff --git a/gui/tests/lazycodex-role-models.test.tsx b/gui/tests/lazycodex-role-models.test.tsx new file mode 100644 index 00000000000..ec4b72c574e --- /dev/null +++ b/gui/tests/lazycodex-role-models.test.tsx @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let mountCount = 0; +let apiBase = ""; +let puts: Array<{ url: string; body: unknown }> = []; +let omoWriteStatus = "written"; +let putFailure: { status: number; body: unknown } | null = null; +let rolesFailure: { status: number; body: unknown } | null = null; +let rolesBody: unknown; +let fetched: string[] = []; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#integrations/omo" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountCount += 1; + apiBase = `http://ocx-lazycodex-roles-${mountCount}.invalid`; + puts = []; + fetched = []; + omoWriteStatus = "written"; + putFailure = null; + rolesFailure = null; + rolesBody = { + lazycodex: { detected: true, pluginEnabled: true, pluginInstalled: true }, + omoJsonc: { state: "present" }, + roles: [ + { role: "explorer", model: "gpt-5.5", omoJsoncModel: null }, + { role: "librarian", model: null, omoJsoncModel: null }, + ], + }; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + fetched.push(url); + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)); + puts.push({ url, body }); + if (putFailure) return json(putFailure.body, putFailure.status); + const role = decodeURIComponent(url.slice(url.lastIndexOf("/") + 1)); + const current = rolesBody as { roles: Array<{ role: string; model: string | null }> }; + rolesBody = { ...current, roles: current.roles.map(row => row.role === role ? { ...row, model: body.model } : row) }; + return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: omoWriteStatus } }); + } + if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "xai/grok-4.5"] }); + if (rolesFailure) return json(rolesFailure.body, rolesFailure.status); + return json(rolesBody); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function settle(): Promise { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); +} + +async function mount(): Promise { + const [{ createRoot }, { LanguageProvider }, { default: LazyCodexRoleModels }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/integrations/LazyCodexRoleModels"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await settle(); +} + +function saveButton(role: string): HTMLButtonElement { + const row = [...container.querySelectorAll("tbody tr")].find(tr => tr.textContent?.includes(role)); + return row!.querySelector("button.btn-primary") as HTMLButtonElement; +} + +async function pick(role: string, model: string): Promise { + const trigger = testWindow.document.querySelector(`[aria-label="Model for ${role}"]`) as unknown as HTMLButtonElement; + await act(async () => { trigger.click(); }); + const option = [...testWindow.document.querySelectorAll('[role="option"]')].find(node => node.textContent === model) as unknown as HTMLElement; + await act(async () => { option.click(); }); +} + +test("lists roles with their pins and saves a picked model for one row", async () => { + await mount(); + expect(container.textContent).toContain("omo (Codex / LazyCodex)"); + expect(container.textContent).toContain("explorer"); + expect(container.textContent).toContain("gpt-5.5"); + expect(container.textContent).toContain("No model pin"); + expect(saveButton("explorer").disabled).toBe(true); + await pick("explorer", "xai/grok-4.5"); + expect(saveButton("explorer").disabled).toBe(false); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(puts).toEqual([{ url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "xai/grok-4.5" } }]); + expect(container.textContent).toContain("explorer now runs on xai/grok-4.5."); +}); + +test("renders nothing and loads no model list when LazyCodex is not detected", async () => { + rolesBody = { lazycodex: { detected: false, pluginEnabled: false, pluginInstalled: false }, omoJsonc: null, roles: [] }; + await mount(); + expect(container.innerHTML).toBe(""); + expect(fetched).toEqual([`${apiBase}/api/codex-agent-roles`]); +}); + +test("a failed role load shows the localized error with a retry that recovers the table", async () => { + rolesFailure = { status: 500, body: { error: "EACCES: permission denied, open '/secret/path'" } }; + await mount(); + expect(container.textContent).toContain("Could not load Codex agent roles."); + expect(container.textContent).not.toContain("EACCES"); + expect(container.querySelector("table")).toBeNull(); + const retry = [...container.querySelectorAll("button")].find(button => button.textContent === "Retry") as HTMLButtonElement; + expect(retry).toBeDefined(); + rolesFailure = null; + await act(async () => { retry.click(); }); + await settle(); + expect(container.textContent).not.toContain("Could not load Codex agent roles."); + expect(container.textContent).toContain("explorer"); +}); + +test("says when omo.jsonc was skipped because of its comments", async () => { + rolesBody = { ...(rolesBody as object), omoJsonc: { state: "comments" } }; + omoWriteStatus = "skipped_comments"; + await mount(); + expect(container.textContent).toContain("omo.jsonc contains comments"); + await pick("librarian", "gpt-5.5"); + await act(async () => { saveButton("librarian").click(); }); + await settle(); + expect(container.textContent).toContain("saving would remove its comments"); +}); + +test("a failed omo.jsonc mirror can be retried with the same model", async () => { + omoWriteStatus = "write_failed"; + await mount(); + await pick("explorer", "xai/grok-4.5"); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(container.textContent).toContain("omo.jsonc could not be written"); + expect(container.textContent).toContain("xai/grok-4.5"); + expect(saveButton("explorer").disabled).toBe(false); + expect(saveButton("explorer").textContent).toBe("Retry omo.jsonc"); + omoWriteStatus = "written"; + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(puts.map(entry => entry.body)).toEqual([{ model: "xai/grok-4.5" }, { model: "xai/grok-4.5" }]); + expect(container.textContent).toContain("explorer now runs on xai/grok-4.5."); + expect(saveButton("explorer").disabled).toBe(true); + expect(saveButton("explorer").textContent).toBe("Save"); +}); + +test("a failed save shows the localized message rather than the server text", async () => { + putFailure = { status: 500, body: { error: "EACCES: permission denied, open '/secret/path'", code: "write_failed" } }; + await mount(); + await pick("explorer", "xai/grok-4.5"); + await act(async () => { saveButton("explorer").click(); }); + await settle(); + expect(container.textContent).toContain("Could not save the model for explorer."); + expect(container.textContent).not.toContain("EACCES"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d910dc0ae31..402eede4dc3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1677,6 +1677,10 @@ "subagent-fallback-preview-sites.test.ts": "routing", "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", + "codex-agent-role-models.test.ts": "routing", + "omo-role-models.test.ts": "clients", + "lazycodex-detection.test.ts": "clients", + "codex-agent-role-routes.test.ts": "server", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 15c52cd6c71..3ad167b6ef0 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1184,6 +1184,25 @@ JSON mode: `payload`. - A bare invocation reads and never writes. +### `ocx agent roles` + +omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc. + +| Method | Route | +|---|---| +| GET | `/api/codex-agent-roles` | +| PUT | `/api/codex-agent-roles/{role}` | + +| Flag | Value | Meaning | +|---|---|---| +| `--json` | boolean | Emit the role list or the write result as JSON. | + +JSON mode: `payload`. + +- A bare invocation reads and never writes. +- Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused. +- set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments. + ### `ocx api policy` Read the protocol policy, or change the Messages surface, unrepresentable policy and rollout switches. @@ -1207,6 +1226,6 @@ JSON mode: `payload`. ## Counts -- declared capabilities: 67 -- of those, state-changing: 37 +- declared capabilities: 68 +- of those, state-changing: 38 - head-resolved invocations: 2 diff --git a/src/cli/agent.ts b/src/cli/agent.ts index db3ce47b746..e651559c133 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -28,6 +28,7 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] + ocx agent roles [status|set ] [--json] ocx agent sidecar [--list] [--model ] [--backend web: vision:] [--reasoning ] [--max-descriptions ] [--enabled ] [--json] @@ -236,6 +237,50 @@ async function sidecar(argv: string[], deps: RuntimeApiDeps): Promise { printData(result, wantsJson, lines); } +interface CodexAgentRolesStatus { + lazycodex?: { detected?: boolean }; + omoJsonc?: { state?: string } | null; + roles?: Array<{ role: string; model: string | null; omoJsoncModel: string | null }>; +} + +async function roles(argv: string[], deps: RuntimeApiDeps): Promise { + const args = [...argv]; + const wantsJson = takeFlag(args, "--json"); + const action = (args.shift() ?? "status").toLowerCase(); + if (action === "status") { + rejectArgs(args, USAGE); + const result = await runtimeRequest("/api/codex-agent-roles", {}, deps); + if (result.lazycodex?.detected !== true) { + printData(result, wantsJson, ["omo (Codex / LazyCodex) is not installed in this CODEX_HOME; role models are managed only with it."]); + return; + } + const rows = result.roles ?? []; + printData(result, wantsJson, [ + "omo (Codex / LazyCodex): detected", + ...(rows.length === 0 ? ["No Codex agent roles found."] : rows.map(row => `${row.role}: ${row.model ?? "(no model pin)"}`)), + `omo.jsonc: ${result.omoJsonc?.state ?? "unknown"}`, + ]); + return; + } + if (action !== "set") throw new CliUsageError(`unknown roles action ${action}`, USAGE); + const role = args.shift(); + const model = args.shift(); + if (!role || !model) throw new CliUsageError("a role and a model are required", USAGE); + rejectArgs(args, USAGE); + const result = await runtimeRequest<{ toml?: { status?: string }; omoJsonc?: { status?: string } }>( + `/api/codex-agent-roles/${encodeURIComponent(role)}`, + { method: "PUT", body: JSON.stringify({ model }) }, + deps, + ); + const omo = result.omoJsonc?.status; + printData(result, wantsJson, [ + `${role}: ${model} (role TOML ${result.toml?.status ?? "unknown"})`, + omo === "skipped_comments" + ? "omo.jsonc: not written, because it contains comments that a rewrite would lose." + : `omo.jsonc: ${omo ?? "unknown"}`, + ]); +} + export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise { return runCliAction(async () => { const [sub = "status", ...rest] = argv; @@ -244,6 +289,7 @@ export async function handleAgentCommand(argv: string[], deps: RuntimeApiDeps = else if (sub === "effort") await effort(rest, deps); else if (sub === "subagents" || sub === "roster") await subagents(rest, deps); else if (sub === "fallback") await fallback(rest, deps); + else if (sub === "roles") await roles(rest, deps); else if (sub === "sidecar") await sidecar(rest, deps); // Lives here rather than as a top-level verb because it is an agent-behavior feature flag: // it controls whether default mode may ask the operator a question mid-task. diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 484454a802f..0c4e4a1c853 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -1060,6 +1060,22 @@ export const CAPABILITIES: readonly Capability[] = [ json: "payload", details: ["A bare invocation reads and never writes."], }, + { + command: ["agent", "roles"], + summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc.", + routes: [ + { method: "GET", path: "/api/codex-agent-roles" }, + { method: "PUT", path: "/api/codex-agent-roles/{role}" }, + ], + flags: [{ name: "--json", value: "boolean", summary: "Emit the role list or the write result as JSON." }], + mutates: true, + json: "payload", + details: [ + "A bare invocation reads and never writes.", + "Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused.", + "set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments.", + ], + }, { command: ["api", "protocols"], summary: "Read the protocol contract version, API surfaces, protocol settings and feature vocabulary.", diff --git a/src/clients/lazycodex.ts b/src/clients/lazycodex.ts new file mode 100644 index 00000000000..0e6662a19f0 --- /dev/null +++ b/src/clients/lazycodex.ts @@ -0,0 +1,48 @@ +/** + * Detection of Codex-based omo (LazyCodex), the variant that reads Codex role files and + * `codex.agents..model` in `~/.omo/omo.jsonc`. + * + * "omo" names three products. Pi-based omo (senpi) owns `~/.omo/agent` and is the omo file + * integration in `src/integrations/registry.ts`; OpenCode-based omo (oh-my-opencode) keeps its + * own config under OpenCode. Neither proves LazyCodex is installed, and `~/.omo` exists for all + * of them, so this reads only LazyCodex's footprint inside CODEX_HOME: the Codex plugin + * `omo@sisyphuslabs` enabled in config.toml (the key LazyCodex's own doctor checks) and an + * installed copy of it carrying the `lazycodex-install.json` receipt its installer writes. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +export const LAZYCODEX_PLUGIN_ID = "omo@sisyphuslabs"; + +export interface LazyCodexDetection { + readonly detected: boolean; + readonly pluginEnabled: boolean; + readonly pluginInstalled: boolean; +} + +function pluginEnabled(codexHome: string): boolean { + let parsed: unknown; + try { + parsed = Bun.TOML.parse(readFileSync(join(codexHome, "config.toml"), "utf8")); + } catch { + return false; + } + const plugins = (parsed as { plugins?: Record } | null)?.plugins; + return plugins?.[LAZYCODEX_PLUGIN_ID]?.enabled === true; +} + +function pluginInstalled(codexHome: string): boolean { + const root = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo"); + try { + return readdirSync(root, { withFileTypes: true }) + .some(entry => entry.isDirectory() && existsSync(join(root, entry.name, "lazycodex-install.json"))); + } catch { + return false; + } +} + +export function detectLazyCodex(codexHome: string): LazyCodexDetection { + const enabled = pluginEnabled(codexHome); + const installed = pluginInstalled(codexHome); + return { detected: enabled && installed, pluginEnabled: enabled, pluginInstalled: installed }; +} diff --git a/src/clients/omo-role-models.ts b/src/clients/omo-role-models.ts new file mode 100644 index 00000000000..1a94853e14d --- /dev/null +++ b/src/clients/omo-role-models.ts @@ -0,0 +1,111 @@ +/** + * omo (Codex / LazyCodex)'s per-role model setting, `codex.agents..model` in `~/.omo/omo.jsonc`. + * + * LazyCodex 5.1.1 and later reads it, and callers reach this only after `detectLazyCodex` + * says LazyCodex is installed. The file is omo's, so this writes only on an explicit + * dashboard or CLI pick, never creates the file, and refuses a file with comments: the write + * re-serializes JSON, and a comment the user wrote would be lost without a word. + */ +import { lstatSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; +import { assertIntegrationWriteOwnership } from "../integrations/config-io"; +import { hasJsoncComments, parseJsonc } from "../lib/jsonc"; + +export type OmoRoleModelsState = + | { readonly state: "absent" } + | { readonly state: "comments" } + | { readonly state: "invalid" } + | { readonly state: "unreadable" } + | { readonly state: "present"; readonly models: Readonly> }; + +export type OmoRoleModelWriteStatus = "written" | "unchanged" | "absent" | "skipped_comments" | "invalid"; + +/** omo resolves its home from HOME, then USERPROFILE, then the OS home; this follows it. */ +export function omoJsoncPath(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string { + const base = env.HOME?.trim() || env.USERPROFILE?.trim() || home; + return join(base, ".omo", "omo.jsonc"); +} + +type JsonObject = Record; + +function isObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +type Loaded = + | { kind: "absent" | "comments" | "invalid" } + | { kind: "document"; text: string; doc: JsonObject }; + +function load(path: string): Loaded { + let text: string; + try { + if (!lstatSync(path).isFile()) return { kind: "invalid" }; + text = readFileSync(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { kind: "absent" }; + throw error; + } + if (hasJsoncComments(text)) return { kind: "comments" }; + try { + const doc = parseJsonc(text.replace(/^\ufeff/, "")); + return isObject(doc) ? { kind: "document", text, doc } : { kind: "invalid" }; + } catch { + return { kind: "invalid" }; + } +} + +function agentsOf(doc: JsonObject): JsonObject | null | undefined { + const codex = doc.codex; + if (codex === undefined) return undefined; + if (!isObject(codex)) return null; + const agents = codex.agents; + if (agents === undefined) return undefined; + return isObject(agents) ? agents : null; +} + +export function readOmoRoleModels(path: string = omoJsoncPath()): OmoRoleModelsState { + let loaded: Loaded; + try { + loaded = load(path); + } catch { + return { state: "unreadable" }; + } + if (loaded.kind !== "document") return { state: loaded.kind }; + const agents = agentsOf(loaded.doc); + if (agents === null) return { state: "invalid" }; + const models: Record = {}; + for (const [role, entry] of Object.entries(agents ?? {})) { + if (isObject(entry) && typeof entry.model === "string") models[role] = entry.model; + } + return { state: "present", models }; +} + +function serialize(text: string, doc: JsonObject): string { + const indent = /\n([ \t]+)"/.exec(text)?.[1] ?? " "; + let out = JSON.stringify(doc, null, indent); + if (/\r?\n$/.test(text)) out += "\n"; + if (text.includes("\r\n")) out = out.replace(/\n/g, "\r\n"); + return text.startsWith("\ufeff") ? `\ufeff${out}` : out; +} + +export function writeOmoRoleModel(role: string, model: string, path: string = omoJsoncPath()): OmoRoleModelWriteStatus { + const loaded = load(path); + if (loaded.kind !== "document") return loaded.kind === "comments" ? "skipped_comments" : loaded.kind; + const { doc, text } = loaded; + // Only an absent key is created; an explicit null is a value the reader calls invalid. + const codex = doc.codex === undefined ? {} : doc.codex; + if (!isObject(codex)) return "invalid"; + const agents = codex.agents === undefined ? {} : codex.agents; + if (!isObject(agents)) return "invalid"; + const entry = agents[role] === undefined ? {} : agents[role]; + if (!isObject(entry)) return "invalid"; + if (entry.model === model) return "unchanged"; + agents[role] = { ...entry, model }; + codex.agents = agents; + doc.codex = codex; + assertIntegrationWriteOwnership(path); + atomicWriteFileNoFollowUnclaimed(path, serialize(text, doc)); + return "written"; +} diff --git a/src/codex/agent-role-models.ts b/src/codex/agent-role-models.ts new file mode 100644 index 00000000000..51b78c44bcc --- /dev/null +++ b/src/codex/agent-role-models.ts @@ -0,0 +1,136 @@ +/** + * The dashboard's write of one Codex agent role's model. + * + * Codex overrides the spawn-time model with the root `model` pin in + * `$CODEX_HOME/agents/.toml`, so that pin is what decides a child's model. This module + * edits exactly that value and nothing else: every other byte of the file, including the + * instructions multiline string that usually mentions `model =` in prose, is left as it was. + * It runs only on an explicit user action; no sync or startup path calls it. + */ +import { lstatSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; +import { assertIntegrationWriteOwnership } from "../integrations/config-io"; +import { encodeBasicString, findInvalidCharacter } from "./prompt-layers/encoding"; +import { dominantEol } from "./prompt-layers/toml-edit"; +import { listCodexAgentRoles, locateTomlModelKey } from "./subagent-model-fallback"; + +export type AgentRoleModelErrorCode = + | "unknown_role" + | "invalid_model" + | "unsupported_model_value" + | "unsafe_target" + | "invalid_role_file"; + +export class AgentRoleModelError extends Error { + constructor(readonly code: AgentRoleModelErrorCode, message: string) { + super(message); + } +} + +export interface CodexAgentRoleModel { + readonly role: string; + readonly model: string | null; +} + +export function validateAgentRoleModel(model: unknown): string { + if (typeof model !== "string") throw new AgentRoleModelError("invalid_model", "model must be a string"); + const trimmed = model.trim(); + if (trimmed === "") throw new AgentRoleModelError("invalid_model", "model must not be empty"); + if (trimmed.includes("\n") || findInvalidCharacter(trimmed) !== null) { + throw new AgentRoleModelError("invalid_model", "model must not contain control characters"); + } + return trimmed; +} + +/** + * Set the root `model` value. An existing value is replaced inside its quotes and keeps its + * quote style when the new value allows it; a missing key is inserted after the leading + * comment block with the file's own line ending. + */ +export function setTomlRootModel(content: string, model: string): string { + const location = locateTomlModelKey(content); + if (location?.inRootTable) { + if (!location.span) { + throw new AgentRoleModelError("unsupported_model_value", "the existing model value is not a one-line string"); + } + const lines = content.split("\n"); + const line = lines[location.line]!; + const literal = line[location.span.start] === "'" && !model.includes("'") + ? `'${model}'` + : encodeBasicString(model); + lines[location.line] = line.slice(0, location.span.start) + literal + line.slice(location.span.end); + return lines.join("\n"); + } + const eol = dominantEol(content); + const bom = content.startsWith("\ufeff") ? 1 : 0; + const lines = content.slice(bom).split("\n"); + let offset = bom; + for (const line of lines) { + if (!/^\s*#/.test(line)) break; + offset += line.length + 1; + } + const assignment = `model = ${encodeBasicString(model)}`; + if (offset > content.length) return `${content}${eol}${assignment}${eol}`; + return `${content.slice(0, offset)}${assignment}${eol}${content.slice(offset)}`; +} + +function roleFile(role: string, codexHome: string): string { + return join(codexHome, "agents", `${role}.toml`); +} + +function requireKnownRole(role: string, codexHome: string): void { + // Membership in the directory listing is the whole path check: a name with a separator or + // `..` can never equal a listed file stem. + if (!listCodexAgentRoles(codexHome).includes(role)) { + throw new AgentRoleModelError("unknown_role", `no Codex agent role named ${JSON.stringify(role)}`); + } +} + +export function listCodexAgentRoleModels(codexHome: string): CodexAgentRoleModel[] { + return listCodexAgentRoles(codexHome).sort().map(role => { + let model: string | null = null; + try { + const location = locateTomlModelKey(readFileSync(roleFile(role, codexHome), "utf8")); + model = location?.inRootTable ? location.value : null; + } catch { /* an unreadable role reports no pin */ } + return { role, model }; + }); +} + +/** + * Codex refuses a role file that is not valid TOML. Editing one anyway could turn a role that + * Codex rejects into one it loads, so both the original and the edited document must parse. + */ +function assertValidRoleToml(role: string, text: string, stage: "before" | "after"): void { + try { + Bun.TOML.parse(text); + } catch { + throw new AgentRoleModelError( + "invalid_role_file", + stage === "before" + ? `${role}.toml is not valid TOML; fix it before setting a model` + : `setting the model would leave ${role}.toml invalid; the file was not changed`, + ); + } +} + +export function writeCodexAgentRoleModel( + role: string, + model: string, + codexHome: string, +): { status: "written" | "unchanged" } { + requireKnownRole(role, codexHome); + const path = roleFile(role, codexHome); + if (!lstatSync(path).isFile()) { + throw new AgentRoleModelError("unsafe_target", `${role}.toml is not a regular file; edit it where it points`); + } + const before = readFileSync(path, "utf8"); + assertValidRoleToml(role, before, "before"); + const after = setTomlRootModel(before, validateAgentRoleModel(model)); + if (after === before) return { status: "unchanged" }; + assertValidRoleToml(role, after, "after"); + assertIntegrationWriteOwnership(path); + atomicWriteFileNoFollowUnclaimed(path, after); + return { status: "written" }; +} diff --git a/src/codex/prompt-layers/toml-edit.ts b/src/codex/prompt-layers/toml-edit.ts index 4895e18a09b..5579b391f1a 100644 --- a/src/codex/prompt-layers/toml-edit.ts +++ b/src/codex/prompt-layers/toml-edit.ts @@ -3,7 +3,7 @@ import { encodeBasicString } from "./encoding"; import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read"; /** Line editing, not re-serialization: the user's comments and layout survive. */ -function dominantEol(content: string): "\r\n" | "\n" { +export function dominantEol(content: string): "\r\n" | "\n" { const crlf = (content.match(/\r\n/g) ?? []).length; if (crlf === 0) return "\n"; const bareLf = (content.match(/\n/g) ?? []).length - crlf; diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index 86e8be0b143..bd9832812b9 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -41,6 +41,7 @@ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { codexAccountNamespaceForModel } from "./account-namespace-match"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_MAIN_DRAIN_SENTINEL_MODELS } from "./catalog/native-models"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { decodeTomlBasicString } from "./prompt-layers/encoding"; import { getUpstreamHostHealth, normalizeUpstreamHostCircuitThreshold, @@ -771,10 +772,14 @@ function findTomlMultilineStringEnd(text: string, from: number, quote: string): const delimiter = quote.repeat(3); let index = text.indexOf(delimiter, from); while (index !== -1) { - if (quote === "'") return index; let backslashes = 0; - for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1; - if (backslashes % 2 === 0) return index; + if (quote === '"') for (let j = index - 1; j >= 0 && text[j] === "\\"; j--) backslashes += 1; + if (backslashes % 2 === 0) { + // TOML lets one or two quotes sit right before the closing delimiter, so """a""""" ends on its last three. + let extra = 0; + while (extra < 2 && text[index + 3 + extra] === quote) extra += 1; + return index + extra; + } index = text.indexOf(delimiter, index + 1); } return -1; @@ -918,28 +923,35 @@ export function scanCodexAgentRolesWithTomlModelFallback( } } -const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/; +const TOML_ROOT_KEY = /^\s*(?:([A-Za-z0-9_-]+)|("(?:\\.|[^"\\])*")|'([^'\n]*)')\s*=/; +const TOML_TABLE_HEADER = /^\s*\[/; + +function tomlKeyName(match: RegExpMatchArray): string | undefined { + if (match[1] !== undefined) return match[1]; + if (match[3] !== undefined) return match[3]; + return decodeTomlBasicString(match[2]!) ?? undefined; +} + +export interface TomlModelKeyLocation { + /** Index into `content.split("\n")`; a trailing `\r` stays on the line. */ + readonly line: number; + readonly inRootTable: boolean; + /** Column span of the quoted value on `line`; null when it is not a one-line string. */ + readonly span: { readonly start: number; readonly end: number } | null; + readonly value: string | null; +} /** - * TOML-aware read of the root `model` pin, or null when there is none. - * - * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted - * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong - * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`, - * an indented key, or a trailing comment are all valid TOML that Codex honours and that a - * stricter matcher would report as unpinned. + * Locate the first `model` key outside multiline strings and arrays. * - * It shares the scanner used for `model_fallback` for the reason that matters here: an imported - * role file keeps its instructions in a multiline string, and that string contains the very words - * this scan looks for. A line matcher would read a key out of prose. - * - * Table context is not tracked, matching the `model_fallback` parse. A `model` key under a later - * table header would be read as the root pin; Codex role files are flat in practice, and for a - * warning the conservative direction is to stay quiet. + * The one scan behind both the read ({@link parseTomlModelPin}) and the dashboard's write of a + * role's model, so the line the writer edits is the line the reader reports. */ -function parseTomlModelPin(content: string): string | null { +export function locateTomlModelKey(content: string): TomlModelKeyLocation | null { const lines = content.split(/\r?\n/); const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 }; + let inRootTable = true; + let undecodedRootKeyLine: number | null = null; for (let i = 0; i < lines.length; i += 1) { const line = lines[i]!; if (state.inMultilineString) { @@ -950,19 +962,52 @@ function parseTomlModelPin(content: string): string | null { continue; } if (state.arrayDepth === 0) { - const key = line.match(TOML_MODEL_KEY); - if (key) { + if (TOML_TABLE_HEADER.test(line)) inRootTable = false; + const key = line.match(TOML_ROOT_KEY); + const name = key ? tomlKeyName(key) : null; + if (key && name === undefined && inRootTable) undecodedRootKeyLine ??= i; + if (key && name === "model") { const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`; let at = 0; while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1; - if (rest[at] !== '"' && rest[at] !== "'") return null; - const value = parseTomlStringAt(rest, at)?.value.trim() ?? ""; - return value === "" ? null : value; + if (rest[at] !== '"' && rest[at] !== "'") return { line: i, inRootTable, span: null, value: null }; + const parsed = parseTomlStringAt(rest, at); + const value = parsed?.value.trim() ?? ""; + const onLine = parsed !== null && parsed.end <= line.length - key[0].length; + return { + line: i, + inRootTable, + span: onLine ? { start: key[0].length + at, end: key[0].length + parsed.end } : null, + value: value === "" ? null : value, + }; } } scanTomlLine(line, state); } - return null; + // A root key that might spell model cannot be ruled out, so a writer must not add a second one. + return undecodedRootKeyLine === null ? null : { line: undecodedRootKeyLine, inRootTable: true, span: null, value: null }; +} + +/** + * TOML-aware read of the root `model` pin, or null when there is none. + * + * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted + * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong + * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`, + * an indented key, or a trailing comment are all valid TOML that Codex honours and that a + * stricter matcher would report as unpinned. + * + * It shares the scanner used for `model_fallback` for the reason that matters here: an imported + * role file keeps its instructions in a multiline string, and that string contains the very words + * this scan looks for. A line matcher would read a key out of prose. + * + * The scan reports whether the key sits in the root table, and this read ignores that flag on + * purpose: a `model` key under a later table header still counts as a pin, because for a warning + * the conservative direction is to stay quiet. The role listing and the writer in + * `src/codex/agent-role-models.ts` do gate on it, since they show and edit only the root value. + */ +function parseTomlModelPin(content: string): string | null { + return locateTomlModelKey(content)?.value ?? null; } /** Filename prefix opencodex gives the Claude agents it generates. */ @@ -981,7 +1026,9 @@ const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"] /** * Roles that look opencodex-derived but pin no model, so Codex runs them on the parent model. * - * opencodex does not write Codex role TOMLs. These arrive when the Codex desktop external-agent + * opencodex does not create, repair, or remove Codex role TOMLs. Its one write into them is the + * root `model` value, and only when a user picks a model for that role in the dashboard or with + * `ocx agent roles set` (`src/codex/agent-role-models.ts`). These arrive when the Codex desktop external-agent * import converts `~/.claude/agents/ocx-*.md` into `$CODEX_HOME/agents/ocx-*.toml`, dropping the * `model:` frontmatter because a `claude-ocx-native--` id is not a Codex model and keeping only the * instructions. The surviving `ocx-route` directive cannot make up the difference: it is honoured diff --git a/src/lib/jsonc.ts b/src/lib/jsonc.ts index dda3c392eb7..8d96b5106e3 100644 --- a/src/lib/jsonc.ts +++ b/src/lib/jsonc.ts @@ -108,3 +108,16 @@ export function canonicalizeJsonc(text: string): string { export function parseJsonc(text: string): unknown { return JSON.parse(canonicalizeJsonc(text)); } + +/** + * True when the text carries a `//` or block comment outside a string. A writer that + * re-serializes the document would drop those, so it checks this before writing. + * An unterminated block comment counts as a comment. + */ +export function hasJsoncComments(text: string): boolean { + try { + return stripJsonComments(text) !== text; + } catch { + return true; + } +} diff --git a/src/server/management-api.ts b/src/server/management-api.ts index da36c718634..9cf119b0c8e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -166,6 +166,12 @@ async function handleWorkflowBudgetRoutesOnDemand(ctx: ManagementContext): Promi return handleWorkflowBudgetRoutes(ctx); } +async function handleCodexAgentRoleRoutesOnDemand(ctx: ManagementContext): Promise { + if (!pathInManagementNamespace(ctx.url.pathname, "/api/codex-agent-roles")) return null; + const { handleCodexAgentRoleRoutes } = await import("./management/codex-agent-role-routes"); + return handleCodexAgentRoleRoutes(ctx); +} + /** * Lazy like the Lab and routing-profile handlers: the protocol planner reaches the router and * the ingress eligibility rules, which no other dashboard request needs. @@ -336,6 +342,7 @@ export async function handleManagementAPI( ?? (await handleQuotaResetRoutesOnDemand(ctx)) ?? (await handleLowQuotaRoutesOnDemand(ctx)) ?? (await handleWorkflowBudgetRoutesOnDemand(ctx)) + ?? (await handleCodexAgentRoleRoutesOnDemand(ctx)) ?? (await handleProtocolRoutesOnDemand(ctx)) ?? (await handleGrokCouponRoutesOnDemand(ctx)) ?? (await handleAnthropicResetGrantRoutesOnDemand(ctx)) diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts new file mode 100644 index 00000000000..a8b8eff53b9 --- /dev/null +++ b/src/server/management/codex-agent-role-routes.ts @@ -0,0 +1,84 @@ +/** + * Per-role model for omo (Codex / LazyCodex): `$CODEX_HOME/agents/.toml` plus LazyCodex's + * `codex.agents..model` mirror in omo.jsonc. Both halves exist only when LazyCodex is + * detected; Pi-based and OpenCode-based omo are never read here. + * + * Loaded on demand from `src/server/management-api.ts`, like the quota-reset handler, so a + * dashboard request that never opens the omo tab loads neither writer. + */ +import { jsonResponse } from "../auth-cors"; +import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; +import type { ManagementContext } from "./context"; + +const ROLE_PATH_PREFIX = "/api/codex-agent-roles/"; + +export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promise { + const { req, url, config } = ctx; + const [{ getCodexHome }, roles, omo, { detectLazyCodex }] = await Promise.all([ + import("../../codex/paths"), + import("../../codex/agent-role-models"), + import("../../clients/omo-role-models"), + import("../../clients/lazycodex"), + ]); + + if (url.pathname === "/api/codex-agent-roles" && req.method === "GET") { + const codexHome = getCodexHome(); + const lazycodex = detectLazyCodex(codexHome); + if (!lazycodex.detected) return jsonResponse({ lazycodex, omoJsonc: null, roles: [] }, 200, req, config); + const omoState = omo.readOmoRoleModels(omo.omoJsoncPath()); + const omoModels = omoState.state === "present" ? omoState.models : {}; + return jsonResponse({ + lazycodex, + omoJsonc: { state: omoState.state }, + roles: roles.listCodexAgentRoleModels(codexHome).map(entry => ({ + ...entry, + omoJsoncModel: omoModels[entry.role] ?? null, + })), + }, 200, req, config); + } + + if (!url.pathname.startsWith(ROLE_PATH_PREFIX) || req.method !== "PUT") return null; + const codexHome = getCodexHome(); + if (!detectLazyCodex(codexHome).detected) { + return jsonResponse({ + error: "omo (Codex / LazyCodex) is not installed in this CODEX_HOME", + code: "lazycodex_not_detected", + }, 409, req, config); + } + let role: string; + try { + role = decodeURIComponent(url.pathname.slice(ROLE_PATH_PREFIX.length)); + } catch { + return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); + } + let body: { model?: unknown }; + try { + body = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, req, config); + } + + let model: string; + let toml: { status: "written" | "unchanged" }; + try { + model = roles.validateAgentRoleModel(body?.model); + toml = roles.writeCodexAgentRoleModel(role, model, codexHome); + } catch (error) { + if (error instanceof roles.AgentRoleModelError) { + const status = error.code === "unknown_role" ? 404 : error.code === "invalid_model" ? 400 : 409; + return jsonResponse({ error: error.message, code: error.code }, status, req, config); + } + // Filesystem and ownership errors carry absolute paths and UIDs; keep them out of the response. + return jsonResponse({ error: "could not write the role file", code: "write_failed" }, 500, req, config); + } + + // The role TOML is what Codex obeys, so its write stands even when the omo mirror cannot follow. + let omoStatus: ReturnType | "write_failed"; + try { + omoStatus = omo.writeOmoRoleModel(role, model, omo.omoJsoncPath()); + } catch { + omoStatus = "write_failed"; + } + return jsonResponse({ ok: true, role, model, toml, omoJsonc: { status: omoStatus } }, 200, req, config); +} diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index f41692503fe..cd48d44e31f 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -166,6 +166,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/subagent-model-fallback", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/subagent-models", module: "server/management/agent-settings-routes", mutates: true }, { method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true }, + // server/management/codex-agent-role-routes + { method: "GET", path: "/api/codex-agent-roles", module: "server/management/codex-agent-role-routes", mutates: false }, + { method: "PUT", path: "/api/codex-agent-roles/{role}", module: "server/management/codex-agent-role-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/aside-profile-routes { method: "GET", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside status alias; the current CLI reads the same aggregate through GET /api/client-integrations/aside/profiles." } }, { method: "PUT", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: true, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside toggle alias; the current CLI uses PUT /api/client-integrations/aside/profiles so older servers cannot mistake a bulk request for a current-account toggle." } }, diff --git a/src/server/management/sibling-guard.ts b/src/server/management/sibling-guard.ts index 7c2f46473c8..a03bb52e740 100644 --- a/src/server/management/sibling-guard.ts +++ b/src/server/management/sibling-guard.ts @@ -34,6 +34,7 @@ export const SIBLING_REFUSED_MANAGEMENT_PATHS: readonly { readonly path: string; { path: "/api/grok/apply", children: false }, { path: "/api/grok/selection", children: false }, { path: "/api/codex-prompt", children: true }, + { path: "/api/codex-agent-roles", children: true }, { path: CODEX_RESTART_PATH, children: false }, { path: "/api/link/join", children: false }, { path: "/api/native-main-profiles", children: true }, diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index a1c31f165cb..541e566762a 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -425,6 +425,41 @@ sibling policy. Profile journal views retain source-store provenance for older l The shared atomic replacement publisher also identifies explicit Remote Workspace file writes as `remote-workspace`; its isolated owner and support limits are documented in [Remote Workspace](../remote-workspace.md). +## omo variants + +"omo" names three products, and each surface here serves exactly one of them: + +| Variant | Its own evidence | opencodex surface | +| --- | --- | --- | +| Pi-based omo (senpi engine) | `~/.omo/agent`, or `OMO_CODING_AGENT_DIR` / `SENPI_CODING_AGENT_DIR` / `PI_CODING_AGENT_DIR` (`omoAgentDir()`) | the `omo` file integration and tab, `providers.opencodex` in `models.json` | +| Codex-based omo (LazyCodex) | `[plugins."omo@sisyphuslabs"] enabled = true` in `$CODEX_HOME/config.toml` plus `lazycodex-install.json` in an installed copy under `$CODEX_HOME/plugins/cache/sisyphuslabs/omo//` (`detectLazyCodex()` in `src/clients/lazycodex.ts`) | role model pins and the omo.jsonc mirror below, on the Codex tab | +| OpenCode-based omo (oh-my-opencode) | its config under OpenCode | none; nothing here reads or writes it | + +`~/.omo` alone identifies none of them: Pi-based omo and LazyCodex both use it. + +### omo (Codex / LazyCodex) role models + +Separate from the `models.json` provider integration above, and only when `detectLazyCodex()` +reports LazyCodex installed, `src/clients/omo-role-models.ts` +mirrors a dashboard or `ocx agent roles set` pick into `codex.agents..model` of +`~/.omo/omo.jsonc`, which LazyCodex 5.1.1 and later reads. The home is resolved the way omo +resolves it: `HOME`, then `USERPROFILE`, then the OS home. This write has no ownership record, +snapshot, or journal. It changes one value the user just chose and leaves every other key as it +was, re-serialized with the file's indentation, line endings, and BOM. + +It never creates the file and never writes one it would damage: a missing file reports +`absent`, a document that is not an object or whose `codex`, `codex.agents`, or role entry is +not an object reports `invalid`, and a file containing any `//` or block comment reports +`skipped_comments`, because re-serializing JSONC would drop those comments. The management +response carries that status and the dashboard shows it; the role TOML write described in +[subagents](../subagents.md#per-role-model-pins) is not rolled back by a skipped mirror. +An explicit `null` in any of those three places counts as not an object. A file that exists but +cannot be read lists as `unreadable`, so the role table still loads, and a save reports +`write_failed` for the mirror. +Without LazyCodex, `GET /api/codex-agent-roles` answers `lazycodex.detected: false` with no roles +and without opening omo.jsonc, and `PUT` answers 409 `lazycodex_not_detected` before touching a +role file. + ## Kilo global JSONC Kilo owns only `provider.opencodex` in the first existing global file among `kilo.jsonc`, diff --git a/structure/subagents.md b/structure/subagents.md index fb66cfc95f6..0c477c5e12e 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -313,6 +313,37 @@ availability on a bounded interval (default 60 s, `src/codex/subagent-model-fall the requested model id only; effort remains owned by the caps described under [Ultra reasoning level](catalog.md#ultra-reasoning-level). +### Per-role model pins + +Codex overrides a child's spawn-time model with the root `model` key of +`$CODEX_HOME/agents/.toml`, so that pin decides which model a role runs on. +`src/codex/agent-role-models.ts` is opencodex's only writer into those files, and it writes only +that one key, only when a user picks a role's model in the dashboard's omo (Codex / LazyCodex) +section on the Codex tab (`PUT /api/codex-agent-roles/{role}`) or with `ocx agent roles set`, +and only while LazyCodex is detected. No sync, startup, or +catalog path calls it, and opencodex never creates, repairs, or removes a role file. + +- The key is located by the same TOML-aware scan the pin reader uses + (`locateTomlModelKey` in `src/codex/subagent-model-fallback.ts`), so a `model =` line inside + the instructions multiline string is never read or edited. A key under a table header is not + the root pin. +- An existing one-line string value is replaced inside its span; every other byte, including + quote style when the new value allows it, trailing comments, line endings, and a leading BOM, + is kept. A missing key is inserted after the leading comment block. A non-string value is + refused rather than duplicated. +- The original file and the edited result must both parse as TOML; otherwise the write is + refused with `invalid_role_file` and the file keeps its bytes, so an edit can never turn a + role Codex rejects into one it loads. Other write failures answer a fixed `write_failed` + message without the filesystem path or owner details. +- The role name must equal a listed `*.toml` stem, which is also the path-traversal check. The + target must be a regular file owned by the running user; the replacement is atomic and does + not follow a symbolic link. +- The same pick is mirrored into LazyCodex's `codex.agents..model`; that half belongs to + [client integrations](clients/integrations.md#omo-codex-lazycodex-role-models). The role file is written first + and stands even when the mirror is skipped. + +Sibling instances refuse the write, because it reaches the shared `CODEX_HOME`. + `injectionModel` and `injectionEffort` are shared selections with two independent consumers. `multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. `syncCodexSubagentDefaults` is a separate, default-off opt-in that applies the selected values to diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 460fb292c96..b252eea4430 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -526,6 +526,7 @@ describe("headless GUI parity CLI", () => { ["/api/combos", "ocx combo"], ["/api/client-config", "ocx export"], ["/api/client-integrations", "ocx integration client"], + ["/api/codex-agent-roles", "ocx agent roles"], // #2463: both read and write reach the CLI. `ocx alias list` reads /api/aliases, // `ocx alias defaults` writes /api/default-aliases, and the per-provider writes sit // under /api/providers/:name/alias, already covered by the /api/providers prefix. @@ -945,6 +946,21 @@ describe("headless GUI parity CLI", () => { ]); }); + test("agent roles reads and sets through the role-model routes, --json in any position", async () => { + const runtime = fakeRuntime(req => req.method === "GET" ? { lazycodex: { detected: true }, omoJsonc: { state: "absent" }, roles: [] } : undefined); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleAgentCommand(["roles", "--json"], runtime.deps)).toBe(0); + expect(await handleAgentCommand(["roles", "set", "ocx explorer", "xai/grok-4.5", "--json"], runtime.deps)).toBe(0); + } finally { + logSpy.mockRestore(); + } + expect(runtime.requests).toEqual([ + { path: "/api/codex-agent-roles", method: "GET", body: null }, + { path: "/api/codex-agent-roles/ocx%20explorer", method: "PUT", body: { model: "xai/grok-4.5" } }, + ]); + }); + test("API key create returns the one-time key through the access command", async () => { const runtime = fakeRuntime((req) => new URL(req.url).pathname === "/api/keys" ? { id: "key-1", name: "deploy", key: "ocx_secret" } diff --git a/tests/clients/lazycodex-detection.test.ts b/tests/clients/lazycodex-detection.test.ts new file mode 100644 index 00000000000..e51c7ca31dd --- /dev/null +++ b/tests/clients/lazycodex-detection.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectLazyCodex } from "../../src/clients/lazycodex"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ENABLED = '[plugins."omo@sisyphuslabs"]\nenabled = true\n'; +let root = ""; +let codexHome = ""; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-lazycodex-detect-")); + codexHome = join(root, "codex"); + mkdirSync(codexHome, { recursive: true }); +}); + +afterEach(() => removeTreeWithRetry(root)); + +function install(version = "5.1.1"): void { + const dir = join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", version); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "lazycodex-install.json"), '{ "version": "5.1.1" }'); +} + +describe("detectLazyCodex", () => { + test("needs the omo@sisyphuslabs Codex plugin both enabled and installed", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + install(); + expect(detectLazyCodex(codexHome)).toEqual({ detected: true, pluginEnabled: true, pluginInstalled: true }); + }); + + test("an enabled entry without an installed copy, or a copy that is disabled, is not LazyCodex", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: true, pluginInstalled: false }); + install(); + writeFileSync(join(codexHome, "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = false\n'); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: false, pluginInstalled: true }); + }); + + test("a plugin directory without the LazyCodex install receipt does not count", () => { + writeFileSync(join(codexHome, "config.toml"), ENABLED); + mkdirSync(join(codexHome, "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"), { recursive: true }); + expect(detectLazyCodex(codexHome).detected).toBe(false); + }); + + test("Pi-based and OpenCode-based omo footprints alone are not LazyCodex", () => { + const home = join(root, "home"); + mkdirSync(join(home, ".omo", "agent"), { recursive: true }); + writeFileSync(join(home, ".omo", "agent", "models.json"), "{}"); + writeFileSync(join(home, ".omo", "omo.jsonc"), '{ "codex": { "agents": {} } }'); + mkdirSync(join(home, ".config", "opencode"), { recursive: true }); + writeFileSync(join(home, ".config", "opencode", "oh-my-opencode.jsonc"), "{}"); + expect(detectLazyCodex(codexHome).detected).toBe(false); + expect(detectLazyCodex(join(root, "missing")).detected).toBe(false); + }); + + test("an unparsable config.toml reads as not enabled", () => { + writeFileSync(join(codexHome, "config.toml"), "[plugins\n"); + install(); + expect(detectLazyCodex(codexHome)).toEqual({ detected: false, pluginEnabled: false, pluginInstalled: true }); + }); +}); diff --git a/tests/clients/omo-role-models.test.ts b/tests/clients/omo-role-models.test.ts new file mode 100644 index 00000000000..2115ddaedc8 --- /dev/null +++ b/tests/clients/omo-role-models.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { omoJsoncPath, readOmoRoleModels, writeOmoRoleModel } from "../../src/clients/omo-role-models"; +import { hasJsoncComments } from "../../src/lib/jsonc"; + +let dir: string | null = null; +afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + dir = null; +}); + +function file(text?: string): string { + dir = mkdtempSync(join(tmpdir(), "ocx-omo-roles-")); + const path = join(dir, "omo.jsonc"); + if (text !== undefined) writeFileSync(path, text); + return path; +} + +describe("omo role models", () => { + test("writes codex.agents..model and keeps sibling keys and indentation", () => { + const path = file('{\n "agents": { "sisyphus": { "model": "a" } },\n "codex": { "agents": { "explorer": { "reasoningEffort": "high" } } }\n}\n'); + expect(writeOmoRoleModel("explorer", "gpt-5.6-sol", path)).toBe("written"); + const written = readFileSync(path, "utf8"); + expect(JSON.parse(written)).toEqual({ + agents: { sisyphus: { model: "a" } }, + codex: { agents: { explorer: { reasoningEffort: "high", model: "gpt-5.6-sol" } } }, + }); + expect(written.startsWith('{\n "agents"')).toBe(true); + expect(written.endsWith("}\n")).toBe(true); + expect(readOmoRoleModels(path)).toEqual({ state: "present", models: { explorer: "gpt-5.6-sol" } }); + expect(writeOmoRoleModel("explorer", "gpt-5.6-sol", path)).toBe("unchanged"); + }); + + test("creates the codex block in a file that has none", () => { + const path = file("{}"); + expect(writeOmoRoleModel("librarian", "m", path)).toBe("written"); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ codex: { agents: { librarian: { model: "m" } } } }); + }); + + test("a file with comments is reported and left byte for byte", () => { + const text = '{\n // pick carefully\n "codex": {}\n}\n'; + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("skipped_comments"); + expect(readOmoRoleModels(path)).toEqual({ state: "comments" }); + expect(readFileSync(path, "utf8")).toBe(text); + }); + + test("a missing file is reported absent and not created", () => { + const path = file(); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("absent"); + expect(readOmoRoleModels(path)).toEqual({ state: "absent" }); + expect(() => readFileSync(path)).toThrow(); + }); + + test("a codex value of the wrong shape is invalid rather than overwritten", () => { + const text = '{ "codex": { "agents": ["explorer"] } }'; + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("invalid"); + expect(readFileSync(path, "utf8")).toBe(text); + }); + + test("an explicit null codex, agents, or role entry is invalid and left byte for byte", () => { + for (const text of ['{ "codex": null }', '{ "codex": { "agents": null } }', '{ "codex": { "agents": { "explorer": null } } }']) { + const path = file(text); + expect(writeOmoRoleModel("explorer", "m", path)).toBe("invalid"); + expect(readFileSync(path, "utf8")).toBe(text); + rmSync(dir!, { recursive: true, force: true }); + } + }); + + test("an unreadable file reads as unreadable, and the write still reports the failure", () => { + const path = file('{ "codex": {} }'); + const denied = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + const spy = spyOn(fs, "readFileSync").mockImplementation((() => { throw denied; }) as never); + try { + expect(readOmoRoleModels(path)).toEqual({ state: "unreadable" }); + expect(() => writeOmoRoleModel("explorer", "m", path)).toThrow("EACCES"); + } finally { + spy.mockRestore(); + } + }); + + test("comment detection ignores comment markers inside strings", () => { + expect(hasJsoncComments('{ "url": "https://example.com/*x*/" }')).toBe(false); + expect(hasJsoncComments('{ "a": 1 /* note */ }')).toBe(true); + expect(hasJsoncComments('{ "a": 1 /* open')).toBe(true); + }); + + test("the path follows HOME before USERPROFILE", () => { + expect(omoJsoncPath({ HOME: "/h", USERPROFILE: "/u" }, "/os")).toBe(join("/h", ".omo", "omo.jsonc")); + expect(omoJsoncPath({ USERPROFILE: "/u" }, "/os")).toBe(join("/u", ".omo", "omo.jsonc")); + expect(omoJsoncPath({}, "/os")).toBe(join("/os", ".omo", "omo.jsonc")); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c66dbf5ece8..4303c879bac 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1691,6 +1691,10 @@ "subagent-fallback-preview-sites.test.ts": "routing", "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", + "codex-agent-role-models.test.ts": "routing", + "omo-role-models.test.ts": "clients", + "lazycodex-detection.test.ts": "clients", + "codex-agent-role-routes.test.ts": "server", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/tests/routing/codex-agent-role-models.test.ts b/tests/routing/codex-agent-role-models.test.ts new file mode 100644 index 00000000000..f8cfca6f347 --- /dev/null +++ b/tests/routing/codex-agent-role-models.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + AgentRoleModelError, + listCodexAgentRoleModels, + setTomlRootModel, + writeCodexAgentRoleModel, +} from "../../src/codex/agent-role-models"; + +const IMPORTED_ROLE = [ + "# imported by Codex", + 'name = "explorer"', + 'developer_instructions = """', + "Never trust a line like", + 'model = "x"', + "inside prose.", + '"""', + 'model = "gpt-5.5" # pinned', + 'model_reasoning_effort = "high"', + "", +].join("\n"); + +describe("setTomlRootModel", () => { + test("replaces only the root value, leaving the instructions string untouched", () => { + const next = setTomlRootModel(IMPORTED_ROLE, "anthropic/claude-sonnet-5"); + expect(next).toBe(IMPORTED_ROLE.replace('model = "gpt-5.5" # pinned', 'model = "anthropic/claude-sonnet-5" # pinned')); + }); + + test("inserts a missing key after the leading comments, with the file's CRLF", () => { + const unpinned = IMPORTED_ROLE.replace('model = "gpt-5.5" # pinned\n', "").replace(/\n/g, "\r\n"); + const next = setTomlRootModel(unpinned, "gpt-5.6-sol"); + expect(next).toBe(unpinned.replace("# imported by Codex\r\n", '# imported by Codex\r\nmodel = "gpt-5.6-sol"\r\n')); + expect(setTomlRootModel(next, "gpt-5.6-sol")).toBe(next); + }); + + test("keeps a literal-string pin literal and preserves spacing", () => { + expect(setTomlRootModel("model = 'a' \n", "b")).toBe("model = 'b' \n"); + expect(setTomlRootModel("model = 'a'\n", "it's")).toBe('model = "it\'s"\n'); + }); + + test("a model key under a table is not the root pin", () => { + const tabled = '[profile]\nmodel = "inner"\n'; + expect(setTomlRootModel(tabled, "outer")).toBe(`model = "outer"\n${tabled}`); + }); + + test("a leading BOM stays at byte 0", () => { + expect(setTomlRootModel('\ufeffname = "r"\n', "m")).toBe('\ufeffmodel = "m"\nname = "r"\n'); + }); + + test("a non-string model value is refused rather than duplicated", () => { + expect(() => setTomlRootModel("model = 5\n", "m")).toThrow(AgentRoleModelError); + }); + + test("an escaped quoted model key is replaced rather than duplicated", () => { + const escaped = 'name = "r"\n"mod\\u0065l" = "old" # pinned\nkeep = 1\n'; + const next = setTomlRootModel(escaped, "new"); + expect(next).toBe('name = "r"\n"mod\\u0065l" = "new" # pinned\nkeep = 1\n'); + expect(Bun.TOML.parse(next)).toEqual({ name: "r", model: "new", keep: 1 }); + expect(setTomlRootModel("'model' = 'old'\n", "new")).toBe("'model' = 'new'\n"); + }); + + test("a quoted root key that cannot be decoded refuses the write", () => { + expect(() => setTomlRootModel('"mo\\x64el" = "old"\n', "new")).toThrow(AgentRoleModelError); + }); + + test("a multiline model value closing on extra quotes is replaced whole", () => { + for (const pinned of ['model = """old""""\nkeep = 1\n', "model = '''old''''\nkeep = 1\n", 'model = """old"""""\nkeep = 1\n']) { + const next = setTomlRootModel(pinned, "new"); + expect(next).toBe(pinned.startsWith("model = '") ? "model = 'new'\nkeep = 1\n" : 'model = "new"\nkeep = 1\n'); + expect(Bun.TOML.parse(next)).toEqual({ model: "new", keep: 1 }); + } + }); +}); + +describe("writeCodexAgentRoleModel", () => { + let home: string | null = null; + afterEach(() => { + if (home) rmSync(home, { recursive: true, force: true }); + home = null; + }); + + function codexHome(): string { + home = mkdtempSync(join(tmpdir(), "ocx-agent-roles-")); + mkdirSync(join(home, "agents")); + writeFileSync(join(home, "agents", "explorer.toml"), IMPORTED_ROLE); + writeFileSync(join(home, "outside.toml"), 'model = "keep"\n'); + return home; + } + + test("writes the pin and reports it back through the listing", () => { + const dir = codexHome(); + expect(writeCodexAgentRoleModel("explorer", " xai/grok-4.5 ", dir)).toEqual({ status: "written" }); + expect(listCodexAgentRoleModels(dir)).toEqual([{ role: "explorer", model: "xai/grok-4.5" }]); + expect(writeCodexAgentRoleModel("explorer", "xai/grok-4.5", dir)).toEqual({ status: "unchanged" }); + }); + + test("refuses a role that is not a listed file, including traversal", () => { + const dir = codexHome(); + for (const role of ["../outside", "missing", "agents/explorer"]) { + expect(() => writeCodexAgentRoleModel(role, "m", dir)).toThrow(AgentRoleModelError); + } + expect(readFileSync(join(dir, "outside.toml"), "utf8")).toBe('model = "keep"\n'); + }); + + test("refuses an empty or multi-line model without touching the file", () => { + const dir = codexHome(); + for (const model of ["", "a\nb", 5]) { + expect(() => writeCodexAgentRoleModel("explorer", model as string, dir)).toThrow(AgentRoleModelError); + } + expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(IMPORTED_ROLE); + }); + + test("refuses a role file that is not valid TOML and leaves its bytes alone", () => { + const dir = codexHome(); + const broken = 'name = "explorer"\nmodel = "old\\q"\n'; + writeFileSync(join(dir, "agents", "explorer.toml"), broken); + let caught: unknown; + try { + writeCodexAgentRoleModel("explorer", "new", dir); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AgentRoleModelError); + expect((caught as AgentRoleModelError).code).toBe("invalid_role_file"); + expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(broken); + }); +}); diff --git a/tests/server/codex-agent-role-routes.test.ts b/tests/server/codex-agent-role-routes.test.ts new file mode 100644 index 00000000000..f0420bcb9e5 --- /dev/null +++ b/tests/server/codex-agent-role-routes.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const ROLE = 'name = "explorer"\ndeveloper_instructions = """\nmodel = "x"\n"""\nmodel = "gpt-5.5"\n'; +const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +let root = ""; + +function restore(name: keyof typeof saved): void { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-agent-role-routes-")); + mkdirSync(join(root, "codex", "agents"), { recursive: true }); + mkdirSync(join(root, "home", ".omo"), { recursive: true }); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), ROLE); + installLazyCodex(); + process.env.CODEX_HOME = join(root, "codex"); + process.env.HOME = join(root, "home"); + process.env.USERPROFILE = join(root, "home"); +}); + +afterEach(() => { + restore("CODEX_HOME"); + restore("HOME"); + restore("USERPROFILE"); + removeTreeWithRetry(root); +}); + +const config = { port: 10100, providers: {}, defaultProvider: "openai" } as unknown as OcxConfig; +const DETECTED = { detected: true, pluginEnabled: true, pluginInstalled: true }; + +function installLazyCodex(): void { + const plugin = join(root, "codex", "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(root, "codex", "config.toml"), '[plugins."omo@sisyphuslabs"]\nenabled = true\n'); +} + +async function call(path: string, init?: RequestInit): Promise<{ status: number; body: Record }> { + const response = await handleManagementAPI(new Request(`http://localhost${path}`, init), new URL(`http://localhost${path}`), config); + expect(response).not.toBeNull(); + return { status: response!.status, body: await response!.json() as Record }; +} + +function put(role: string, model: unknown) { + return call(`/api/codex-agent-roles/${role}`, { method: "PUT", body: JSON.stringify({ model }) }); +} + +describe("/api/codex-agent-roles", () => { + test("round-trips a role model through the TOML and omo.jsonc", async () => { + writeFileSync(join(root, "home", ".omo", "omo.jsonc"), '{ "codex": {} }\n'); + expect((await call("/api/codex-agent-roles")).body).toEqual({ + lazycodex: DETECTED, + omoJsonc: { state: "present" }, + roles: [{ role: "explorer", model: "gpt-5.5", omoJsoncModel: null }], + }); + const saved = await put("explorer", "xai/grok-4.5"); + expect(saved.status).toBe(200); + expect(saved.body).toEqual({ ok: true, role: "explorer", model: "xai/grok-4.5", toml: { status: "written" }, omoJsonc: { status: "written" } }); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE.replace('model = "gpt-5.5"', 'model = "xai/grok-4.5"')); + expect((await call("/api/codex-agent-roles")).body.roles).toEqual([{ role: "explorer", model: "xai/grok-4.5", omoJsoncModel: "xai/grok-4.5" }]); + }); + + test("without LazyCodex it lists nothing, writes nothing, and never opens omo.jsonc", async () => { + writeFileSync(join(root, "codex", "config.toml"), ""); + const omoPath = join(root, "home", ".omo", "omo.jsonc"); + writeFileSync(omoPath, '{ "codex": {} }\n'); + const nativeRead = fs.readFileSync; + const reads: string[] = []; + const spy = spyOn(fs, "readFileSync").mockImplementation(((path: fs.PathOrFileDescriptor, options?: unknown) => { + reads.push(String(path)); + return nativeRead(path, options as BufferEncoding); + }) as never); + try { + expect((await call("/api/codex-agent-roles")).body).toEqual({ + lazycodex: { detected: false, pluginEnabled: false, pluginInstalled: true }, + omoJsonc: null, + roles: [], + }); + const refused = await put("explorer", "m"); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe("lazycodex_not_detected"); + } finally { + spy.mockRestore(); + } + expect(reads).not.toContain(omoPath); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + expect(readFileSync(omoPath, "utf8")).toBe('{ "codex": {} }\n'); + }); + + test("reports an absent or commented omo.jsonc without writing it", async () => { + expect((await put("explorer", "m1")).body.omoJsonc).toEqual({ status: "absent" }); + const commented = '{ // mine\n "codex": {} }\n'; + writeFileSync(join(root, "home", ".omo", "omo.jsonc"), commented); + expect((await call("/api/codex-agent-roles")).body.omoJsonc).toEqual({ state: "comments" }); + const result = await put("explorer", "m2"); + expect(result.body.toml).toEqual({ status: "written" }); + expect(result.body.omoJsonc).toEqual({ status: "skipped_comments" }); + expect(readFileSync(join(root, "home", ".omo", "omo.jsonc"), "utf8")).toBe(commented); + }); + + test("rejects unknown roles, traversal, and bad models", async () => { + expect((await put("missing", "m")).status).toBe(404); + expect((await put("..%2Fconfig", "m")).status).toBe(404); + expect((await put("%E0%A4%A", "m")).status).toBe(400); + expect((await put("explorer", "")).status).toBe(400); + expect((await put("explorer", 7)).status).toBe(400); + expect(readFileSync(join(root, "codex", "agents", "explorer.toml"), "utf8")).toBe(ROLE); + }); + + test("an unreadable omo.jsonc still lists the roles", async () => { + const omoPath = join(root, "home", ".omo", "omo.jsonc"); + writeFileSync(omoPath, '{ "codex": {} }\n'); + const nativeRead = fs.readFileSync; + const spy = spyOn(fs, "readFileSync").mockImplementation(((path: fs.PathOrFileDescriptor, options?: unknown) => { + if (path === omoPath) throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + return nativeRead(path, options as BufferEncoding); + }) as never); + try { + const listed = await call("/api/codex-agent-roles"); + expect(listed.status).toBe(200); + expect(listed.body).toEqual({ + lazycodex: DETECTED, + omoJsonc: { state: "unreadable" }, + roles: [{ role: "explorer", model: "gpt-5.5", omoJsoncModel: null }], + }); + const saved = await put("explorer", "m3"); + expect(saved.body.toml).toEqual({ status: "written" }); + expect(saved.body.omoJsonc).toEqual({ status: "write_failed" }); + } finally { + spy.mockRestore(); + } + }); + + test("refuses an invalid role file with 409 and keeps it unchanged", async () => { + const rolePath = join(root, "codex", "agents", "explorer.toml"); + const broken = 'name = "explorer"\nmodel = "old\\q"\n'; + writeFileSync(rolePath, broken); + const refused = await put("explorer", "m4"); + expect(refused.status).toBe(409); + expect(refused.body.code).toBe("invalid_role_file"); + expect(readFileSync(rolePath, "utf8")).toBe(broken); + }); + + test("a filesystem failure is reported without its path", async () => { + const rolePath = join(root, "codex", "agents", "explorer.toml"); + const nativeRead = fs.readFileSync; + const spy = spyOn(fs, "readFileSync").mockImplementation(((path: fs.PathOrFileDescriptor, options?: unknown) => { + if (path === rolePath) throw Object.assign(new Error(`EACCES: permission denied, open '${rolePath}'`), { code: "EACCES" }); + return nativeRead(path, options as BufferEncoding); + }) as never); + try { + const failed = await put("explorer", "m5"); + expect(failed.status).toBe(500); + expect(failed.body).toEqual({ error: "could not write the role file", code: "write_failed" }); + expect(JSON.stringify(failed.body)).not.toContain(root); + } finally { + spy.mockRestore(); + } + }); +}); From da13a027275ca6bc345f2daa8897ee38451bc4c6 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 21:36:34 +0900 Subject: [PATCH 04/61] feat(codex): auto-assign LazyCodex role models by sizing each role (carry #6269) (#6367) Carries #6269 by @LilMGenius: Auto-assign for omo (Codex / LazyCodex) agent roles. One sizing call asks a model the user already has to sort each role into a capability tier and an effort level, never naming a model. A deterministic mapper then turns each tier into a concrete model and reasoning effort. POST /api/codex-agent-roles/auto-assign only proposes changes. Applying a proposal goes through the existing PUT. The CLI form is ocx agent roles suggest [--apply]. Maintainer follow-ups: sizing failures now return only a fixed category or HTTP status, the effort writer keeps the role-TOML validation from #6366, and two React Doctor warnings are fixed. Co-authored-by: LilMGenius --- .../020_wp2_role_auto_assign.md | 6 + .../src/content/docs/guides/integrations.md | 30 +++ .../src/content/docs/reference/cli/agents.md | 6 + .../docs/reference/configuration/agents.md | 1 + gui/src/i18n/de.ts | 26 +++ gui/src/i18n/en.ts | 26 +++ gui/src/i18n/fr.ts | 26 +++ gui/src/i18n/ja.ts | 26 +++ gui/src/i18n/ko.ts | 26 +++ gui/src/i18n/ru.ts | 26 +++ gui/src/i18n/tr.ts | 26 +++ gui/src/i18n/vi.ts | 26 +++ gui/src/i18n/zh-TW.ts | 26 +++ gui/src/i18n/zh.ts | 26 +++ .../integrations/LazyCodexRoleAutoAssign.tsx | 179 ++++++++++++++++ .../integrations/LazyCodexRoleModels.tsx | 12 +- gui/src/styles/lazycodex-role-models.css | 14 ++ gui/tests/lazycodex-role-auto-assign.test.tsx | 154 +++++++++++++ scripts/test-layout/layout.json | 4 + .../ocx/references/01_management_surface.md | 10 +- src/cli/agent.ts | 71 +++++- src/cli/capabilities.ts | 12 +- src/codex/agent-role-models.ts | 61 ++++-- src/codex/role-auto-assign.ts | 156 ++++++++++++++ src/codex/role-sizing.ts | 202 ++++++++++++++++++ src/codex/subagent-model-fallback.ts | 13 +- src/codex/subagent-selectable-models.ts | 32 +++ src/config/schema/config-schema.ts | 6 + src/lib/local-chat-completion.ts | 144 +++++++++++++ .../management/agent-settings-routes.ts | 21 +- .../management/codex-agent-role-routes.ts | 60 +++++- .../management/codex-role-auto-assign.ts | 149 +++++++++++++ src/server/management/context.ts | 2 + src/server/management/route-registry.ts | 1 + src/types/config.ts | 5 + src/vision/routed-describe.ts | 150 +++---------- structure/subagents.md | 33 +++ tests/cli/cli-headless-parity.test.ts | 104 +++++++++ tests/fixtures/test-layout-expected.json | 4 + tests/lib/local-chat-completion.test.ts | 80 +++++++ tests/routing/codex-agent-role-models.test.ts | 37 ++++ tests/routing/codex-role-auto-assign.test.ts | 130 +++++++++++ tests/routing/codex-role-sizing.test.ts | 107 ++++++++++ .../codex-role-auto-assign-routes.test.ts | 156 ++++++++++++++ 44 files changed, 2239 insertions(+), 173 deletions(-) create mode 100644 gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx create mode 100644 gui/tests/lazycodex-role-auto-assign.test.tsx create mode 100644 src/codex/role-auto-assign.ts create mode 100644 src/codex/role-sizing.ts create mode 100644 src/codex/subagent-selectable-models.ts create mode 100644 src/lib/local-chat-completion.ts create mode 100644 src/server/management/codex-role-auto-assign.ts create mode 100644 tests/lib/local-chat-completion.test.ts create mode 100644 tests/routing/codex-role-auto-assign.test.ts create mode 100644 tests/routing/codex-role-sizing.test.ts create mode 100644 tests/server/codex-role-auto-assign-routes.test.ts diff --git a/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md b/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md index 2964ac84381..406e266fa58 100644 --- a/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md +++ b/devlog/_plan/261001_omo_lazycodex_carry/020_wp2_role_auto_assign.md @@ -7,6 +7,12 @@ Source: LilMGenius:feat/codex-role-auto-assign head `6c97ca9a4f`, layer `ada7ec1 1. After wp1 merges: `git fetch origin dev && git switch -c codex/omo-lazycodex-role-auto-assign origin/dev`. 2. Assert `git rev-parse refs/omo/6269` = `6c97ca9a4fdec6c59901fa115be341d9cc590a15` and the wp1 squash SHA is an ancestor of `origin/dev`, then `git cherry-pick ada7ec14b1b089f461e43e208d54750591fd26f9..6c97ca9a4fdec6c59901fa115be341d9cc590a15` (gates 1–3), reconciling in particular `src/server/management/agent-settings-routes.ts` (dev native picker-order acceptance), `src/config/schema/config-schema.ts` and `src/types/config.ts` (dev Codex-credit additions). 3. Line-count check for uncapped files near 2,000 (`agent-settings-routes.ts`). + +**Re-verification at P (2026-10-01, dev `a6114b62ed`).** wp1 landed as #6366 with a maintainer security fix (`70f696593c`): `writeCodexAgentRoleModel` validates the role TOML before and after the edit (`invalid_role_file`), and the route answers a fixed `write_failed` message. A probe replay showed the second L2 commit `a0e3ac8d39` (role `model_reasoning_effort` write) conflicts with that fix in `src/codex/agent-role-models.ts` and `tests/routing/codex-agent-role-models.test.ts`. Resolution (gate 3, union, no behavior dropped from either side): +- `AgentRoleModelErrorCode` keeps `invalid_effort` and `invalid_role_file` (plus the existing codes). +- `writeCodexAgentRoleModel`: `assertValidRoleToml(role, before, "before")` → `withModel = setTomlRootModel(...)` → `after = effort === undefined ? withModel : setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort))` → unchanged check → `assertValidRoleToml(role, after, "after")` → write. +- Tests: keep the invalid-TOML refusal test and both new effort tests. +Later L2 commits are replayed after this resolution; any further conflict in these two files follows the same union rule. Because the effort path now also passes the after-validation, gate 7 re-review covers the effort writer. 4. PR `feat(codex): auto-assign LazyCodex role models by sizing each role (carry #6269)`; same template/credit/security/integration records; screenshots from #6269 body. 5. Exact-head CI, squash merge, resolve threads. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 95926d210fb..b4488b926d8 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -555,6 +555,36 @@ ocx agent roles ocx agent roles set explorer xai/grok-4.5 ``` +### Auto-assign + +Auto-assign is part of omo (Codex / LazyCodex): it sits above the role table in that section and +exists only while LazyCodex is detected. Without it the dashboard shows neither, the API answers +409 `lazycodex_not_detected`, and `ocx agent roles suggest` is refused. + +Auto-assign proposes a model for every role at once. opencodex asks your +default Codex model (the root `model` in Codex `config.toml`) one question: for each role, given its +description and the start of its instructions, which capability tier (fast, standard or frontier) +and how much reasoning (glance, measured, thorough or exhaustive) does it need? That model never +picks a model. opencodex then picks the cheapest model from your picker list that reaches the tier: + +- Models listed under `codexRoleTiers` in the opencodex config (`{ "fast": [...], "standard": [...], "frontier": [...] }`) + have that tier. +- Other models with a known price are ranked by price and split evenly across the three tiers. With only + one or two priced models, the dearest is frontier and the other, if any, is standard. +- Models with no price and no listed tier are never proposed. List them to include them. + +Each proposal shows the model, the tier, the reasoning effort, a one-line reason, and what would move +it up or down. A role the model could not size clearly is shown as not sized, with the reason, and +cannot be applied. Nothing is written until you press Apply on a row or Apply all. Applying uses the +same save as picking by hand, and also rewrites the role's `model_reasoning_effort` when the file already has +one. The effort is placed on the chosen model's own levels: its lowest, its default, one above the +default, or its highest. + +```bash +ocx agent roles suggest +ocx agent roles suggest --model xai/grok-4.5 --apply +``` + ## Kilo Kilo CLI, VS Code, and JetBrains share one global config. This integration writes diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 0f14578987d..3473fc53b48 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -28,6 +28,12 @@ and the command says so. See [omo (Codex / LazyCodex) role models](/guides/integ ocx agent roles set explorer xai/grok-4.5 ``` +`ocx agent roles suggest` is omo (Codex / LazyCodex) only, refused like `set` when LazyCodex is not +installed. It sizes every role with one call to the default Codex model (or `--model`) and +prints a proposed model and effort per role without writing anything. `--apply` writes every proposal +through the same write as `set`, skipping and naming the roles whose model and effort already match. +See [Auto-assign](/guides/integrations/#auto-assign). + `ocx agent sidecar web --list` and `ocx agent sidecar vision --list` print the models the server currently offers for each sidecar — the exact filtered set the dashboard picker shows (picker-visible rows plus the login-entitled Luna/Haiku auth slots, intersected with executor diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 0680b278d31..ed106c598e0 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -56,6 +56,7 @@ still depends on upstream support for your account. | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | | `subagentModelFallbackByModel?` | `Record` | `{}` | Per-primary-model fallback chains, keyed by the requested primary model id. This is the supported home for per-role fallback metadata; `model_fallback` inside Codex agent TOML makes Codex 0.146+ skip the role (#1190). | | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | +| `codexRoleTiers?` | `{ fast?, standard?, frontier?: string[] }` | — | Capability tier of named models for [role auto-assign](/guides/integrations/#auto-assign). Listed models take that tier; other models are tiered by price, and unpriced unlisted models are never proposed. | | `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | | `plaintextV2AgentMessages?` | `boolean` | — (unset) | Experimental opt-in. It runs only when explicitly set to `true` and asks eligible native ChatGPT v2 parents to emit `spawn_agent`, `send_message`, and `followup_task` message arguments as plaintext. See [Plaintext v2 agent messages](#plaintext-v2-agent-messages). | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 92b18c06e88..2c8b06eb11e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1684,6 +1684,32 @@ export const de: Record = { "integrations.lazycodexRoles.omoFailed": "{role} läuft laut Rollendatei jetzt mit {model}, aber omo.jsonc konnte nicht geschrieben werden.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc erneut versuchen", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc enthält Kommentare, daher ändert Speichern hier nur die Rollendateien. Passen Sie codex.agents in omo.jsonc von Hand an, damit beide übereinstimmen.", + "integrations.lazycodexRoles.auto.button": "Automatisch zuweisen", + "integrations.lazycodexRoles.auto.running": "Rollen werden eingestuft…", + "integrations.lazycodexRoles.auto.hint": "Jede Rolle mit deinem Standardmodell einstufen und für jede das günstigste ausreichende Modell vorschlagen. Nichts ändert sich, bevor du übernimmst.", + "integrations.lazycodexRoles.auto.failed": "Die Codex-Agentenrollen konnten nicht eingestuft werden.", + "integrations.lazycodexRoles.auto.title": "Vorgeschlagene Modelle", + "integrations.lazycodexRoles.auto.sizedWith": "Eingestuft mit {model}", + "integrations.lazycodexRoles.auto.applyAll": "Alle übernehmen", + "integrations.lazycodexRoles.auto.discard": "Verwerfen", + "integrations.lazycodexRoles.auto.sizingFailed": "Die Einstufung ist fehlgeschlagen: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "{count} von {total} Vorschlägen übernommen.", + "integrations.lazycodexRoles.auto.proposalFor": "Vorschlag für {role}", + "integrations.lazycodexRoles.auto.tierEffort": "Stufe {tier}, Aufwand {effort}", + "integrations.lazycodexRoles.auto.tierFast": "Schnell", + "integrations.lazycodexRoles.auto.tierStandard": "Standard", + "integrations.lazycodexRoles.auto.tierFrontier": "Spitze", + "integrations.lazycodexRoles.auto.effortGlance": "kurz", + "integrations.lazycodexRoles.auto.effortMeasured": "gemessen", + "integrations.lazycodexRoles.auto.effortThorough": "gründlich", + "integrations.lazycodexRoles.auto.effortExhaustive": "erschöpfend", + "integrations.lazycodexRoles.auto.unsized": "Nicht eingestuft: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "Kein Modell: {reason}", + "integrations.lazycodexRoles.auto.applied": "Übernommen", + "integrations.lazycodexRoles.auto.alreadySet": "Bereits gesetzt", + "integrations.lazycodexRoles.auto.apply": "Übernehmen", + "integrations.lazycodexRoles.auto.moveUp": "Höher, wenn: {text}", + "integrations.lazycodexRoles.auto.moveDown": "Niedriger, wenn: {text}", "integrations.semantics.cline": "Verwaltet OpenCodex in providers.json und models.json der Cline CLI. Beenden Sie Cline vor Änderungen oder Synchronisierung und starten Sie es danach neu. Rückgängig stellt beide Originaldateien wieder her. Ihr Standardanbieter bleibt unverändert; wählen Sie OpenCodex in Cline.", "integrations.semantics.kilo": "Verwaltet nur provider.opencodex in Kilos globaler Konfiguration — die erste vorhandene Datei unter kilo.jsonc, kilo.json, opencode.jsonc, opencode.json oder config.json in ~/.config/kilo (XDG_CONFIG_HOME verschiebt dieses Verzeichnis; ist keine vorhanden, wird kilo.jsonc erstellt). Andere Schlüssel bleiben unverändert. Anwenden schreibt die ganze Datei neu, daher bleiben Kommentare und nachgestellte Kommas nicht erhalten. Wählen Sie opencodex/ in Kilo.", "integrations.semantics.droid": "Fügt Factory Droid in settings.json OpenCodex-Modelle hinzu. Deaktivieren entfernt nur verwaltete Einträge; Rückgängig stellt die gespeicherte Datei wieder her.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 6427b7e277b..3169e58de48 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2287,6 +2287,32 @@ export const en = { "integrations.lazycodexRoles.omoFailed": "{role} now runs on {model} in its role file, but omo.jsonc could not be written.", "integrations.lazycodexRoles.retryMirror": "Retry omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contains comments, so saving here changes only the role files. Edit codex.agents in omo.jsonc by hand to keep it in step.", + "integrations.lazycodexRoles.auto.button": "Auto-assign", + "integrations.lazycodexRoles.auto.running": "Sizing roles…", + "integrations.lazycodexRoles.auto.hint": "Size every role with your default model and propose the cheapest sufficient model for each. Nothing changes until you apply.", + "integrations.lazycodexRoles.auto.failed": "Could not size the Codex agent roles.", + "integrations.lazycodexRoles.auto.title": "Proposed models", + "integrations.lazycodexRoles.auto.sizedWith": "Sized with {model}", + "integrations.lazycodexRoles.auto.applyAll": "Apply all", + "integrations.lazycodexRoles.auto.discard": "Discard", + "integrations.lazycodexRoles.auto.sizingFailed": "The sizing call failed: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "Applied {count} of {total} proposals.", + "integrations.lazycodexRoles.auto.proposalFor": "Proposal for {role}", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} tier, {effort} effort", + "integrations.lazycodexRoles.auto.tierFast": "Fast", + "integrations.lazycodexRoles.auto.tierStandard": "Standard", + "integrations.lazycodexRoles.auto.tierFrontier": "Frontier", + "integrations.lazycodexRoles.auto.effortGlance": "glance", + "integrations.lazycodexRoles.auto.effortMeasured": "measured", + "integrations.lazycodexRoles.auto.effortThorough": "thorough", + "integrations.lazycodexRoles.auto.effortExhaustive": "exhaustive", + "integrations.lazycodexRoles.auto.unsized": "Not sized: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "No model: {reason}", + "integrations.lazycodexRoles.auto.applied": "Applied", + "integrations.lazycodexRoles.auto.alreadySet": "Already set", + "integrations.lazycodexRoles.auto.apply": "Apply", + "integrations.lazycodexRoles.auto.moveUp": "Move up if: {text}", + "integrations.lazycodexRoles.auto.moveDown": "Move down if: {text}", "integrations.semantics.cline": "Manages OpenCodex in Cline CLI providers.json and models.json. Stop Cline before changing or syncing these files, then restart. Undo restores both originals. Your default provider stays unchanged; select OpenCodex in Cline.", "integrations.semantics.kilo": "Manages only provider.opencodex in Kilo's global config — the first existing file among kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, or config.json under ~/.config/kilo (XDG_CONFIG_HOME relocates that directory; kilo.jsonc is created when none exist). Other keys stay unchanged. Apply rewrites the whole file, so comments and trailing commas are not preserved. Select opencodex/ in Kilo.", "integrations.semantics.droid": "Adds OpenCodex custom models to Factory Droid settings.json. Disable removes only managed rows; undo restores the saved file.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index fb37e358d76..18e15f8cf24 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2214,6 +2214,32 @@ export const fr: Record = { "integrations.lazycodexRoles.omoFailed": "{role} utilise désormais {model} dans son fichier de rôle, mais omo.jsonc n’a pas pu être écrit.", "integrations.lazycodexRoles.retryMirror": "Réessayer omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc contient des commentaires : l’enregistrement ici ne modifie donc que les fichiers de rôle. Modifiez codex.agents dans omo.jsonc à la main pour rester synchronisé.", + "integrations.lazycodexRoles.auto.button": "Attribution auto", + "integrations.lazycodexRoles.auto.running": "Évaluation des rôles…", + "integrations.lazycodexRoles.auto.hint": "Évaluer chaque rôle avec votre modèle par défaut et proposer pour chacun le modèle suffisant le moins cher. Rien ne change avant que vous appliquiez.", + "integrations.lazycodexRoles.auto.failed": "Impossible d'évaluer les rôles d'agent Codex.", + "integrations.lazycodexRoles.auto.title": "Modèles proposés", + "integrations.lazycodexRoles.auto.sizedWith": "Évalué avec {model}", + "integrations.lazycodexRoles.auto.applyAll": "Tout appliquer", + "integrations.lazycodexRoles.auto.discard": "Ignorer", + "integrations.lazycodexRoles.auto.sizingFailed": "L'évaluation a échoué : {error}", + "integrations.lazycodexRoles.auto.appliedCount": "{count} propositions appliquées sur {total}.", + "integrations.lazycodexRoles.auto.proposalFor": "Proposition pour {role}", + "integrations.lazycodexRoles.auto.tierEffort": "Niveau {tier}, effort {effort}", + "integrations.lazycodexRoles.auto.tierFast": "Rapide", + "integrations.lazycodexRoles.auto.tierStandard": "Courant", + "integrations.lazycodexRoles.auto.tierFrontier": "Pointe", + "integrations.lazycodexRoles.auto.effortGlance": "bref", + "integrations.lazycodexRoles.auto.effortMeasured": "mesuré", + "integrations.lazycodexRoles.auto.effortThorough": "approfondi", + "integrations.lazycodexRoles.auto.effortExhaustive": "exhaustif", + "integrations.lazycodexRoles.auto.unsized": "Non évalué : {reason}", + "integrations.lazycodexRoles.auto.unassigned": "Aucun modèle : {reason}", + "integrations.lazycodexRoles.auto.applied": "Appliqué", + "integrations.lazycodexRoles.auto.alreadySet": "Déjà appliqué", + "integrations.lazycodexRoles.auto.apply": "Appliquer", + "integrations.lazycodexRoles.auto.moveUp": "Monter si : {text}", + "integrations.lazycodexRoles.auto.moveDown": "Descendre si : {text}", "integrations.semantics.cline": "Gère OpenCodex dans providers.json et models.json de Cline CLI. Quittez Cline avant toute modification ou synchronisation, puis redémarrez-le. Annuler restaure les deux fichiers originaux. Votre fournisseur par défaut reste inchangé ; sélectionnez OpenCodex dans Cline.", "integrations.semantics.kilo": "Gère uniquement provider.opencodex dans la configuration globale de Kilo — le premier fichier existant parmi kilo.jsonc, kilo.json, opencode.jsonc, opencode.json ou config.json sous ~/.config/kilo (XDG_CONFIG_HOME déplace ce répertoire ; kilo.jsonc est créé si aucun n'existe). Les autres clés restent inchangées. Appliquer réécrit tout le fichier : commentaires et virgules finales ne sont pas conservés. Sélectionnez opencodex/ dans Kilo.", "integrations.semantics.droid": "Ajoute les modèles personnalisés OpenCodex au settings.json de Factory Droid. Désactiver retire seulement les entrées gérées ; Annuler restaure le fichier sauvegardé.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 754e1a98389..2943a80baf3 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2117,6 +2117,32 @@ export const ja: Record = { "integrations.lazycodexRoles.omoFailed": "ロールファイルでは {role} が {model} で動作しますが、omo.jsonc に書き込めませんでした。", "integrations.lazycodexRoles.retryMirror": "omo.jsonc を再試行", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc にコメントがあるため、ここで保存するとロールファイルだけが変わります。omo.jsonc の codex.agents は手動で合わせてください。", + "integrations.lazycodexRoles.auto.button": "自動割り当て", + "integrations.lazycodexRoles.auto.running": "ロールを評価中…", + "integrations.lazycodexRoles.auto.hint": "既定のモデルで各ロールを評価し、それぞれに十分で最も安いモデルを提案します。適用するまで何も変わりません。", + "integrations.lazycodexRoles.auto.failed": "Codex エージェントロールを評価できませんでした。", + "integrations.lazycodexRoles.auto.title": "提案されたモデル", + "integrations.lazycodexRoles.auto.sizedWith": "{model} で評価", + "integrations.lazycodexRoles.auto.applyAll": "すべて適用", + "integrations.lazycodexRoles.auto.discard": "破棄", + "integrations.lazycodexRoles.auto.sizingFailed": "評価の呼び出しに失敗しました: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "{total} 件中 {count} 件の提案を適用しました。", + "integrations.lazycodexRoles.auto.proposalFor": "{role} への提案", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} 層、{effort} の推論", + "integrations.lazycodexRoles.auto.tierFast": "高速", + "integrations.lazycodexRoles.auto.tierStandard": "標準", + "integrations.lazycodexRoles.auto.tierFrontier": "最先端", + "integrations.lazycodexRoles.auto.effortGlance": "一瞥", + "integrations.lazycodexRoles.auto.effortMeasured": "標準的", + "integrations.lazycodexRoles.auto.effortThorough": "入念", + "integrations.lazycodexRoles.auto.effortExhaustive": "徹底", + "integrations.lazycodexRoles.auto.unsized": "未評価: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "モデルなし: {reason}", + "integrations.lazycodexRoles.auto.applied": "適用済み", + "integrations.lazycodexRoles.auto.alreadySet": "設定済み", + "integrations.lazycodexRoles.auto.apply": "適用", + "integrations.lazycodexRoles.auto.moveUp": "上げる条件: {text}", + "integrations.lazycodexRoles.auto.moveDown": "下げる条件: {text}", "integrations.semantics.cline": "Cline CLI の providers.json と models.json の OpenCodex 項目を管理します。変更・同期前に Cline を終了し、完了後に再起動してください。元に戻すと両方の元ファイルが復元されます。既定のプロバイダーは変わりません。Cline で OpenCodex を選択してください。", "integrations.semantics.kilo": "Kilo のグローバル設定(~/.config/kilo 配下で最初に存在する kilo.jsonc、kilo.json、opencode.jsonc、opencode.json、config.json。XDG_CONFIG_HOME はこのディレクトリを移動し、いずれも存在しなければ kilo.jsonc が作成されます)の provider.opencodex のみを管理します。他のキーは変更しません。適用時にファイル全体を書き直すため、コメントと末尾カンマは保持されません。Kilo で opencodex/<モデル> を選択してください。", "integrations.semantics.droid": "Factory Droid の settings.json に OpenCodex カスタムモデルを追加します。無効化すると管理対象の項目だけが削除され、元に戻すと保存したファイルが復元されます。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 57dfaafed4a..21bf12f8e55 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1720,6 +1720,32 @@ export const ko: Record = { "integrations.lazycodexRoles.omoFailed": "역할 파일에서 {role}은(는) 이제 {model}로 실행되지만, omo.jsonc에는 쓰지 못했습니다.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc 다시 시도", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc에 주석이 있어서 여기서 저장하면 역할 파일만 바뀝니다. omo.jsonc의 codex.agents는 직접 맞춰 주세요.", + "integrations.lazycodexRoles.auto.button": "자동 배정", + "integrations.lazycodexRoles.auto.running": "역할 평가 중…", + "integrations.lazycodexRoles.auto.hint": "기본 모델로 모든 역할을 평가하고 역할마다 충분하면서 가장 저렴한 모델을 제안합니다. 적용하기 전에는 아무것도 바뀌지 않습니다.", + "integrations.lazycodexRoles.auto.failed": "Codex 에이전트 역할을 평가하지 못했습니다.", + "integrations.lazycodexRoles.auto.title": "제안된 모델", + "integrations.lazycodexRoles.auto.sizedWith": "{model}(으)로 평가함", + "integrations.lazycodexRoles.auto.applyAll": "모두 적용", + "integrations.lazycodexRoles.auto.discard": "버리기", + "integrations.lazycodexRoles.auto.sizingFailed": "평가 호출이 실패했습니다: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "제안 {total}개 중 {count}개를 적용했습니다.", + "integrations.lazycodexRoles.auto.proposalFor": "{role} 제안", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} 등급, {effort} 추론", + "integrations.lazycodexRoles.auto.tierFast": "빠름", + "integrations.lazycodexRoles.auto.tierStandard": "표준", + "integrations.lazycodexRoles.auto.tierFrontier": "최상위", + "integrations.lazycodexRoles.auto.effortGlance": "훑어보기", + "integrations.lazycodexRoles.auto.effortMeasured": "보통", + "integrations.lazycodexRoles.auto.effortThorough": "꼼꼼히", + "integrations.lazycodexRoles.auto.effortExhaustive": "철저히", + "integrations.lazycodexRoles.auto.unsized": "평가 안 됨: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "모델 없음: {reason}", + "integrations.lazycodexRoles.auto.applied": "적용됨", + "integrations.lazycodexRoles.auto.alreadySet": "이미 설정됨", + "integrations.lazycodexRoles.auto.apply": "적용", + "integrations.lazycodexRoles.auto.moveUp": "올릴 조건: {text}", + "integrations.lazycodexRoles.auto.moveDown": "내릴 조건: {text}", "integrations.semantics.cline": "Cline CLI의 providers.json과 models.json에서 OpenCodex 항목을 관리합니다. 변경·동기화 전에 Cline을 종료하고 완료 후 다시 실행하세요. 되돌리기는 두 파일의 원본을 복원합니다. 기본 프로바이더는 유지되므로 Cline에서 OpenCodex를 선택하세요.", "integrations.semantics.kilo": "Kilo 전역 설정(~/.config/kilo에서 먼저 존재하는 kilo.jsonc, kilo.json, opencode.jsonc, opencode.json, config.json. XDG_CONFIG_HOME는 이 디렉터리를 이동하며, 아무것도 없으면 kilo.jsonc가 생성됨)의 provider.opencodex만 관리합니다. 다른 키는 그대로 둡니다. 적용 시 파일 전체를 다시 쓰므로 주석과 후행 쉼표는 보존되지 않습니다. Kilo에서 opencodex/<모델>을 선택하세요.", "integrations.semantics.droid": "Factory Droid의 settings.json에 OpenCodex 사용자 지정 모델을 추가합니다. 비활성화하면 관리 항목만 제거하고, 되돌리기는 저장한 파일을 복원합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 22c19a0bb92..41badf78984 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2208,6 +2208,32 @@ export const ru: Record = { "integrations.lazycodexRoles.omoFailed": "В файле роли {role} теперь работает на {model}, но записать omo.jsonc не удалось.", "integrations.lazycodexRoles.retryMirror": "Повторить запись omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "В omo.jsonc есть комментарии, поэтому сохранение здесь меняет только файлы ролей. Поправьте codex.agents в omo.jsonc вручную, чтобы они совпадали.", + "integrations.lazycodexRoles.auto.button": "Автоназначение", + "integrations.lazycodexRoles.auto.running": "Оценка ролей…", + "integrations.lazycodexRoles.auto.hint": "Оценить каждую роль моделью по умолчанию и предложить для каждой самую дешёвую достаточную модель. До применения ничего не меняется.", + "integrations.lazycodexRoles.auto.failed": "Не удалось оценить роли агентов Codex.", + "integrations.lazycodexRoles.auto.title": "Предложенные модели", + "integrations.lazycodexRoles.auto.sizedWith": "Оценено моделью {model}", + "integrations.lazycodexRoles.auto.applyAll": "Применить все", + "integrations.lazycodexRoles.auto.discard": "Отменить", + "integrations.lazycodexRoles.auto.sizingFailed": "Оценка не удалась: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "Применено предложений: {count} из {total}.", + "integrations.lazycodexRoles.auto.proposalFor": "Предложение для {role}", + "integrations.lazycodexRoles.auto.tierEffort": "Уровень {tier}, усилие {effort}", + "integrations.lazycodexRoles.auto.tierFast": "Быстрый", + "integrations.lazycodexRoles.auto.tierStandard": "Стандартный", + "integrations.lazycodexRoles.auto.tierFrontier": "Передовой", + "integrations.lazycodexRoles.auto.effortGlance": "беглое", + "integrations.lazycodexRoles.auto.effortMeasured": "обычное", + "integrations.lazycodexRoles.auto.effortThorough": "тщательное", + "integrations.lazycodexRoles.auto.effortExhaustive": "исчерпывающее", + "integrations.lazycodexRoles.auto.unsized": "Не оценено: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "Нет модели: {reason}", + "integrations.lazycodexRoles.auto.applied": "Применено", + "integrations.lazycodexRoles.auto.alreadySet": "Уже задано", + "integrations.lazycodexRoles.auto.apply": "Применить", + "integrations.lazycodexRoles.auto.moveUp": "Повысить, если: {text}", + "integrations.lazycodexRoles.auto.moveDown": "Понизить, если: {text}", "integrations.semantics.cline": "Управляет OpenCodex в файлах providers.json и models.json Cline CLI. Закройте Cline перед изменением или синхронизацией и запустите снова после завершения. Отмена восстанавливает оба исходных файла. Провайдер по умолчанию не меняется; выберите OpenCodex в Cline.", "integrations.semantics.kilo": "Управляет только provider.opencodex в глобальной конфигурации Kilo — первый существующий файл среди kilo.jsonc, kilo.json, opencode.jsonc, opencode.json или config.json в ~/.config/kilo (XDG_CONFIG_HOME переносит этот каталог; если файла нет, создаётся kilo.jsonc). Остальные ключи не меняются. Применение перезаписывает весь файл, поэтому комментарии и завершающие запятые не сохраняются. Выберите opencodex/<модель> в Kilo.", "integrations.semantics.droid": "Добавляет модели OpenCodex в settings.json Factory Droid. Отключение удаляет только управляемые записи; отмена восстанавливает сохранённый файл.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b4267243dc7..72ea24676e1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2226,6 +2226,32 @@ export const tr: Record = { "integrations.lazycodexRoles.omoFailed": "{role} rol dosyasında artık {model} ile çalışıyor, ancak omo.jsonc yazılamadı.", "integrations.lazycodexRoles.retryMirror": "omo.jsonc'yi yeniden dene", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc yorum içerdiği için burada kaydetmek yalnızca rol dosyalarını değiştirir. Eşleşmeleri için omo.jsonc içindeki codex.agents değerini elle düzenleyin.", + "integrations.lazycodexRoles.auto.button": "Otomatik ata", + "integrations.lazycodexRoles.auto.running": "Roller değerlendiriliyor…", + "integrations.lazycodexRoles.auto.hint": "Her rolü varsayılan modelinle değerlendir ve her biri için yeterli en ucuz modeli öner. Uygulayana kadar hiçbir şey değişmez.", + "integrations.lazycodexRoles.auto.failed": "Codex ajan rolleri değerlendirilemedi.", + "integrations.lazycodexRoles.auto.title": "Önerilen modeller", + "integrations.lazycodexRoles.auto.sizedWith": "{model} ile değerlendirildi", + "integrations.lazycodexRoles.auto.applyAll": "Tümünü uygula", + "integrations.lazycodexRoles.auto.discard": "Vazgeç", + "integrations.lazycodexRoles.auto.sizingFailed": "Değerlendirme çağrısı başarısız oldu: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "{total} öneriden {count} tanesi uygulandı.", + "integrations.lazycodexRoles.auto.proposalFor": "{role} için öneri", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} katman, {effort} çaba", + "integrations.lazycodexRoles.auto.tierFast": "Hızlı", + "integrations.lazycodexRoles.auto.tierStandard": "Standart", + "integrations.lazycodexRoles.auto.tierFrontier": "Öncü", + "integrations.lazycodexRoles.auto.effortGlance": "göz atma", + "integrations.lazycodexRoles.auto.effortMeasured": "ölçülü", + "integrations.lazycodexRoles.auto.effortThorough": "titiz", + "integrations.lazycodexRoles.auto.effortExhaustive": "kapsamlı", + "integrations.lazycodexRoles.auto.unsized": "Değerlendirilmedi: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "Model yok: {reason}", + "integrations.lazycodexRoles.auto.applied": "Uygulandı", + "integrations.lazycodexRoles.auto.alreadySet": "Zaten ayarlı", + "integrations.lazycodexRoles.auto.apply": "Uygula", + "integrations.lazycodexRoles.auto.moveUp": "Yükselt, eğer: {text}", + "integrations.lazycodexRoles.auto.moveDown": "Düşür, eğer: {text}", "integrations.semantics.cline": "Cline CLI providers.json ve models.json dosyalarındaki OpenCodex girdilerini yönetir. Değişiklik veya eşitleme öncesinde Cline’ı kapatın, ardından yeniden başlatın. Geri al iki özgün dosyayı da geri yükler. Varsayılan sağlayıcınız değişmez; Cline içinde OpenCodex’i seçin.", "integrations.semantics.kilo": "Yalnızca Kilo’nun genel yapılandırmasındaki provider.opencodex öğesini yönetir — ~/.config/kilo altındaki ilk bulunan kilo.jsonc, kilo.json, opencode.jsonc, opencode.json veya config.json (XDG_CONFIG_HOME bu dizini taşır; hiçbiri yoksa kilo.jsonc oluşturulur). Diğer anahtarlar değişmez. Uygula dosyanın tamamını yeniden yazar; yorumlar ve sondaki virgüller korunmaz. Kilo’da opencodex/ seçin.", "integrations.semantics.droid": "Factory Droid settings.json dosyasına OpenCodex özel modellerini ekler. Devre dışı bırakma yalnızca yönetilen girdileri kaldırır; Geri al kaydedilen dosyayı geri yükler.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 0b779d69786..974de52d65b 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2242,6 +2242,32 @@ export const vi: Record = { "integrations.lazycodexRoles.omoFailed": "Trong tệp vai trò, {role} giờ chạy trên {model}, nhưng không ghi được omo.jsonc.", "integrations.lazycodexRoles.retryMirror": "Thử lại omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc có chú thích nên lưu ở đây chỉ thay đổi các tệp vai trò. Hãy tự sửa codex.agents trong omo.jsonc để hai nơi khớp nhau.", + "integrations.lazycodexRoles.auto.button": "Tự động gán", + "integrations.lazycodexRoles.auto.running": "Đang đánh giá vai trò…", + "integrations.lazycodexRoles.auto.hint": "Đánh giá mọi vai trò bằng mô hình mặc định và đề xuất mô hình đủ dùng rẻ nhất cho từng vai trò. Không có gì thay đổi cho đến khi bạn áp dụng.", + "integrations.lazycodexRoles.auto.failed": "Không thể đánh giá các vai trò tác tử Codex.", + "integrations.lazycodexRoles.auto.title": "Mô hình đề xuất", + "integrations.lazycodexRoles.auto.sizedWith": "Đánh giá bằng {model}", + "integrations.lazycodexRoles.auto.applyAll": "Áp dụng tất cả", + "integrations.lazycodexRoles.auto.discard": "Bỏ qua", + "integrations.lazycodexRoles.auto.sizingFailed": "Lệnh đánh giá thất bại: {error}", + "integrations.lazycodexRoles.auto.appliedCount": "Đã áp dụng {count} trên {total} đề xuất.", + "integrations.lazycodexRoles.auto.proposalFor": "Đề xuất cho {role}", + "integrations.lazycodexRoles.auto.tierEffort": "Bậc {tier}, nỗ lực {effort}", + "integrations.lazycodexRoles.auto.tierFast": "Nhanh", + "integrations.lazycodexRoles.auto.tierStandard": "Tiêu chuẩn", + "integrations.lazycodexRoles.auto.tierFrontier": "Hàng đầu", + "integrations.lazycodexRoles.auto.effortGlance": "lướt qua", + "integrations.lazycodexRoles.auto.effortMeasured": "vừa phải", + "integrations.lazycodexRoles.auto.effortThorough": "kỹ lưỡng", + "integrations.lazycodexRoles.auto.effortExhaustive": "toàn diện", + "integrations.lazycodexRoles.auto.unsized": "Chưa đánh giá: {reason}", + "integrations.lazycodexRoles.auto.unassigned": "Không có mô hình: {reason}", + "integrations.lazycodexRoles.auto.applied": "Đã áp dụng", + "integrations.lazycodexRoles.auto.alreadySet": "Đã đặt sẵn", + "integrations.lazycodexRoles.auto.apply": "Áp dụng", + "integrations.lazycodexRoles.auto.moveUp": "Nâng lên nếu: {text}", + "integrations.lazycodexRoles.auto.moveDown": "Hạ xuống nếu: {text}", "integrations.semantics.cline": "Quản lý OpenCodex trong providers.json và models.json của Cline CLI. Hãy dừng Cline trước khi thay đổi hoặc đồng bộ các tệp này, rồi khởi động lại. Hoàn tác sẽ khôi phục cả hai bản gốc. Nhà cung cấp mặc định của bạn không thay đổi; hãy chọn OpenCodex trong Cline.", "integrations.semantics.kilo": "Chỉ quản lý provider.opencodex trong cấu hình toàn cục của Kilo — tệp tồn tại đầu tiên trong kilo.jsonc, kilo.json, opencode.jsonc, opencode.json hoặc config.json dưới ~/.config/kilo (XDG_CONFIG_HOME chuyển thư mục này; nếu không tệp nào tồn tại, kilo.jsonc sẽ được tạo). Các khóa khác giữ nguyên. Áp dụng ghi lại toàn bộ tệp nên chú thích và dấu phẩy cuối không được giữ. Chọn opencodex/ trong Kilo.", "integrations.semantics.droid": "Thêm các mô hình OpenCodex vào settings.json của Factory Droid. Tắt chỉ xóa các mục được quản lý; Hoàn tác khôi phục tệp đã lưu.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 0882126f59d..c74d616ca00 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3029,6 +3029,32 @@ export const zhTW: Record = { "integrations.lazycodexRoles.omoFailed": "角色檔案中 {role} 現在使用 {model},但無法寫入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重試 omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有註解,因此在這裡儲存只會修改角色檔案。請手動調整 omo.jsonc 中的 codex.agents 以保持一致。", + "integrations.lazycodexRoles.auto.button": "自動指派", + "integrations.lazycodexRoles.auto.running": "正在評估角色…", + "integrations.lazycodexRoles.auto.hint": "以預設模型評估每個角色,並為每個角色提議足夠且最便宜的模型。套用前不會變更任何內容。", + "integrations.lazycodexRoles.auto.failed": "無法評估 Codex 代理角色。", + "integrations.lazycodexRoles.auto.title": "提議的模型", + "integrations.lazycodexRoles.auto.sizedWith": "以 {model} 評估", + "integrations.lazycodexRoles.auto.applyAll": "全部套用", + "integrations.lazycodexRoles.auto.discard": "捨棄", + "integrations.lazycodexRoles.auto.sizingFailed": "評估呼叫失敗:{error}", + "integrations.lazycodexRoles.auto.appliedCount": "已套用 {total} 項提議中的 {count} 項。", + "integrations.lazycodexRoles.auto.proposalFor": "{role} 的提議", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} 層級、{effort} 推理", + "integrations.lazycodexRoles.auto.tierFast": "快速", + "integrations.lazycodexRoles.auto.tierStandard": "標準", + "integrations.lazycodexRoles.auto.tierFrontier": "前沿", + "integrations.lazycodexRoles.auto.effortGlance": "略看", + "integrations.lazycodexRoles.auto.effortMeasured": "適中", + "integrations.lazycodexRoles.auto.effortThorough": "周密", + "integrations.lazycodexRoles.auto.effortExhaustive": "窮盡", + "integrations.lazycodexRoles.auto.unsized": "未評估:{reason}", + "integrations.lazycodexRoles.auto.unassigned": "沒有模型:{reason}", + "integrations.lazycodexRoles.auto.applied": "已套用", + "integrations.lazycodexRoles.auto.alreadySet": "已設定", + "integrations.lazycodexRoles.auto.apply": "套用", + "integrations.lazycodexRoles.auto.moveUp": "提高的條件:{text}", + "integrations.lazycodexRoles.auto.moveDown": "降低的條件:{text}", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 項目。修改或同步前請結束 Cline,完成後重新啟動。復原會還原兩個檔案的原始內容。預設供應商保持不變,請在 Cline 中選擇 OpenCodex。", "integrations.semantics.kilo": "僅管理 Kilo 全域設定——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 會移動該目錄;若都不存在則建立 kilo.jsonc)——中的 provider.opencodex。其他鍵保持不變。套用會重寫整個檔案,因此不會保留註解與尾隨逗號。請在 Kilo 中選擇 opencodex/<模型>。", "integrations.semantics.droid": "將 OpenCodex 自訂模型加入 Factory Droid 的 settings.json。停用只移除受管理項目;復原會還原已儲存的檔案。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 79d92a3f21b..16e6ff735fb 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1701,6 +1701,32 @@ export const zh: Record = { "integrations.lazycodexRoles.omoFailed": "角色文件中 {role} 现在使用 {model},但无法写入 omo.jsonc。", "integrations.lazycodexRoles.retryMirror": "重试 omo.jsonc", "integrations.lazycodexRoles.omoCommentsState": "omo.jsonc 含有注释,因此在这里保存只会修改角色文件。请手动调整 omo.jsonc 中的 codex.agents 以保持一致。", + "integrations.lazycodexRoles.auto.button": "自动分配", + "integrations.lazycodexRoles.auto.running": "正在评估角色…", + "integrations.lazycodexRoles.auto.hint": "用默认模型评估每个角色,并为每个角色建议够用且最便宜的模型。应用之前不会改动任何内容。", + "integrations.lazycodexRoles.auto.failed": "无法评估 Codex 代理角色。", + "integrations.lazycodexRoles.auto.title": "建议的模型", + "integrations.lazycodexRoles.auto.sizedWith": "使用 {model} 评估", + "integrations.lazycodexRoles.auto.applyAll": "全部应用", + "integrations.lazycodexRoles.auto.discard": "放弃", + "integrations.lazycodexRoles.auto.sizingFailed": "评估调用失败:{error}", + "integrations.lazycodexRoles.auto.appliedCount": "已应用 {total} 条建议中的 {count} 条。", + "integrations.lazycodexRoles.auto.proposalFor": "{role} 的建议", + "integrations.lazycodexRoles.auto.tierEffort": "{tier} 层级,{effort} 推理", + "integrations.lazycodexRoles.auto.tierFast": "快速", + "integrations.lazycodexRoles.auto.tierStandard": "标准", + "integrations.lazycodexRoles.auto.tierFrontier": "前沿", + "integrations.lazycodexRoles.auto.effortGlance": "略看", + "integrations.lazycodexRoles.auto.effortMeasured": "适中", + "integrations.lazycodexRoles.auto.effortThorough": "周密", + "integrations.lazycodexRoles.auto.effortExhaustive": "穷尽", + "integrations.lazycodexRoles.auto.unsized": "未评估:{reason}", + "integrations.lazycodexRoles.auto.unassigned": "没有模型:{reason}", + "integrations.lazycodexRoles.auto.applied": "已应用", + "integrations.lazycodexRoles.auto.alreadySet": "已设置", + "integrations.lazycodexRoles.auto.apply": "应用", + "integrations.lazycodexRoles.auto.moveUp": "提高的条件:{text}", + "integrations.lazycodexRoles.auto.moveDown": "降低的条件:{text}", "integrations.semantics.cline": "管理 Cline CLI 的 providers.json 和 models.json 中的 OpenCodex 项目。修改或同步前请退出 Cline,完成后重新启动。撤销会恢复两个文件的原始内容。默认提供商保持不变,请在 Cline 中选择 OpenCodex。", "integrations.semantics.kilo": "仅管理 Kilo 全局配置——~/.config/kilo 下最先存在的 kilo.jsonc、kilo.json、opencode.jsonc、opencode.json 或 config.json(XDG_CONFIG_HOME 会移动该目录;若都不存在则创建 kilo.jsonc)——中的 provider.opencodex。其他键保持不变。应用会重写整个文件,因此不会保留注释和尾随逗号。请在 Kilo 中选择 opencodex/<模型>。", "integrations.semantics.droid": "将 OpenCodex 自定义模型添加到 Factory Droid 的 settings.json。停用仅移除托管条目;撤销会恢复已保存的文件。", diff --git a/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx new file mode 100644 index 00000000000..330af3521d5 --- /dev/null +++ b/gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { readJsonOrThrow } from "../../fetch-json"; +import { useT, type TKey } from "../../i18n/shared"; +import { Notice } from "../../ui"; + +type Tier = "fast" | "standard" | "frontier"; +type EffortIntent = "glance" | "measured" | "thorough" | "exhaustive"; + +export interface RoleProposal { + role: string; + model: string | null; + effort: string | null; + status: "proposed" | "unassigned" | "unsized"; + tier?: Tier; + effortIntent?: EffortIntent; + rationale?: string; + moveUpIf?: string; + moveDownIf?: string; + proposedModel?: string | null; + proposedEffort?: string | null; + reason?: string | null; +} + +interface Proposals { + sizingModel: string; + sizingError: string | null; + proposals: RoleProposal[]; +} + +const TIER_LABEL: Record = { + fast: "integrations.lazycodexRoles.auto.tierFast", + standard: "integrations.lazycodexRoles.auto.tierStandard", + frontier: "integrations.lazycodexRoles.auto.tierFrontier", +}; + +const EFFORT_LABEL: Record = { + glance: "integrations.lazycodexRoles.auto.effortGlance", + measured: "integrations.lazycodexRoles.auto.effortMeasured", + thorough: "integrations.lazycodexRoles.auto.effortThorough", + exhaustive: "integrations.lazycodexRoles.auto.effortExhaustive", +}; + +function alreadySet(p: RoleProposal): boolean { + return p.proposedModel === p.model && (p.proposedEffort == null || p.proposedEffort === p.effort); +} + +export default function LazyCodexRoleAutoAssign({ + apiBase, + busy, + apply, +}: { + apiBase: string; + busy: boolean; + apply: (role: string, model: string, effort: string | null) => Promise; +}) { + const t = useT(); + const [running, setRunning] = useState(false); + const [applying, setApplying] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [applied, setApplied] = useState>({}); + const [summary, setSummary] = useState(null); + + const run = async () => { + setRunning(true); + setError(null); + setSummary(null); + try { + const response = await fetch(`${apiBase}/api/codex-agent-roles/auto-assign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const payload = await readJsonOrThrow(response, t("integrations.lazycodexRoles.auto.failed")); + setResult(payload ?? null); + setApplied({}); + } catch (caught) { + setResult(null); + setError(caught instanceof Error && caught.message ? caught.message : t("integrations.lazycodexRoles.auto.failed")); + } finally { + setRunning(false); + } + }; + + const applicable = (result?.proposals ?? []).filter(p => + p.status === "proposed" && p.proposedModel && !applied[p.role] && !alreadySet(p)); + + const applyOne = async (proposal: RoleProposal): Promise => { + const ok = await apply(proposal.role, proposal.proposedModel!, proposal.proposedEffort ?? null); + if (ok) setApplied(current => ({ ...current, [proposal.role]: true })); + return ok; + }; + + const applyAll = async () => { + setApplying(true); + let count = 0; + const total = applicable.length; + // react-doctor-disable-next-line react-doctor/async-await-in-loop -- sequential on purpose: every PUT rewrites the shared omo.jsonc mirror + for (const proposal of applicable) if (await applyOne(proposal)) count += 1; + setSummary(t("integrations.lazycodexRoles.auto.appliedCount", { count: String(count), total: String(total) })); + setApplying(false); + }; + + const locked = running || applying || busy; + + return ( +
+
+

{t("integrations.lazycodexRoles.auto.hint")}

+ +
+ {error && {error}} + {result && ( +
+
+
{t("integrations.lazycodexRoles.auto.title")}
+ {t("integrations.lazycodexRoles.auto.sizedWith", { model: result.sizingModel })} +
+ + +
+
+ {result.sizingError && {t("integrations.lazycodexRoles.auto.sizingFailed", { error: result.sizingError })}} + {summary && {summary}} +
    + {result.proposals.map(proposal => ( +
  • +
    + {proposal.role} + {proposal.tier && proposal.effortIntent && ( + + {t("integrations.lazycodexRoles.auto.tierEffort", { + tier: t(TIER_LABEL[proposal.tier]), + effort: t(EFFORT_LABEL[proposal.effortIntent]), + })} + + )} +
    + {proposal.status === "unsized" ? ( +

    {t("integrations.lazycodexRoles.auto.unsized", { reason: proposal.reason ?? "" })}

    + ) : ( + <> +
    + {proposal.model ? {proposal.model} : t("integrations.lazycodexRoles.none")} + + {proposal.proposedModel + ? {proposal.proposedModel}{proposal.proposedEffort ? ` · ${proposal.proposedEffort}` : ""} + : {t("integrations.lazycodexRoles.auto.unassigned", { reason: proposal.reason ?? "" })}} + {proposal.status === "proposed" && (applied[proposal.role] || alreadySet(proposal) + ? ( + + {applied[proposal.role] ? t("integrations.lazycodexRoles.auto.applied") : t("integrations.lazycodexRoles.auto.alreadySet")} + + ) + : ( + + ))} +
    + {proposal.rationale &&

    {proposal.rationale}

    } + {proposal.moveUpIf &&

    {t("integrations.lazycodexRoles.auto.moveUp", { text: proposal.moveUpIf })}

    } + {proposal.moveDownIf &&

    {t("integrations.lazycodexRoles.auto.moveDown", { text: proposal.moveDownIf })}

    } + + )} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/gui/src/pages/integrations/LazyCodexRoleModels.tsx b/gui/src/pages/integrations/LazyCodexRoleModels.tsx index 892a7132009..86c59001e16 100644 --- a/gui/src/pages/integrations/LazyCodexRoleModels.tsx +++ b/gui/src/pages/integrations/LazyCodexRoleModels.tsx @@ -3,6 +3,7 @@ import { useDataSurface } from "../../data-surface"; import { readJsonOrThrow } from "../../fetch-json"; import { useT, type TKey } from "../../i18n/shared"; import { Notice, Select, type SelectOption } from "../../ui"; +import LazyCodexRoleAutoAssign from "./LazyCodexRoleAutoAssign"; interface RoleRow { role: string; @@ -53,15 +54,15 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri }); const data = resource.state.data; - const save = async (role: string, model: string) => { - if (pending !== null) return; + const save = async (role: string, model: string, effort: string | null = null): Promise => { + if (pending !== null) return false; setPending(role); setResult(null); try { const response = await fetch(`${apiBase}/api/codex-agent-roles/${encodeURIComponent(role)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model }), + body: JSON.stringify(effort ? { model, effort } : { model }), }); const payload = await readJsonOrThrow<{ omoJsonc?: { status?: OmoWriteStatus } }>( response, @@ -78,8 +79,10 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri return omoStatus === "write_failed" ? { ...rest, [role]: model } : rest; }); await resource.refresh(); + return true; } catch { setResult({ tone: "err", text: t("integrations.lazycodexRoles.saveFailed", { role }) }); + return false; } finally { setPending(null); } @@ -114,6 +117,8 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri {data.roles.length === 0 ? (

{t("integrations.lazycodexRoles.empty")}

) : ( + <> +
@@ -163,6 +168,7 @@ export default function LazyCodexRoleModels({ apiBase, active }: { apiBase: stri
+ )} ); diff --git a/gui/src/styles/lazycodex-role-models.css b/gui/src/styles/lazycodex-role-models.css index 47de491d670..fef739a5fe9 100644 --- a/gui/src/styles/lazycodex-role-models.css +++ b/gui/src/styles/lazycodex-role-models.css @@ -8,3 +8,17 @@ .lazycodex-role-models .tbl { min-width: 0; } .lazycodex-role-models .tbl :is(th, td):nth-child(2) { display: none; } } +.lazycodex-auto-assign { margin: 8px 0 12px; } +.lazycodex-auto-assign-bar { display: flex; align-items: center; gap: 8px 12px; flex-wrap: wrap; } +.lazycodex-auto-assign-bar > .page-sub { flex: 1 1 260px; margin: 0; } +.lazycodex-auto-assign-bar h5 { margin: 0; } +.lazycodex-auto-assign-actions { display: flex; gap: 8px; margin-left: auto; } +.lazycodex-auto-assign-panel { margin-top: 10px; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); } +.lazycodex-auto-assign-list { list-style: none; margin: 10px 0 0; padding: 0; display: grid; gap: 10px; } +.lazycodex-auto-assign-list > li { padding-top: 10px; border-top: 1px solid var(--border); min-width: 0; } +.lazycodex-auto-assign-list p { margin: 4px 0 0; overflow-wrap: anywhere; } +.lazycodex-auto-assign-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.lazycodex-auto-assign-change { display: flex; align-items: center; gap: 6px 8px; flex-wrap: wrap; margin-top: 4px; } +.lazycodex-auto-assign-change code { overflow-wrap: anywhere; } +.lazycodex-auto-assign-change > :is(button, .lazycodex-auto-assign-done) { margin-left: auto; } +.lazycodex-auto-assign-done { color: var(--green); font-weight: 600; } diff --git a/gui/tests/lazycodex-role-auto-assign.test.tsx b/gui/tests/lazycodex-role-auto-assign.test.tsx new file mode 100644 index 00000000000..92aaa8218c9 --- /dev/null +++ b/gui/tests/lazycodex-role-auto-assign.test.tsx @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const; + +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let mountCount = 0; +let apiBase = ""; +let requests: Array<{ method: string; url: string; body: unknown }> = []; + +const PROPOSALS = { + sizingModel: "gpt-5.5", + sizingError: null, + proposals: [ + { role: "explorer", model: "gpt-5.5", effort: "high", status: "proposed", tier: "fast", effortIntent: "glance", rationale: "Read-only search.", moveUpIf: "It edits.", moveDownIf: "Never.", proposedModel: "a/small", proposedEffort: "low", reason: null }, + { role: "worker", model: null, effort: null, status: "proposed", tier: "standard", effortIntent: "measured", rationale: "Writes code.", moveUpIf: "Cross-module.", moveDownIf: "Renames only.", proposedModel: "a/mid", proposedEffort: null, reason: null }, + { role: "vague", model: null, effort: null, status: "unsized", reason: "the sizing model did not answer with JSON" }, + ], + candidates: [], +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#integrations/codex" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + mountCount += 1; + apiBase = `http://ocx-lazycodex-auto-${mountCount}.invalid`; + requests = []; + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + const method = init?.method ?? "GET"; + if (method !== "GET") requests.push({ method, url, body: JSON.parse(String(init?.body)) }); + if (url.endsWith("/auto-assign")) return json(PROPOSALS); + if (method === "PUT") return json({ ok: true, toml: { status: "written" }, omoJsonc: { status: "written" } }); + if (url.endsWith("/api/subagent-models")) return json({ available: ["gpt-5.5", "a/small", "a/mid"] }); + return json({ + lazycodex: { detected: true, pluginEnabled: true, pluginInstalled: true }, + omoJsonc: { state: "present" }, + roles: [ + { role: "explorer", model: "gpt-5.5", omoJsoncModel: null }, + { role: "vague", model: null, omoJsoncModel: null }, + { role: "worker", model: null, omoJsoncModel: null }, + ], + }); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function settle(): Promise { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 30)); }); +} + +async function mount(): Promise { + const [{ createRoot }, { LanguageProvider }, { default: LazyCodexRoleModels }] = await Promise.all([ + import("react-dom/client"), + import("../src/i18n/provider"), + import("../src/pages/integrations/LazyCodexRoleModels"), + ]); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await settle(); +} + +function button(label: string, scope: ParentNode = container): HTMLButtonElement { + const found = [...scope.querySelectorAll("button")].find(node => node.textContent === label); + if (!found) throw new Error(`no button ${label}`); + return found as HTMLButtonElement; +} + +function proposal(role: string): HTMLElement { + return container.querySelector(`[aria-label="Proposal for ${role}"]`) as HTMLElement; +} + +async function click(node: HTMLButtonElement): Promise { + await act(async () => { node.click(); }); + await settle(); +} + +test("auto-assign shows proposals for review and writes nothing until a row is applied", async () => { + await mount(); + await click(button("Auto-assign")); + expect(requests).toEqual([{ method: "POST", url: `${apiBase}/api/codex-agent-roles/auto-assign`, body: {} }]); + expect(container.textContent).toContain("Sized with gpt-5.5"); + expect(proposal("explorer").textContent).toContain("a/small · low"); + expect(proposal("explorer").textContent).toContain("Fast tier, glance effort"); + expect(proposal("explorer").textContent).toContain("Move up if: It edits."); + expect(proposal("vague").textContent).toContain("Not sized: the sizing model did not answer with JSON"); + expect(proposal("vague").querySelector("button")).toBeNull(); + + await click(button("Apply", proposal("explorer"))); + expect(requests.slice(1)).toEqual([ + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low" } }, + ]); + expect(proposal("explorer").textContent).toContain("Applied"); +}); + +test("apply all writes each remaining proposal once and skips unsized roles", async () => { + await mount(); + await click(button("Auto-assign")); + await click(button("Apply all")); + expect(requests.slice(1)).toEqual([ + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/explorer`, body: { model: "a/small", effort: "low" } }, + { method: "PUT", url: `${apiBase}/api/codex-agent-roles/worker`, body: { model: "a/mid" } }, + ]); + expect(container.textContent).toContain("Applied 2 of 2 proposals."); + expect(button("Apply all").disabled).toBe(true); +}); + +test("a proposal that matches the role's current model and effort is shown as already set", async () => { + PROPOSALS.proposals[0] = { ...PROPOSALS.proposals[0]!, model: "a/small", effort: "low" }; + try { + await mount(); + await click(button("Auto-assign")); + expect(proposal("explorer").textContent).toContain("Already set"); + expect(proposal("explorer").querySelector("button")).toBeNull(); + await click(button("Apply all")); + expect(requests.slice(1).map(r => r.url)).toEqual([`${apiBase}/api/codex-agent-roles/worker`]); + } finally { + PROPOSALS.proposals[0] = { ...PROPOSALS.proposals[0]!, model: "gpt-5.5", effort: "high" }; + } +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 402eede4dc3..3f617936b7b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1678,9 +1678,13 @@ "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "codex-agent-role-models.test.ts": "routing", + "codex-role-sizing.test.ts": "routing", + "codex-role-auto-assign.test.ts": "routing", "omo-role-models.test.ts": "clients", "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", + "codex-role-auto-assign-routes.test.ts": "server", + "local-chat-completion.test.ts": "lib", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index 3ad167b6ef0..29c90bc9530 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -1186,22 +1186,26 @@ JSON mode: `payload`. ### `ocx agent roles` -omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc. +omo (Codex / LazyCodex): show each Codex agent role's model pin, set one role's model in its TOML and in omo.jsonc, or suggest a model for every role. | Method | Route | |---|---| | GET | `/api/codex-agent-roles` | | PUT | `/api/codex-agent-roles/{role}` | +| POST | `/api/codex-agent-roles/auto-assign` | | Flag | Value | Meaning | |---|---|---| -| `--json` | boolean | Emit the role list or the write result as JSON. | +| `--json` | boolean | Emit the role list, the write result or the proposals as JSON. | +| `--model` | string | suggest: size the roles with this model instead of the Codex default model. | +| `--apply` | boolean | suggest: write every proposal through the role model write. | JSON mode: `payload`. - A bare invocation reads and never writes. -- Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused. +- Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused. - set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments. +- suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort. ### `ocx api policy` diff --git a/src/cli/agent.ts b/src/cli/agent.ts index e651559c133..1ae5e56fcca 100644 --- a/src/cli/agent.ts +++ b/src/cli/agent.ts @@ -28,7 +28,7 @@ const USAGE = `Usage: ocx agent effort [--main ] [--subagent ] [--json] ocx agent subagents [model,model...] [--json] ocx agent fallback [model,model...] [--poll-ms <5000-600000>] [--json] - ocx agent roles [status|set ] [--json] + ocx agent roles [status|set |suggest [--model ] [--apply]] [--json] ocx agent sidecar [--list] [--model ] [--backend web: vision:] [--reasoning ] [--max-descriptions ] [--enabled ] [--json] @@ -243,6 +243,71 @@ interface CodexAgentRolesStatus { roles?: Array<{ role: string; model: string | null; omoJsoncModel: string | null }>; } +interface CodexRoleProposal { + role: string; + model: string | null; + effort?: string | null; + status: "proposed" | "unassigned" | "unsized"; + tier?: string; + effortIntent?: string; + rationale?: string; + proposedModel?: string | null; + proposedEffort?: string | null; + reason?: string | null; +} + +function proposalAlreadySet(proposal: CodexRoleProposal): boolean { + return proposal.proposedModel === proposal.model + && (proposal.proposedEffort == null || proposal.proposedEffort === proposal.effort); +} + +const OMO_JSONC_NOT_WRITTEN = new Set(["skipped_comments", "invalid", "write_failed"]); + +async function suggestRoles(args: string[], wantsJson: boolean, deps: RuntimeApiDeps): Promise { + const model = takeOption(args, "--model"); + const apply = takeFlag(args, "--apply"); + rejectArgs(args, USAGE); + const result = await runtimeRequest<{ sizingModel?: string; proposals?: CodexRoleProposal[] }>( + "/api/codex-agent-roles/auto-assign", + { method: "POST", body: JSON.stringify(model ? { model } : {}) }, + deps, + ); + const proposals = result.proposals ?? []; + const applied: Array<{ role: string; model: string; effort?: string }> = []; + const skipped: string[] = []; + const omoJsoncNotWritten: Array<{ role: string; status: string }> = []; + if (apply) { + for (const proposal of proposals) { + if (proposal.status !== "proposed" || !proposal.proposedModel) continue; + if (proposalAlreadySet(proposal)) { + skipped.push(proposal.role); + continue; + } + const body = { model: proposal.proposedModel, ...(proposal.proposedEffort ? { effort: proposal.proposedEffort } : {}) }; + const written = await runtimeRequest<{ omoJsonc?: { status?: string } } | null>( + `/api/codex-agent-roles/${encodeURIComponent(proposal.role)}`, + { method: "PUT", body: JSON.stringify(body) }, + deps, + ); + applied.push({ role: proposal.role, ...body }); + const omoStatus = written?.omoJsonc?.status; + if (omoStatus && OMO_JSONC_NOT_WRITTEN.has(omoStatus)) omoJsoncNotWritten.push({ role: proposal.role, status: omoStatus }); + } + } + printData(apply ? { ...result, applied, skipped, omoJsoncNotWritten } : result, wantsJson, [ + `Sized with ${result.sizingModel ?? "unknown"}.`, + ...proposals.map(p => p.status === "unsized" + ? `${p.role}: not sized (${p.reason ?? "unknown"})` + : p.status === "unassigned" + ? `${p.role}: ${p.tier}/${p.effortIntent}, no model (${p.reason ?? "unknown"})` + : `${p.role}: ${p.model ?? "(no pin)"} -> ${p.proposedModel}${p.proposedEffort ? ` (${p.proposedEffort})` : ""} [${p.tier}/${p.effortIntent}] ${p.rationale ?? ""}`), + apply + ? `Applied ${applied.length} of ${proposals.length} roles.${skipped.length > 0 ? ` Skipped ${skipped.length} already set: ${skipped.join(", ")}.` : ""}` + : "Nothing was written; rerun with --apply to write every proposal that differs from the role's current pin.", + ...omoJsoncNotWritten.map(entry => `${entry.role}: omo.jsonc not written (${entry.status}); the role TOML was updated.`), + ]); +} + async function roles(argv: string[], deps: RuntimeApiDeps): Promise { const args = [...argv]; const wantsJson = takeFlag(args, "--json"); @@ -262,6 +327,10 @@ async function roles(argv: string[], deps: RuntimeApiDeps): Promise { ]); return; } + if (action === "suggest") { + await suggestRoles(args, wantsJson, deps); + return; + } if (action !== "set") throw new CliUsageError(`unknown roles action ${action}`, USAGE); const role = args.shift(); const model = args.shift(); diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 0c4e4a1c853..9dcd8ff3596 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -1062,18 +1062,24 @@ export const CAPABILITIES: readonly Capability[] = [ }, { command: ["agent", "roles"], - summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, or set one role's model in its TOML and in omo.jsonc.", + summary: "omo (Codex / LazyCodex): show each Codex agent role's model pin, set one role's model in its TOML and in omo.jsonc, or suggest a model for every role.", routes: [ { method: "GET", path: "/api/codex-agent-roles" }, { method: "PUT", path: "/api/codex-agent-roles/{role}" }, + { method: "POST", path: "/api/codex-agent-roles/auto-assign" }, + ], + flags: [ + { name: "--json", value: "boolean", summary: "Emit the role list, the write result or the proposals as JSON." }, + { name: "--model", value: "string", summary: "suggest: size the roles with this model instead of the Codex default model." }, + { name: "--apply", value: "boolean", summary: "suggest: write every proposal through the role model write." }, ], - flags: [{ name: "--json", value: "boolean", summary: "Emit the role list or the write result as JSON." }], mutates: true, json: "payload", details: [ "A bare invocation reads and never writes.", - "Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles and set is refused.", + "Requires Codex-based omo (LazyCodex): the omo@sisyphuslabs Codex plugin enabled in config.toml and installed; otherwise status lists no roles, and set and suggest are refused.", "set rewrites only the root model value of $CODEX_HOME/agents/.toml; omo.jsonc is skipped when absent or when it contains comments.", + "suggest sizes every role with one model call and prints proposals without writing; --apply writes each proposed model, and its effort when the role file already sets model_reasoning_effort.", ], }, { diff --git a/src/codex/agent-role-models.ts b/src/codex/agent-role-models.ts index 51b78c44bcc..2eae1a954a3 100644 --- a/src/codex/agent-role-models.ts +++ b/src/codex/agent-role-models.ts @@ -1,10 +1,11 @@ /** - * The dashboard's write of one Codex agent role's model. + * The dashboard's write of one Codex agent role's model, and optionally its reasoning effort. * * Codex overrides the spawn-time model with the root `model` pin in * `$CODEX_HOME/agents/.toml`, so that pin is what decides a child's model. This module - * edits exactly that value and nothing else: every other byte of the file, including the - * instructions multiline string that usually mentions `model =` in prose, is left as it was. + * edits exactly that value, plus the root `model_reasoning_effort` when a caller passes one, and + * nothing else: every other byte of the file, including the instructions multiline string that + * usually mentions `model =` in prose, is left as it was. * It runs only on an explicit user action; no sync or startup path calls it. */ import { lstatSync, readFileSync } from "node:fs"; @@ -13,11 +14,18 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write"; import { assertIntegrationWriteOwnership } from "../integrations/config-io"; import { encodeBasicString, findInvalidCharacter } from "./prompt-layers/encoding"; import { dominantEol } from "./prompt-layers/toml-edit"; -import { listCodexAgentRoles, locateTomlModelKey } from "./subagent-model-fallback"; +import { isDeclaredReasoningEffort } from "../reasoning-effort"; +import { + listCodexAgentRoles, + locateTomlModelKey, + locateTomlReasoningEffortKey, + type TomlModelKeyLocation, +} from "./subagent-model-fallback"; export type AgentRoleModelErrorCode = | "unknown_role" | "invalid_model" + | "invalid_effort" | "unsupported_model_value" | "unsafe_target" | "invalid_role_file"; @@ -43,22 +51,28 @@ export function validateAgentRoleModel(model: unknown): string { return trimmed; } +export function validateAgentRoleEffort(effort: unknown): string { + if (typeof effort !== "string" || !isDeclaredReasoningEffort(effort)) { + throw new AgentRoleModelError("invalid_effort", "effort must be a reasoning effort level such as low, medium or high"); + } + return effort; +} + /** - * Set the root `model` value. An existing value is replaced inside its quotes and keeps its + * Set a root string key. An existing value is replaced inside its quotes and keeps its * quote style when the new value allows it; a missing key is inserted after the leading * comment block with the file's own line ending. */ -export function setTomlRootModel(content: string, model: string): string { - const location = locateTomlModelKey(content); +function setTomlRootString(content: string, location: TomlModelKeyLocation | null, key: string, value: string): string { if (location?.inRootTable) { if (!location.span) { - throw new AgentRoleModelError("unsupported_model_value", "the existing model value is not a one-line string"); + throw new AgentRoleModelError("unsupported_model_value", `the existing ${key} value is not a one-line string`); } const lines = content.split("\n"); const line = lines[location.line]!; - const literal = line[location.span.start] === "'" && !model.includes("'") - ? `'${model}'` - : encodeBasicString(model); + const literal = line[location.span.start] === "'" && !value.includes("'") + ? `'${value}'` + : encodeBasicString(value); lines[location.line] = line.slice(0, location.span.start) + literal + line.slice(location.span.end); return lines.join("\n"); } @@ -70,11 +84,28 @@ export function setTomlRootModel(content: string, model: string): string { if (!/^\s*#/.test(line)) break; offset += line.length + 1; } - const assignment = `model = ${encodeBasicString(model)}`; + const assignment = `${key} = ${encodeBasicString(value)}`; if (offset > content.length) return `${content}${eol}${assignment}${eol}`; return `${content.slice(0, offset)}${assignment}${eol}${content.slice(offset)}`; } +export function setTomlRootModel(content: string, model: string): string { + return setTomlRootString(content, locateTomlModelKey(content), "model", model); +} + +export function setTomlRootReasoningEffort(content: string, effort: string): string { + return setTomlRootString(content, locateTomlReasoningEffortKey(content), "model_reasoning_effort", effort); +} + +export function readCodexAgentRoleEffort(role: string, codexHome: string): string | null { + try { + const location = locateTomlReasoningEffortKey(readFileSync(roleFile(role, codexHome), "utf8")); + return location?.inRootTable ? location.value : null; + } catch { + return null; + } +} + function roleFile(role: string, codexHome: string): string { return join(codexHome, "agents", `${role}.toml`); } @@ -119,6 +150,7 @@ export function writeCodexAgentRoleModel( role: string, model: string, codexHome: string, + effort?: string, ): { status: "written" | "unchanged" } { requireKnownRole(role, codexHome); const path = roleFile(role, codexHome); @@ -127,7 +159,10 @@ export function writeCodexAgentRoleModel( } const before = readFileSync(path, "utf8"); assertValidRoleToml(role, before, "before"); - const after = setTomlRootModel(before, validateAgentRoleModel(model)); + const withModel = setTomlRootModel(before, validateAgentRoleModel(model)); + const after = effort === undefined + ? withModel + : setTomlRootReasoningEffort(withModel, validateAgentRoleEffort(effort)); if (after === before) return { status: "unchanged" }; assertValidRoleToml(role, after, "after"); assertIntegrationWriteOwnership(path); diff --git a/src/codex/role-auto-assign.ts b/src/codex/role-auto-assign.ts new file mode 100644 index 00000000000..cad8e9c5585 --- /dev/null +++ b/src/codex/role-auto-assign.ts @@ -0,0 +1,156 @@ +/** + * Turns a role's sized tier and effort into a concrete model and reasoning level. Deterministic: + * the sizing model never names a model, and nothing here asks one. + * + * Tier of a candidate model, first rule that applies: + * 1. codexRoleTiers in the opencodex config lists it under fast, standard or frontier. + * 2. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens. + * Three or more split evenly across the three tiers by rank, cheapest in fast. Fewer than three + * cannot fill every tier, so the ranking is anchored at the top: the dearest is frontier, + * because it is the strongest model on offer, and each cheaper one sits one tier lower. One + * priced model is frontier; two are standard and frontier. The tier left empty is fast, so a + * fast role steps up to the cheapest model and a standard role keeps it instead of jumping to + * the dearest, which keeps every role on the cheapest sufficient model on offer. + * 3. Otherwise it is unclassified and never proposed. + * + * A role gets the cheapest sufficient model: the lowest tier at or above what it was sized for, + * then the lowest price, then the picker's own order. + * + * Effort binds to positions on the chosen model's ladder: glance is the floor, measured is the + * model's default or the middle rung, exhaustive is the ceiling, and thorough is the rung above + * measured when that rung is not the ceiling. When no such interior rung exists, thorough + * collapses to the nearer of measured and the ceiling; the two are equally near, and the one + * above wins because thorough asks for more than the everyday setting. Nothing resolves outside + * the ladder. + */ +import { SIZING_TIERS, type RoleSizingOutcome, type SizingEffort, type SizingTier } from "./role-sizing"; +import { CODEX_REASONING_LEVELS } from "../reasoning-effort"; + +export type RoleTierMapping = Partial>; + +export interface RoleModelCandidate { + readonly model: string; + /** Input plus output USD per 1M tokens, or null when no price is known. */ + readonly unitPrice: number | null; + readonly efforts: readonly string[]; + readonly defaultEffort?: string; +} + +export interface ClassifiedCandidate extends RoleModelCandidate { + readonly tier: SizingTier | null; + readonly tierSource: "mapping" | "price" | null; +} + +const LADDER_ORDER = ["none", "minimal", ...CODEX_REASONING_LEVELS.map(level => level.effort)]; + +export function effortLadder(efforts: readonly string[]): string[] { + return [...new Set(efforts)] + .filter(effort => LADDER_ORDER.includes(effort)) + .sort((a, b) => LADDER_ORDER.indexOf(a) - LADDER_ORDER.indexOf(b)); +} + +export function mapEffortToLevel(intent: SizingEffort, efforts: readonly string[], defaultEffort?: string): string | null { + const ladder = effortLadder(efforts); + if (ladder.length === 0) return null; + const top = ladder.length - 1; + const measured = defaultEffort !== undefined && ladder.includes(defaultEffort) + ? ladder.indexOf(defaultEffort) + : Math.floor(top / 2); + switch (intent) { + case "glance": return ladder[0]!; + case "measured": return ladder[measured]!; + case "thorough": return ladder[Math.min(measured + 1, top)]!; + case "exhaustive": return ladder[top]!; + } +} + +export function classifyRoleModelCandidates( + candidates: readonly RoleModelCandidate[], + mapping: RoleTierMapping = {}, +): ClassifiedCandidate[] { + const mapped = new Map(); + for (const tier of SIZING_TIERS) { + for (const model of mapping[tier] ?? []) if (!mapped.has(model)) mapped.set(model, tier); + } + const priced = candidates + .map((candidate, order) => ({ candidate, order })) + .filter(({ candidate }) => !mapped.has(candidate.model) && candidate.unitPrice !== null) + .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order); + const byPrice = new Map(); + priced.forEach(({ candidate }, rank) => { + const top = SIZING_TIERS.length - 1; + const index = priced.length < SIZING_TIERS.length + ? top - (priced.length - 1 - rank) + : Math.round((rank * top) / (priced.length - 1)); + byPrice.set(candidate.model, SIZING_TIERS[index]!); + }); + return candidates.map(candidate => { + const fromMapping = mapped.get(candidate.model); + if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const }; + const fromPrice = byPrice.get(candidate.model); + return fromPrice + ? { ...candidate, tier: fromPrice, tierSource: "price" as const } + : { ...candidate, tier: null, tierSource: null }; + }); +} + +export function cheapestSufficientCandidate( + classified: readonly ClassifiedCandidate[], + tier: SizingTier, +): ClassifiedCandidate | null { + const need = SIZING_TIERS.indexOf(tier); + const ranked = classified + .map((candidate, order) => ({ candidate, order, rank: candidate.tier ? SIZING_TIERS.indexOf(candidate.tier) : -1 })) + .filter(entry => entry.rank >= need) + .sort((a, b) => a.rank - b.rank + || (a.candidate.unitPrice ?? Infinity) - (b.candidate.unitPrice ?? Infinity) + || a.order - b.order); + return ranked[0]?.candidate ?? null; +} + +export interface RoleProposalInput { + readonly role: string; + readonly model: string | null; + readonly effort: string | null; +} + +export type RoleProposal = RoleProposalInput & ( + | { + readonly status: "proposed" | "unassigned"; + readonly tier: SizingTier; + readonly effortIntent: SizingEffort; + readonly rationale: string; + readonly moveUpIf: string; + readonly moveDownIf: string; + readonly proposedModel: string | null; + /** Only when the role file already sets model_reasoning_effort. */ + readonly proposedEffort: string | null; + readonly reason: string | null; + } + | { readonly status: "unsized"; readonly reason: string } +); + +export function buildRoleProposals( + roles: readonly RoleProposalInput[], + sizing: ReadonlyMap, + classified: readonly ClassifiedCandidate[], +): RoleProposal[] { + return roles.map(role => { + const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" }; + if ("unsized" in outcome) return { ...role, status: "unsized", reason: outcome.unsized }; + const { tier, effort, rationale, moveUpIf, moveDownIf } = outcome.sizing; + const pick = cheapestSufficientCandidate(classified, tier); + const sized = { ...role, tier, effortIntent: effort, rationale, moveUpIf, moveDownIf }; + if (!pick) { + return { + ...sized, + status: "unassigned", + proposedModel: null, + proposedEffort: null, + reason: `no available model is classified ${tier} or above`, + }; + } + const proposedEffort = role.effort === null ? null : mapEffortToLevel(effort, pick.efforts, pick.defaultEffort); + return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null }; + }); +} diff --git a/src/codex/role-sizing.ts b/src/codex/role-sizing.ts new file mode 100644 index 00000000000..b8aae188a07 --- /dev/null +++ b/src/codex/role-sizing.ts @@ -0,0 +1,202 @@ +/** + * The sizing rubric for Codex agent roles: which capability tier a role needs and how hard it + * should reason, on a neutral scale that never names a model. + * + * This is the one home of the rubric. The dashboard's auto-assign sends it as the system prompt + * and parses the answer here; any later surface that sizes a role imports it rather than + * restating it. Choosing the concrete model and the concrete effort level is not the sizing + * model's job: that mapping is deterministic code in "role-auto-assign.ts". + * + * The tier and effort definitions, the risk factors, and the rules are ported from the + * modelchk skill by LilMGenius, + * https://github.com/LilMGenius/paperthin/blob/main/skills/depth/modelchk/SKILL.md, + * adapted from sizing one task to sizing a standing role and given a strict JSON answer shape. + * That text is used under the MIT License: + * + * Copyright (c) 2026 LilMGenius + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING + * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, + * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/** Capability tiers, cheapest first. */ +export const SIZING_TIERS = ["fast", "standard", "frontier"] as const; +export type SizingTier = typeof SIZING_TIERS[number]; + +/** Reasoning effort intents, least deliberation first. Positions on a ladder, never level names. */ +export const SIZING_EFFORTS = ["glance", "measured", "thorough", "exhaustive"] as const; +export type SizingEffort = typeof SIZING_EFFORTS[number]; + +export const ROLE_INSTRUCTIONS_EXCERPT_CHARS = 1500; + +const MAX_FIELD_CHARS = 400; + +export const ROLE_SIZING_SYSTEM_PROMPT = [ + "You size Codex agent roles before anyone spends a run on them. Each role is a standing agent that", + "a parent hands many tasks of the kind its instructions describe. For each role, size two dials", + "from one assessment of risk and complexity: capability tier and reasoning effort.", + "", + "Capability tier is the cheapest sufficient class:", + "- fast: local, mechanical, reversible work with cheap, complete verification.", + "- standard: ordinary repo-grounded reasoning, multi-step drafting, normal coding, and conventional documentation or skill work.", + "- frontier: architecture, high ambiguity, safety/security/privacy/data-loss risk, release-critical review, cross-domain scope, or work where one wrong assumption wastes a large run.", + "", + "Reasoning effort is how hard the model should deliberate. From least to most deliberation:", + "- glance: minimal deliberation; take the direct path. Resolves to the model's floor.", + "- measured: ordinary deliberation. Resolves to the model's default, or the middle of its ladder if no default is named.", + "- thorough: work the alternatives and check assumptions. Resolves above the everyday setting, short of the top.", + "- exhaustive: maximal deliberation; exhaust the search and re-check the work. Resolves to the model's ceiling.", + "", + "The axes are independent. A bounded but fiddly role can be fast + thorough; a quick expert call can", + "be frontier + glance. In most work they move together, parting when a cheap task needs hard thinking", + "or a strong model needs only a quick call. Effort buys deliberation, never capability, and more", + "effort is not more correct.", + "", + "Assess risk and complexity once for both dials:", + "- file, module, or ownership boundary crossing;", + "- reversibility and blast radius;", + "- safety, security, privacy, publishing, or data-loss risk;", + "- novelty, ambiguity, and long-context synthesis load;", + "- need for external research, adversarial review, or careful release sequencing;", + "- cost of a wrong answer.", + "", + "Choose the cheapest capability tier whose ceiling covers the role's judgment and risk. Default effort", + "to track tier (fast->glance, standard->measured, frontier->thorough), reserving exhaustive for the", + "hardest, highest-stakes work. Raise effort when an otherwise cheap role needs ambiguity resolved, long", + "multi-step reasoning, or adversarial self-check; lower it for bounded work under a strong model.", + "", + "Rules:", + "- Default to the cheapest sufficient tier and the effort the work needs, not the strongest of either.", + "- Never raise effort to buy capability; raise the tier.", + "- Risk beats size: a narrow high-risk role can need frontier; a broad mechanical role can stay fast + thorough.", + "- Use only the neutral words above. Do not name concrete model products, vendors, versions, or vendor effort levels anywhere in the answer.", + "", + "Answer with one JSON object and nothing else, no prose and no code fence:", + '{"roles":{"":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","rationale":"","move_up_if":"","move_down_if":""}}}', + "Include every role you were given, keyed by its exact name, with exactly those five fields.", +].join("\n"); + +export interface RoleSizing { + readonly tier: SizingTier; + readonly effort: SizingEffort; + readonly rationale: string; + readonly moveUpIf: string; + readonly moveDownIf: string; +} + +/** A role's sizing, or the reason it could not be sized. No reason is ever turned into a guess. */ +export type RoleSizingOutcome = + | { readonly sizing: RoleSizing } + | { readonly unsized: string }; + +export interface RoleSizingInput { + readonly role: string; + readonly instructions: string; +} + +function textField(value: unknown): string | null { + return typeof value === "string" && value.trim() !== "" ? value.trim() : null; +} + +/** + * What the sizing model reads about one role: its description, then its developer instructions, + * cut to ROLE_INSTRUCTIONS_EXCERPT_CHARS. Null when the file is not TOML or carries neither, + * because a role sized from its name alone would be a guess. + */ +export function roleInstructionsExcerpt(toml: string): string | null { + let document: unknown; + try { + document = Bun.TOML.parse(toml.replace(/^\ufeff/, "")); + } catch { + return null; + } + if (typeof document !== "object" || document === null) return null; + const fields = document as Record; + const parts = [textField(fields.description), textField(fields.developer_instructions)] + .filter((part): part is string => part !== null); + if (parts.length === 0) return null; + return parts.join("\n\n").slice(0, ROLE_INSTRUCTIONS_EXCERPT_CHARS); +} + +export function buildRoleSizingUserMessage(inputs: readonly RoleSizingInput[]): string { + return "Size these Codex agent roles.\n\n" + JSON.stringify({ + roles: inputs.map(input => ({ name: input.role, instructions: input.instructions })), + }, null, 2); +} + +function oneLine(value: unknown): string | null { + if (typeof value !== "string") return null; + const collapsed = value.replace(/\s+/g, " ").trim(); + return collapsed === "" || collapsed.length > MAX_FIELD_CHARS ? null : collapsed; +} + +function isMember(set: readonly T[], value: unknown): value is T { + return typeof value === "string" && (set as readonly string[]).includes(value); +} + +function validateEntry(entry: unknown): RoleSizingOutcome { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + return { unsized: "the sizing answer for this role is not an object" }; + } + const fields = entry as Record; + if (!isMember(SIZING_TIERS, fields.tier)) return { unsized: "the sizing answer has no valid tier" }; + if (!isMember(SIZING_EFFORTS, fields.effort)) return { unsized: "the sizing answer has no valid effort" }; + const rationale = oneLine(fields.rationale); + const moveUpIf = oneLine(fields.move_up_if); + const moveDownIf = oneLine(fields.move_down_if); + if (rationale === null || moveUpIf === null || moveDownIf === null) { + return { unsized: "the sizing answer is missing a rationale or a move trigger" }; + } + return { sizing: { tier: fields.tier, effort: fields.effort, rationale, moveUpIf, moveDownIf } }; +} + +const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i; + +/** + * Validate the sizing model's answer for each requested role. + * + * The answer must be one JSON object of the shape the rubric asks for. Two leniencies, both for + * content the mapping never reads: a single surrounding code fence, which models add out of habit, + * and keys beyond the five fields in a role's entry, which are ignored. Every one of the five must + * still be present and valid. Anything else wrong marks the affected role, or every role when the + * whole answer is unusable, as unsized with the reason. + */ +export function parseRoleSizingResponse(text: string, roles: readonly string[]): Map { + const out = new Map(); + const everyRole = (reason: string) => { + for (const role of roles) out.set(role, { unsized: reason }); + return out; + }; + const trimmed = text.trim(); + const fenced = CODE_FENCE.exec(trimmed); + let parsed: unknown; + try { + parsed = JSON.parse(fenced ? fenced[1]! : trimmed); + } catch { + return everyRole("the sizing model did not answer with JSON"); + } + const answered = typeof parsed === "object" && parsed !== null + ? (parsed as Record).roles + : undefined; + if (typeof answered !== "object" || answered === null || Array.isArray(answered)) { + return everyRole("the sizing answer has no roles object"); + } + for (const role of roles) { + out.set(role, Object.hasOwn(answered, role) + ? validateEntry((answered as Record)[role]) + : { unsized: "the sizing model did not size this role" }); + } + return out; +} diff --git a/src/codex/subagent-model-fallback.ts b/src/codex/subagent-model-fallback.ts index bd9832812b9..db703531cdd 100644 --- a/src/codex/subagent-model-fallback.ts +++ b/src/codex/subagent-model-fallback.ts @@ -948,6 +948,15 @@ export interface TomlModelKeyLocation { * role's model, so the line the writer edits is the line the reader reports. */ export function locateTomlModelKey(content: string): TomlModelKeyLocation | null { + return locateTomlStringKey(content, "model"); +} + +/** The same scan for a role's root model_reasoning_effort, which the dashboard edits beside the pin. */ +export function locateTomlReasoningEffortKey(content: string): TomlModelKeyLocation | null { + return locateTomlStringKey(content, "model_reasoning_effort"); +} + +function locateTomlStringKey(content: string, keyName: string): TomlModelKeyLocation | null { const lines = content.split(/\r?\n/); const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 }; let inRootTable = true; @@ -966,7 +975,7 @@ export function locateTomlModelKey(content: string): TomlModelKeyLocation | null const key = line.match(TOML_ROOT_KEY); const name = key ? tomlKeyName(key) : null; if (key && name === undefined && inRootTable) undecodedRootKeyLine ??= i; - if (key && name === "model") { + if (key && name === keyName) { const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`; let at = 0; while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1; @@ -984,7 +993,7 @@ export function locateTomlModelKey(content: string): TomlModelKeyLocation | null } scanTomlLine(line, state); } - // A root key that might spell model cannot be ruled out, so a writer must not add a second one. + // A root key that might spell the wanted key cannot be ruled out, so a writer must not add a second one. return undecodedRootKeyLine === null ? null : { line: undecodedRootKeyLine, inRootTable: true, span: null, value: null }; } diff --git a/src/codex/subagent-selectable-models.ts b/src/codex/subagent-selectable-models.ts new file mode 100644 index 00000000000..bf98caea70f --- /dev/null +++ b/src/codex/subagent-selectable-models.ts @@ -0,0 +1,32 @@ +import type { OcxConfig } from "../types"; +import { slugEquals } from "../providers/slug-codec"; +import { catalogModelSlug, type CatalogModel } from "./catalog"; + +/** + * The models a subagent can be pinned to: enabled native slugs first, then visible routed + * models, then any saved roster slot that has since gone out of view. + * + * One list behind both the dashboard's subagent roster and role-model pickers + * (GET /api/subagent-models) and the role auto-assign candidates, so a proposal can only name a + * model the picker would offer. A saved roster slot stays representable after its model is + * disabled elsewhere: the roster UI treats this list as the rows it can render, and a Save of a + * list missing that slot would silently truncate the persisted roster. + */ +export function subagentSelectableModels( + config: Pick, + models: readonly CatalogModel[], + nativeSlugs: readonly string[], +): string[] { + const disabled = new Set(config.disabledModels ?? []); + const visibleRouted = [...new Set(models + .filter(m => ![...disabled].some(stored => + stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id) + )) + .map(catalogModelSlug))]; + const selectable = [...nativeSlugs.filter(ns => !disabled.has(ns)), ...visibleRouted]; + const selectableSet = new Set(selectable); + return [ + ...selectable, + ...[...new Set(config.subagentModels ?? [])].filter(model => !selectableSet.has(model)), + ]; +} diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 6febc06293f..dd135d90360 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -237,6 +237,12 @@ export const configSchema = z.object({ z.string(), z.array(z.string().trim().min(1)).min(1), ).optional().catch(undefined), + // Advisory input to role auto-assign only; a malformed block falls back to price ranking. + codexRoleTiers: z.object({ + fast: z.array(z.string().trim().min(1)).optional(), + standard: z.array(z.string().trim().min(1)).optional(), + frontier: z.array(z.string().trim().min(1)).optional(), + }).strict().optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), codexClientCompaction: z.boolean().optional().catch(undefined), diff --git a/src/lib/local-chat-completion.ts b/src/lib/local-chat-completion.ts new file mode 100644 index 00000000000..04c60ca0e19 --- /dev/null +++ b/src/lib/local-chat-completion.ts @@ -0,0 +1,144 @@ +/** + * One non-streaming chat completion through this proxy's own /v1/chat/completions. + * + * The way opencodex makes a model call of its own: the request goes back through the router like + * any client's, so every provider the user has configured is reachable and none needs its own + * client here. The routed vision describer and the Codex role sizing both send through it. + * + * Destination: the unauthenticated loopback listener when one is enabled, otherwise the bind + * address (#4236), resolved by localInferenceDestination rather than composing 127.0.0.1 by hand. + * Admission: localAdmissionToken (env token, hardened service token file, first apiKeys entry), + * sent as x-opencodex-api-key and never Authorization, because an admission secret in a + * forwardable header is a forwarding hazard. Never the admin token. + */ +import type { OcxConfig } from "../types"; +import { localAdmissionToken, localInferenceDestination } from "./local-destinations"; +import { signalWithTimeout, cancelBodyOnAbort } from "./abort"; +import { readBoundedResponseBytes } from "./bounded-body"; +import { redactSecretString } from "./redact"; +import { sidecarEnter } from "./sidecar-tracker"; +import { configuredPort } from "../server/auth-cors"; + +export type LocalChatConfig = Pick; + +export interface LocalChatCompletionRequest { + readonly config: LocalChatConfig; + readonly body: Record; + /** Names the caller in error text and logs, e.g. "routed describe". */ + readonly label: string; + /** Log prefix and sidecar breadcrumb, e.g. "vision". */ + readonly logTag: string; + readonly timeoutMs: number; + readonly maxResponseBytes: number; + /** + * Count raw bytes while reading and cancel the body at the bound. Unset keeps the routed + * describer's bound: the whole body is read, then its UTF-16 length is compared. + */ + readonly boundWhileStreaming?: boolean; + readonly headers?: Record; + readonly abortSignal?: AbortSignal; + /** Test seam; production always self-fetches the resolved local destination. */ + readonly baseUrlOverride?: string; +} + +export type LocalChatCompletionOutcome = { text: string; error?: string }; + +function destination(config: Pick) { + // config.port can be 0 (ephemeral bind, tests) or stale after a live port override; the + // server records its ACTUAL bound port via setCorsOrigin at startup, so prefer that when + // config carries no positive port. configuredPort() is itself 0 when _corsOrigin has no + // explicit port (a default-port origin), so the literal default has to backstop it or the + // composed URL names port 0 and the self-fetch cannot connect. + const port = config.port && config.port > 0 + ? config.port + : Number(configuredPort()) || 10_100; + return localInferenceDestination(config, port); +} + +export function localChatCompletionBaseUrl( + config: Pick, +): string { + return destination(config).origin; +} + +export async function postLocalChatCompletion(request: LocalChatCompletionRequest): Promise { + const { config, label, logTag } = request; + const headers: Record = { "Content-Type": "application/json", ...request.headers }; + const admission = localAdmissionToken(config); + if (admission) headers["x-opencodex-api-key"] = admission; + // A bind that demands admission with no resolvable credential would return 401 with a body + // the caller reports as a failure; naming the cause once is the difference between "it is + // broken" and a fixable configuration note. + if (!admission && !request.baseUrlOverride && destination(config).requiresAdmissionToken) { + console.warn( + `[${logTag}] ${label} has no opencodex data-plane credential for ` + + `${localChatCompletionBaseUrl(config)} — the self-fetch will be refused.`, + ); + } + + const linkedSignal = signalWithTimeout(request.timeoutMs, request.abortSignal); + const sidecarExit = sidecarEnter(logTag); + const t0 = Date.now(); + try { + const res = await fetch(`${request.baseUrlOverride ?? localChatCompletionBaseUrl(config)}/v1/chat/completions`, { + method: "POST", + headers, + body: JSON.stringify({ ...request.body, stream: false }), + signal: linkedSignal.signal, + redirect: "manual", + }); + const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); + try { + let raw: string; + if (request.boundWhileStreaming) { + const read = await readBoundedResponseBytes(res, { maxBytes: request.maxResponseBytes, signal: linkedSignal.signal }); + if (read.oversized) return { text: "", error: `${label} response exceeded byte bound` }; + raw = new TextDecoder().decode(read.bytes); + } else { + raw = await res.text(); + if (raw.length > request.maxResponseBytes) { + return { text: "", error: `${label} response exceeded byte bound` }; + } + } + if (!res.ok) { + return { text: "", error: `${label} HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` }; + } + let payload: unknown; + try { payload = JSON.parse(raw); } catch { + return { text: "", error: `${label} returned non-JSON` }; + } + const content = extractChatContent(payload); + if (!content) return { text: "", error: `${label} returned no text` }; + return { text: content }; + } finally { + detachBodyGuard(); + } + } catch (e) { + const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; + console.warn(`[${logTag}] ${label} ${kind} (${Date.now() - t0}ms)`); + return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) }; + } finally { + sidecarExit(); + linkedSignal.cleanup(); + } +} + +function extractChatContent(payload: unknown): string | undefined { + if (!payload || typeof payload !== "object") return undefined; + const choices = (payload as { choices?: unknown }).choices; + if (!Array.isArray(choices) || choices.length === 0) return undefined; + const message = (choices[0] as { message?: unknown })?.message; + if (!message || typeof message !== "object") return undefined; + const content = (message as { content?: unknown }).content; + if (typeof content === "string" && content.trim().length > 0) return content; + // Some adapters emit content parts; join text parts. + if (Array.isArray(content)) { + const joined = content + .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" + ? (part as { text: string }).text + : "")) + .join(""); + if (joined.trim().length > 0) return joined; + } + return undefined; +} diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index e4b5750534d..7956af8e449 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -729,20 +729,13 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // the first five eligible visible rows by display priority; OCX guidance uses natural ranks. if (url.pathname === "/api/subagent-models" && req.method === "GET") { const models = await (deps.fetchAllModels ?? fetchAllModels)(config); - const disabled = new Set(config.disabledModels ?? []); // Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the // catalog, just buried by priority. List them first so the user can feature them over routed. - const { listCatalogNativeSlugs } = await import("../../codex/catalog"); - const visibleRouted = [...new Set(models - .filter(m => ![...disabled].some(stored => - stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id) - )) - .map(catalogModelSlug))]; + const [{ listCatalogNativeSlugs }, { subagentSelectableModels }] = await Promise.all([ + import("../../codex/catalog"), + import("../../codex/subagent-selectable-models"), + ]); const chosen = config.subagentModels ?? []; - const selectable = [ - ...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)), - ...visibleRouted, - ]; // A saved roster slot must stay representable even after its model is disabled // elsewhere (Models page, provider allowlist, a provider row going away). The // dashboard treats `available` as the set of rows it can render, so a chosen id @@ -751,11 +744,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise // deliberate 5-model roster to an unrelated visibility toggle is data loss, not a // filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a // model the user already excluded so its switch remains reachable. - const selectableSet = new Set(selectable); - const available = [ - ...selectable, - ...[...new Set(chosen)].filter(model => !selectableSet.has(model)), - ]; + const available = subagentSelectableModels(config, models, listCatalogNativeSlugs()); // #857: let CLI/GUI show when a running Codex app-server keeps an older // in-memory catalog than the one on disk. Bounded request-path read: the synchronous // collector blocked the event loop for the whole Windows CIM walk (4-7s measured). diff --git a/src/server/management/codex-agent-role-routes.ts b/src/server/management/codex-agent-role-routes.ts index a8b8eff53b9..bcd5562e8fd 100644 --- a/src/server/management/codex-agent-role-routes.ts +++ b/src/server/management/codex-agent-role-routes.ts @@ -11,9 +11,17 @@ import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import type { ManagementContext } from "./context"; const ROLE_PATH_PREFIX = "/api/codex-agent-roles/"; +const AUTO_ASSIGN_PATH = "/api/codex-agent-roles/auto-assign"; + +function lazycodexNotDetected(ctx: ManagementContext): Response { + return jsonResponse({ + error: "omo (Codex / LazyCodex) is not installed in this CODEX_HOME", + code: "lazycodex_not_detected", + }, 409, ctx.req, ctx.config); +} export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promise { - const { req, url, config } = ctx; + const { req, url, config, deps } = ctx; const [{ getCodexHome }, roles, omo, { detectLazyCodex }] = await Promise.all([ import("../../codex/paths"), import("../../codex/agent-role-models"), @@ -37,21 +45,49 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis }, 200, req, config); } + if (url.pathname === AUTO_ASSIGN_PATH && req.method === "POST") { + const codexHome = getCodexHome(); + if (!detectLazyCodex(codexHome).detected) return lazycodexNotDetected(ctx); + let body: { model?: unknown }; + try { + body = await readManagementJsonBody(req); + } catch (error) { + rethrowManagementBodyTooLarge(error); + return jsonResponse({ error: "invalid JSON body" }, 400, req, config); + } + if (body?.model !== undefined && typeof body.model !== "string") { + return jsonResponse({ error: "model must be a string", code: "invalid_model" }, 400, req, config); + } + const [{ proposeCodexRoleModels, NoSizingModelError }, { fetchAllModels }] = await Promise.all([ + import("./codex-role-auto-assign"), + import("./shared"), + ]); + try { + return jsonResponse(await proposeCodexRoleModels({ + config, + codexHome, + ...(typeof body?.model === "string" ? { sizingModel: body.model } : {}), + fetchAllModels: deps.fetchAllModels ?? fetchAllModels, + ...(deps.completeCodexRoleSizing ? { completeRoleSizing: deps.completeCodexRoleSizing } : {}), + }), 200, req, config); + } catch (error) { + if (error instanceof NoSizingModelError) { + return jsonResponse({ error: error.message, code: "no_sizing_model" }, 409, req, config); + } + throw error; + } + } + if (!url.pathname.startsWith(ROLE_PATH_PREFIX) || req.method !== "PUT") return null; const codexHome = getCodexHome(); - if (!detectLazyCodex(codexHome).detected) { - return jsonResponse({ - error: "omo (Codex / LazyCodex) is not installed in this CODEX_HOME", - code: "lazycodex_not_detected", - }, 409, req, config); - } + if (!detectLazyCodex(codexHome).detected) return lazycodexNotDetected(ctx); let role: string; try { role = decodeURIComponent(url.pathname.slice(ROLE_PATH_PREFIX.length)); } catch { return jsonResponse({ error: "role must be URL-encoded", code: "invalid_role" }, 400, req, config); } - let body: { model?: unknown }; + let body: { model?: unknown; effort?: unknown }; try { body = await readManagementJsonBody(req); } catch (error) { @@ -60,13 +96,15 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } let model: string; + let effort: string | undefined; let toml: { status: "written" | "unchanged" }; try { model = roles.validateAgentRoleModel(body?.model); - toml = roles.writeCodexAgentRoleModel(role, model, codexHome); + effort = body?.effort === undefined || body.effort === null ? undefined : roles.validateAgentRoleEffort(body.effort); + toml = roles.writeCodexAgentRoleModel(role, model, codexHome, effort); } catch (error) { if (error instanceof roles.AgentRoleModelError) { - const status = error.code === "unknown_role" ? 404 : error.code === "invalid_model" ? 400 : 409; + const status = error.code === "unknown_role" ? 404 : error.code === "invalid_model" || error.code === "invalid_effort" ? 400 : 409; return jsonResponse({ error: error.message, code: error.code }, status, req, config); } // Filesystem and ownership errors carry absolute paths and UIDs; keep them out of the response. @@ -80,5 +118,5 @@ export async function handleCodexAgentRoleRoutes(ctx: ManagementContext): Promis } catch { omoStatus = "write_failed"; } - return jsonResponse({ ok: true, role, model, toml, omoJsonc: { status: omoStatus } }, 200, req, config); + return jsonResponse({ ok: true, role, model, ...(effort ? { effort } : {}), toml, omoJsonc: { status: omoStatus } }, 200, req, config); } diff --git a/src/server/management/codex-role-auto-assign.ts b/src/server/management/codex-role-auto-assign.ts new file mode 100644 index 00000000000..472f54a8c17 --- /dev/null +++ b/src/server/management/codex-role-auto-assign.ts @@ -0,0 +1,149 @@ +/** + * The read-only half of role auto-assign: size every Codex agent role with one model call, map + * each size to a concrete model and effort, and return the proposals. Nothing is written here; + * the dashboard and "ocx agent roles suggest --apply" apply a proposal through the ordinary + * PUT /api/codex-agent-roles/{role}. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { OcxConfig } from "../../types"; +import type { CatalogModel } from "../../codex/catalog"; +import type { RoleModelCandidate } from "../../codex/role-auto-assign"; + +export interface RoleSizingCall { + readonly model: string; + readonly system: string; + readonly user: string; +} + +export type CompleteRoleSizing = (call: RoleSizingCall, config: OcxConfig) => Promise<{ text: string; error?: string }>; + +const SIZING_TIMEOUT_MS = 180_000; +const MAX_SIZING_RESPONSE_BYTES = 1024 * 1024; + +/** + * The completion error can carry upstream response text and exception messages, which may + * include paths, account identifiers or secrets the redactor does not recognise. Only a fixed + * category, plus a bare HTTP status, leaves the management response and the CLI. + */ +export function publicSizingError(error: string): string { + const http = /^[\w .-]* HTTP (\d{3}):/.exec(error); + if (http) return `HTTP ${http[1]}`; + if (error.endsWith("response exceeded byte bound")) return "the response was too large"; + if (error.endsWith("returned non-JSON")) return "the response was not JSON"; + if (error.endsWith("returned no text")) return "the response had no text"; + return "the request could not be completed"; +} + +async function completeThroughProxy(call: RoleSizingCall, config: OcxConfig) { + const { postLocalChatCompletion } = await import("../../lib/local-chat-completion"); + return postLocalChatCompletion({ + config, + label: "role sizing", + logTag: "role-sizing", + timeoutMs: SIZING_TIMEOUT_MS, + maxResponseBytes: MAX_SIZING_RESPONSE_BYTES, + boundWhileStreaming: true, + body: { + model: call.model, + messages: [{ role: "system", content: call.system }, { role: "user", content: call.user }], + }, + }); +} + +async function roleModelCandidates(config: OcxConfig, models: readonly CatalogModel[]): Promise { + const [catalog, { subagentSelectableModels }, { resolveMatchedPrice }] = await Promise.all([ + import("../../codex/catalog"), + import("../../codex/subagent-selectable-models"), + import("../../usage/cost"), + ]); + const nativeSlugs = catalog.listCatalogNativeSlugs(); + const routed = new Map(models.map(model => [catalog.catalogModelSlug(model), model])); + const unitPrice = (provider: string, modelId: string): number | null => { + const cost = resolveMatchedPrice(provider, modelId)?.cost4; + return cost ? cost.input + cost.output : null; + }; + return subagentSelectableModels(config, models, nativeSlugs).map(slug => { + const row = routed.get(slug); + if (row) { + return { + model: slug, + unitPrice: unitPrice(row.provider, row.id), + efforts: row.reasoningEfforts ?? [], + ...(row.defaultReasoningEffort ? { defaultEffort: row.defaultReasoningEffort } : {}), + }; + } + if (nativeSlugs.includes(slug)) { + const defaultEffort = catalog.nativeDefaultReasoningEffort(slug); + return { + model: slug, + unitPrice: unitPrice("openai", slug), + efforts: catalog.nativeReasoningEfforts(slug), + ...(defaultEffort ? { defaultEffort } : {}), + }; + } + return { model: slug, unitPrice: null, efforts: [] }; + }); +} + +export class NoSizingModelError extends Error {} + +export async function proposeCodexRoleModels(options: { + config: OcxConfig; + codexHome: string; + sizingModel?: string; + fetchAllModels: (config: OcxConfig) => Promise; + completeRoleSizing?: CompleteRoleSizing; +}) { + const [roles, sizing, assign, { readConfiguredDefaultModel }] = await Promise.all([ + import("../../codex/agent-role-models"), + import("../../codex/role-sizing"), + import("../../codex/role-auto-assign"), + import("../../codex/catalog/parsing"), + ]); + const sizingModel = options.sizingModel?.trim() || readConfiguredDefaultModel(); + if (!sizingModel) throw new NoSizingModelError("no default model is set in Codex config.toml; pass a sizing model"); + + const roleRows = roles.listCodexAgentRoleModels(options.codexHome).map(row => ({ + role: row.role, + model: row.model, + effort: roles.readCodexAgentRoleEffort(row.role, options.codexHome), + })); + const inputs: { role: string; instructions: string }[] = []; + const outcomes = new Map(); + for (const row of roleRows) { + let excerpt: string | null = null; + try { + excerpt = sizing.roleInstructionsExcerpt(readFileSync(join(options.codexHome, "agents", `${row.role}.toml`), "utf8")); + } catch { /* an unreadable role is reported unsized below */ } + if (excerpt === null) outcomes.set(row.role, { unsized: "the role file has no description or developer_instructions to size from" }); + else inputs.push({ role: row.role, instructions: excerpt }); + } + + let sizingError: string | null = null; + if (inputs.length > 0) { + const answer = await (options.completeRoleSizing ?? completeThroughProxy)({ + model: sizingModel, + system: sizing.ROLE_SIZING_SYSTEM_PROMPT, + user: sizing.buildRoleSizingUserMessage(inputs), + }, options.config); + const names = inputs.map(input => input.role); + if (answer.error) { + sizingError = publicSizingError(answer.error); + for (const role of names) outcomes.set(role, { unsized: `the sizing call failed: ${sizingError}` }); + } else { + for (const [role, outcome] of sizing.parseRoleSizingResponse(answer.text, names)) outcomes.set(role, outcome); + } + } + + const classified = assign.classifyRoleModelCandidates( + await roleModelCandidates(options.config, await options.fetchAllModels(options.config)), + options.config.codexRoleTiers, + ); + return { + sizingModel, + sizingError, + proposals: assign.buildRoleProposals(roleRows, outcomes, classified), + candidates: classified.map(({ model, tier, tierSource, unitPrice }) => ({ model, tier, tierSource, unitPrice })), + }; +} diff --git a/src/server/management/context.ts b/src/server/management/context.ts index b9a0bba0644..5945f842d02 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -84,6 +84,8 @@ export interface ManagementApiDeps { * Tests stub it to orphan the fixture file mid-fetch (the r7 recheck test). */ fetchAllModels?: (config: OcxConfig) => Promise; + /** Codex role auto-assign's one sizing model call; route tests answer it without a provider. */ + completeCodexRoleSizing?: import("./codex-role-auto-assign").CompleteRoleSizing; /** * Writer seam for the Grok toggle: lets a test place the file in any state * between the pre-write recheck and the write itself (the r8 post-inspection diff --git a/src/server/management/route-registry.ts b/src/server/management/route-registry.ts index cd48d44e31f..c210d87e768 100644 --- a/src/server/management/route-registry.ts +++ b/src/server/management/route-registry.ts @@ -168,6 +168,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [ { method: "PUT", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: true }, // server/management/codex-agent-role-routes { method: "GET", path: "/api/codex-agent-roles", module: "server/management/codex-agent-role-routes", mutates: false }, + { method: "POST", path: "/api/codex-agent-roles/auto-assign", module: "server/management/codex-agent-role-routes", mutates: false, mechanism: "path-constant" }, { method: "PUT", path: "/api/codex-agent-roles/{role}", module: "server/management/codex-agent-role-routes", mutates: true, mechanism: "prefix-decode" }, // server/management/aside-profile-routes { method: "GET", path: "/api/client-integrations/aside", module: "server/management/aside-profile-routes", mutates: false, mechanism: "path-constant", exempt: { reason: "compatibility-alias", why: "Legacy Aside status alias; the current CLI reads the same aggregate through GET /api/client-integrations/aside/profiles." } }, diff --git a/src/types/config.ts b/src/types/config.ts index 1c0f7c5c83c..97828712620 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -651,6 +651,11 @@ export interface OcxConfig { * reject the whole role file as an unknown field (#1190). */ subagentModelFallbackByModel?: Record; + /** + * Capability tier of named models for Codex role auto-assign, overriding its price-rank + * classification. Models no list names and no price covers are never proposed. + */ + codexRoleTiers?: { fast?: string[]; standard?: string[]; frontier?: string[] }; /** * TTL (ms) for cached sub-agent model availability probes. Default 60_000. */ diff --git a/src/vision/routed-describe.ts b/src/vision/routed-describe.ts index dc0af782182..4b1cf4a5e79 100644 --- a/src/vision/routed-describe.ts +++ b/src/vision/routed-describe.ts @@ -19,18 +19,13 @@ * admission secret in a forwardable header is a forwarding hazard). Loopback * binds require no token at all (resolveApiAuth admits loopback). * - * Destination (#4236): the unauthenticated loopback listener when one is - * enabled, otherwise the BIND address — the former roadmap-170 limitation - * ("a bindHost where 127.0.0.1 does not answer cannot reach its own - * loopback") is closed by resolving through `localInferenceDestination` - * rather than composing 127.0.0.1 by hand. + * Destination (#4236) and transport: postLocalChatCompletion in + * src/lib/local-chat-completion.ts, which resolves the unauthenticated + * loopback listener when one is enabled and otherwise the BIND address. */ import type { OcxConfig } from "../types"; -import { localAdmissionToken, localInferenceDestination } from "../lib/local-destinations"; -import { signalWithTimeout, cancelBodyOnAbort } from "../lib/abort"; -import { redactSecretString } from "../lib/redact"; -import { sidecarEnter } from "../lib/sidecar-tracker"; -import { configuredPort } from "../server/auth-cors"; +import { localAdmissionToken } from "../lib/local-destinations"; +import { localChatCompletionBaseUrl, postLocalChatCompletion } from "../lib/local-chat-completion"; import type { DescribeOutcome, VisionSettings } from "./describe"; export const VISION_DESCRIBE_TERMINAL_HEADER = "x-opencodex-vision-describe"; @@ -77,28 +72,7 @@ export function routedDescribeAdmissionToken(config: Pick) export function routedDescribeBaseUrl( config: Pick, ): string { - return routedDescribeDestination(config).origin; -} - -/** - * The local destination this self-fetch dials, and whether it needs a credential. - * - * This is a local client like any other: the unauthenticated loopback listener when one is - * enabled, otherwise the bind address — on a tailnet-bound hub there is no loopback socket at - * all (#4236). The helper sends the OpenAI chat wire, which that listener now admits. - */ -function routedDescribeDestination( - config: Pick, -) { - // config.port can be 0 (ephemeral bind, tests) or stale after a live port override; the - // server records its ACTUAL bound port via setCorsOrigin at startup, so prefer that when - // config carries no positive port. `configuredPort()` is itself `0` when `_corsOrigin` has no - // explicit port (a default-port origin), so the literal default has to backstop it or the - // composed URL names port 0 and the self-fetch cannot connect. - const port = config.port && config.port > 0 - ? config.port - : Number(configuredPort()) || 10_100; - return localInferenceDestination(config, port); + return localChatCompletionBaseUrl(config); } export async function describeImageRouted( @@ -114,93 +88,27 @@ export async function describeImageRouted( const invalid = validateImageUrl(imageUrl); if (invalid) return { text: "", error: invalid }; - const headers: Record = { - "Content-Type": "application/json", - [VISION_DESCRIBE_TERMINAL_HEADER]: "1", - }; - const admission = routedDescribeAdmissionToken(config); - if (admission) headers["x-opencodex-api-key"] = admission; - // A bind that demands admission with no resolvable credential would return 401 with a body - // the caller reports as a describe failure; naming the cause once is the difference between - // "vision is broken" and a fixable configuration note. - if (!admission && !baseUrlOverride && routedDescribeDestination(config).requiresAdmissionToken) { - console.warn( - "[vision] routed describe has no opencodex data-plane credential for " - + `${routedDescribeBaseUrl(config)} — the self-fetch will be refused.`, - ); - } - - const requestBody = { - model: routedModel, - stream: false, - messages: [ - { role: "system", content: DESCRIBE_INSTRUCTION }, - { - role: "user", - content: [ - ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), - { type: "image_url", image_url: { url: imageUrl } }, - ], - }, - ], - }; - - const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal); - const sidecarExit = sidecarEnter("vision"); - const t0 = Date.now(); - try { - const res = await fetch(`${baseUrlOverride ?? routedDescribeBaseUrl(config)}/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify(requestBody), - signal: linkedSignal.signal, - redirect: "manual", - }); - const detachBodyGuard = cancelBodyOnAbort(res.body, linkedSignal.signal); - try { - const raw = await res.text(); - if (raw.length > MAX_ROUTED_RESPONSE_BYTES) { - return { text: "", error: "routed describe response exceeded byte bound" }; - } - if (!res.ok) { - return { text: "", error: `routed describe HTTP ${res.status}: ${redactSecretString(raw.slice(0, 200))}` }; - } - let payload: unknown; - try { payload = JSON.parse(raw); } catch { - return { text: "", error: "routed describe returned non-JSON" }; - } - const content = extractChatContent(payload); - if (!content) return { text: "", error: "routed describe returned no text" }; - return { text: content }; - } finally { - detachBodyGuard(); - } - } catch (e) { - const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; - console.warn(`[vision] routed describe ${kind} (${Date.now() - t0}ms)`); - return { text: "", error: redactSecretString(e instanceof Error ? e.message : String(e)) }; - } finally { - sidecarExit(); - linkedSignal.cleanup(); - } -} - -function extractChatContent(payload: unknown): string | undefined { - if (!payload || typeof payload !== "object") return undefined; - const choices = (payload as { choices?: unknown }).choices; - if (!Array.isArray(choices) || choices.length === 0) return undefined; - const message = (choices[0] as { message?: unknown })?.message; - if (!message || typeof message !== "object") return undefined; - const content = (message as { content?: unknown }).content; - if (typeof content === "string" && content.trim().length > 0) return content; - // Some adapters emit content parts; join text parts. - if (Array.isArray(content)) { - const joined = content - .map(part => (part && typeof part === "object" && typeof (part as { text?: unknown }).text === "string" - ? (part as { text: string }).text - : "")) - .join(""); - if (joined.trim().length > 0) return joined; - } - return undefined; + return postLocalChatCompletion({ + config, + label: "routed describe", + logTag: "vision", + timeoutMs: settings.timeoutMs, + maxResponseBytes: MAX_ROUTED_RESPONSE_BYTES, + headers: { [VISION_DESCRIBE_TERMINAL_HEADER]: "1" }, + abortSignal, + baseUrlOverride, + body: { + model: routedModel, + messages: [ + { role: "system", content: DESCRIBE_INSTRUCTION }, + { + role: "user", + content: [ + ...(contextText ? [{ type: "text", text: `User's request context: ${contextText}` }] : []), + { type: "image_url", image_url: { url: imageUrl } }, + ], + }, + ], + }, + }); } diff --git a/structure/subagents.md b/structure/subagents.md index 0c477c5e12e..5c263686f9c 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -344,6 +344,39 @@ catalog path calls it, and opencodex never creates, repairs, or removes a role f Sibling instances refuse the write, because it reaches the shared `CODEX_HOME`. +### Role model auto-assign + +`POST /api/codex-agent-roles/auto-assign` (dashboard Auto-assign, `ocx agent roles suggest`) proposes a +model for every role and writes nothing. It belongs to omo (Codex / LazyCodex): without +`detectLazyCodex()` it answers 409 `lazycodex_not_detected` before reading the body or calling a model, +the same refusal as the PUT. Applying a proposal is the ordinary +`PUT /api/codex-agent-roles/{role}`, now with an optional `effort`. The work splits in two on purpose: + +- **Sizing is one model call.** `src/codex/role-sizing.ts` holds the rubric (ported from the MIT-licensed + modelchk skill, cited in the file) as the system prompt, the neutral vocabulary + (`fast|standard|frontier`, `glance|measured|thorough|exhaustive`), and the validator. Each role sends its + name and the first 1500 characters of `description` plus `developer_instructions`; a role with neither + is not sent. The call goes through the proxy's own `/v1/chat/completions` via + `postLocalChatCompletion` (`src/lib/local-chat-completion.ts`, shared with the routed vision describer), on + the root `model` of Codex `config.toml` unless the caller names one. An answer that is not the strict JSON + shape, a missing role, or a field outside the vocabulary leaves that role **unsized** with the reason; + nothing is guessed. Keys beyond the five answer fields are ignored, and all five stay required. The + sizing model never names a model. +- **Mapping is deterministic code.** `src/codex/role-auto-assign.ts` draws candidates from + `subagentSelectableModels` (the same list the role picker renders) and tiers them: `codexRoleTiers` in the + opencodex config first, then price rank (input plus output per 1M tokens). Three or more priced models + split evenly across the three tiers. Fewer are anchored at the top, the dearest frontier and each cheaper + one a tier lower, so one is frontier and two are standard and frontier; fast is the tier left empty, + which keeps a standard role on the cheaper model. Unpriced, unmapped models are never proposed. A role + gets the lowest sufficient tier, then the lowest price. Effort binds to ladder positions of the chosen + model (floor, default or middle, the rung above, ceiling), collapsing inside the range; it is proposed + only for a role whose file already sets `model_reasoning_effort`, and written by the same span-preserving + editor as `model`, located by the same TOML-aware scan. + +The sizing call is injectable (`completeCodexRoleSizing` on the management deps), so route tests answer it +without a provider. Sibling instances refuse the preview too: it sits under the refused +`/api/codex-agent-roles` prefix, and its proposals could not be applied there anyway. + `injectionModel` and `injectionEffort` are shared selections with two independent consumers. `multiAgentGuidanceEnabled` controls only OpenCodex-authored delegation guidance. `syncCodexSubagentDefaults` is a separate, default-off opt-in that applies the selected values to diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index b252eea4430..f1ce4b0290e 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -961,6 +961,110 @@ describe("headless GUI parity CLI", () => { ]); }); + test("agent roles suggest prints proposals, and --apply writes only proposed roles through PUT", async () => { + const proposals = { + sizingModel: "gpt-5.5", + proposals: [ + { role: "explorer", model: "gpt-5.5", status: "proposed", tier: "fast", effortIntent: "glance", proposedModel: "a/small", proposedEffort: "low" }, + { role: "worker", model: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null }, + { role: "vague", model: null, status: "unsized", reason: "no JSON" }, + ], + }; + const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + try { + expect(await handleAgentCommand(["roles", "suggest", "--model", "a/sizer", "--json"], runtime.deps)).toBe(0); + expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0); + } finally { + logSpy.mockRestore(); + } + expect(runtime.requests).toEqual([ + { path: "/api/codex-agent-roles/auto-assign", method: "POST", body: { model: "a/sizer" } }, + { path: "/api/codex-agent-roles/auto-assign", method: "POST", body: {} }, + { path: "/api/codex-agent-roles/explorer", method: "PUT", body: { model: "a/small", effort: "low" } }, + { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } }, + ]); + }); + + test("agent roles suggest --apply skips proposals that already match the role's pin and says so", async () => { + const proposals = { + sizingModel: "gpt-5.5", + proposals: [ + { role: "explorer", model: "a/small", effort: "low", status: "proposed", tier: "fast", effortIntent: "glance", proposedModel: "a/small", proposedEffort: "low" }, + { role: "reviewer", model: "a/mid", effort: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null }, + { role: "planner", model: "a/mid", effort: "low", status: "proposed", tier: "standard", effortIntent: "thorough", proposedModel: "a/mid", proposedEffort: "high" }, + { role: "worker", model: null, effort: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null }, + ], + }; + const runtime = fakeRuntime(req => req.method === "POST" ? proposals : { ok: true }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + let output = ""; + try { + expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0); + output = logSpy.mock.calls.flat().join("\n"); + } finally { + logSpy.mockRestore(); + } + expect(runtime.requests).toEqual([ + { path: "/api/codex-agent-roles/auto-assign", method: "POST", body: {} }, + { path: "/api/codex-agent-roles/planner", method: "PUT", body: { model: "a/mid", effort: "high" } }, + { path: "/api/codex-agent-roles/worker", method: "PUT", body: { model: "a/mid" } }, + ]); + expect(output).toContain("Applied 2 of 4 roles. Skipped 2 already set: explorer, reviewer."); + }); + + test("agent roles suggest --apply names each role whose omo.jsonc mirror was not written", async () => { + const proposals = { + sizingModel: "gpt-5.5", + proposals: ["explorer", "reviewer", "planner", "worker"].map(role => ({ + role, model: null, effort: null, status: "proposed", tier: "standard", effortIntent: "measured", proposedModel: "a/mid", proposedEffort: null, + })), + }; + const mirror: Record = { explorer: "written", reviewer: "write_failed", planner: "invalid", worker: "skipped_comments" }; + const runtime = fakeRuntime(req => req.method === "POST" + ? proposals + : { ok: true, toml: { status: "written" }, omoJsonc: { status: mirror[new URL(req.url).pathname.split("/").pop()!] } }); + const logSpy = spyOn(console, "log").mockImplementation(() => {}); + let output = ""; + let json: { applied?: unknown[]; omoJsoncNotWritten?: unknown } = {}; + try { + expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).toBe(0); + output = logSpy.mock.calls.flat().join("\n"); + logSpy.mockClear(); + expect(await handleAgentCommand(["roles", "suggest", "--apply", "--json"], runtime.deps)).toBe(0); + json = JSON.parse(String(logSpy.mock.calls.flat().join(""))); + } finally { + logSpy.mockRestore(); + } + expect(output).toContain("Applied 4 of 4 roles."); + expect(output).toContain("reviewer: omo.jsonc not written (write_failed)"); + expect(output).toContain("planner: omo.jsonc not written (invalid)"); + expect(output).toContain("worker: omo.jsonc not written (skipped_comments)"); + expect(output).not.toContain("explorer: omo.jsonc"); + expect(json.applied).toHaveLength(4); + expect(json.omoJsoncNotWritten).toEqual([ + { role: "reviewer", status: "write_failed" }, + { role: "planner", status: "invalid" }, + { role: "worker", status: "skipped_comments" }, + ]); + }); + + test("agent roles suggest --apply stops at the lazycodex_not_detected refusal and writes nothing", async () => { + const runtime = fakeRuntime(req => req.method === "POST" + ? Response.json({ error: "omo (Codex / LazyCodex) is not installed in this CODEX_HOME", code: "lazycodex_not_detected" }, { status: 409 }) + : { ok: true }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + let output = ""; + try { + expect(await handleAgentCommand(["roles", "suggest", "--apply"], runtime.deps)).not.toBe(0); + output = errorSpy.mock.calls.flat().join("\n"); + } finally { + errorSpy.mockRestore(); + } + expect(runtime.requests).toEqual([{ path: "/api/codex-agent-roles/auto-assign", method: "POST", body: {} }]); + expect(output).toContain("omo (Codex / LazyCodex) is not installed"); + }); + test("API key create returns the one-time key through the access command", async () => { const runtime = fakeRuntime((req) => new URL(req.url).pathname === "/api/keys" ? { id: "key-1", name: "deploy", key: "ocx_secret" } diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 4303c879bac..7676548a0e5 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1692,9 +1692,13 @@ "subagent-model-fallback-api.test.ts": "routing", "subagent-model-fallback.test.ts": "routing", "codex-agent-role-models.test.ts": "routing", + "codex-role-sizing.test.ts": "routing", + "codex-role-auto-assign.test.ts": "routing", "omo-role-models.test.ts": "clients", "lazycodex-detection.test.ts": "clients", "codex-agent-role-routes.test.ts": "server", + "codex-role-auto-assign-routes.test.ts": "server", + "local-chat-completion.test.ts": "lib", "subagent-roster-retention.test.ts": "routing", "sync-client-integrations.test.ts": "clients", "synthetic-tool.test.ts": "images", diff --git a/tests/lib/local-chat-completion.test.ts b/tests/lib/local-chat-completion.test.ts new file mode 100644 index 00000000000..4da65403a46 --- /dev/null +++ b/tests/lib/local-chat-completion.test.ts @@ -0,0 +1,80 @@ +import { afterEach, expect, test } from "bun:test"; +import { postLocalChatCompletion } from "../../src/lib/local-chat-completion"; +import { describeImageRouted } from "../../src/vision/routed-describe"; + +let server: ReturnType | null = null; + +afterEach(async () => { + await server?.stop(true); + server = null; +}); + +const CONFIG = { port: 0, hostname: "127.0.0.1", apiKeys: [] }; + +function completion(content: string): string { + return JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }); +} + +function serve(respond: () => Response): string { + server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: respond }); + return `http://127.0.0.1:${server.port}`; +} + +function send(baseUrlOverride: string, maxResponseBytes: number) { + return postLocalChatCompletion({ + config: CONFIG, + body: { model: "m", messages: [] }, + label: "role sizing", + logTag: "role-sizing", + timeoutMs: 5_000, + maxResponseBytes, + boundWhileStreaming: true, + baseUrlOverride, + }); +} + +test("returns the completion text when the body fits the byte bound", async () => { + const base = serve(() => new Response(completion("small"))); + expect(await send(base, 1024)).toEqual({ text: "small" }); +}); + +test("stops reading an endless body at the byte bound and cancels it", async () => { + let cancelled = false; + const chunk = new TextEncoder().encode("x".repeat(16 * 1024)); + const base = serve(() => new Response(new ReadableStream({ + pull(controller) { controller.enqueue(chunk); }, + cancel() { cancelled = true; }, + }))); + const outcome = await send(base, 64 * 1024); + expect(outcome).toEqual({ text: "", error: "role sizing response exceeded byte bound" }); + for (let i = 0; i < 50 && !cancelled; i += 1) await Bun.sleep(20); + expect(cancelled).toBe(true); +}); + +test("counts UTF-8 bytes, not UTF-16 units, against the bound", async () => { + const body = completion("가".repeat(1_000)); + expect(body.length).toBeLessThan(2_048); + expect(new TextEncoder().encode(body).byteLength).toBeGreaterThan(2_048); + const base = serve(() => new Response(body)); + expect(await send(base, 2_048)).toEqual({ text: "", error: "role sizing response exceeded byte bound" }); +}); + +test("the routed vision describer keeps its upstream UTF-16 length bound", async () => { + const caption = "가".repeat(2 * 1024 * 1024); + const body = completion(caption); + expect(body.length).toBeLessThan(4 * 1024 * 1024); + expect(new TextEncoder().encode(body).byteLength).toBeGreaterThan(4 * 1024 * 1024); + const base = serve(() => new Response(body)); + const outcome = await describeImageRouted( + "data:image/png;base64,aGVsbG8=", + undefined, + "", + "vlm/qwen-vl", + CONFIG, + { model: "vlm/qwen-vl", reasoning: "low", timeoutMs: 10_000 }, + undefined, + base, + ); + expect(outcome.error).toBeUndefined(); + expect(outcome.text.length).toBe(caption.length); +}); diff --git a/tests/routing/codex-agent-role-models.test.ts b/tests/routing/codex-agent-role-models.test.ts index f8cfca6f347..d96d411fd70 100644 --- a/tests/routing/codex-agent-role-models.test.ts +++ b/tests/routing/codex-agent-role-models.test.ts @@ -5,7 +5,9 @@ import { join } from "node:path"; import { AgentRoleModelError, listCodexAgentRoleModels, + readCodexAgentRoleEffort, setTomlRootModel, + setTomlRootReasoningEffort, writeCodexAgentRoleModel, } from "../../src/codex/agent-role-models"; @@ -74,6 +76,23 @@ describe("setTomlRootModel", () => { }); }); +describe("setTomlRootReasoningEffort", () => { + const prose = IMPORTED_ROLE.replace("inside prose.", "model_reasoning_effort = \"low\" is prose too."); + + test("replaces only the root effort, not a matching line inside the instructions", () => { + const next = setTomlRootReasoningEffort(prose, "xhigh"); + expect(next).toBe(prose.replace('model_reasoning_effort = "high"\n', 'model_reasoning_effort = "xhigh"\n')); + expect(next).toContain('model_reasoning_effort = "low" is prose too.'); + }); + + test("keeps CRLF, a BOM, a literal quote style and a trailing comment byte for byte", () => { + const crlf = "\ufeff" + prose.replace('model_reasoning_effort = "high"', "model_reasoning_effort = 'high' # tuned").replace(/\n/g, "\r\n"); + const next = setTomlRootReasoningEffort(crlf, "low"); + expect(next).toBe(crlf.replace("'high' # tuned", "'low' # tuned")); + expect(setTomlRootReasoningEffort(next, "low")).toBe(next); + }); +}); + describe("writeCodexAgentRoleModel", () => { let home: string | null = null; afterEach(() => { @@ -126,4 +145,22 @@ describe("writeCodexAgentRoleModel", () => { expect((caught as AgentRoleModelError).code).toBe("invalid_role_file"); expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(broken); }); + + test("writes the model and effort together and changes nothing else", () => { + const dir = codexHome(); + expect(readCodexAgentRoleEffort("explorer", dir)).toBe("high"); + expect(writeCodexAgentRoleModel("explorer", "xai/grok-4.5", dir, "medium")).toEqual({ status: "written" }); + expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(IMPORTED_ROLE + .replace('model = "gpt-5.5" # pinned', 'model = "xai/grok-4.5" # pinned') + .replace('model_reasoning_effort = "high"', 'model_reasoning_effort = "medium"')); + expect(readCodexAgentRoleEffort("explorer", dir)).toBe("medium"); + }); + + test("refuses an unknown effort without writing the model either", () => { + const dir = codexHome(); + for (const effort of ["turbo", "", "high\n"]) { + expect(() => writeCodexAgentRoleModel("explorer", "xai/grok-4.5", dir, effort)).toThrow(AgentRoleModelError); + } + expect(readFileSync(join(dir, "agents", "explorer.toml"), "utf8")).toBe(IMPORTED_ROLE); + }); }); diff --git a/tests/routing/codex-role-auto-assign.test.ts b/tests/routing/codex-role-auto-assign.test.ts new file mode 100644 index 00000000000..1c9c548bd0a --- /dev/null +++ b/tests/routing/codex-role-auto-assign.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { + buildRoleProposals, + cheapestSufficientCandidate, + classifyRoleModelCandidates, + mapEffortToLevel, + type RoleModelCandidate, +} from "../../src/codex/role-auto-assign"; +import type { RoleSizingOutcome } from "../../src/codex/role-sizing"; + +const FULL = ["low", "medium", "high", "xhigh"]; + +function candidate(model: string, unitPrice: number | null, efforts: string[] = FULL, defaultEffort?: string): RoleModelCandidate { + return { model, unitPrice, efforts, ...(defaultEffort ? { defaultEffort } : {}) }; +} + +describe("mapEffortToLevel", () => { + test("binds intents to floor, default, above default and ceiling", () => { + const ladder = ["xhigh", "low", "high", "medium"]; + expect(mapEffortToLevel("glance", ladder, "medium")).toBe("low"); + expect(mapEffortToLevel("measured", ladder, "medium")).toBe("medium"); + expect(mapEffortToLevel("thorough", ladder, "medium")).toBe("high"); + expect(mapEffortToLevel("exhaustive", ladder, "medium")).toBe("xhigh"); + }); + + test("measured falls to the middle rung when the default is absent or off the ladder", () => { + expect(mapEffortToLevel("measured", FULL)).toBe("medium"); + expect(mapEffortToLevel("measured", ["low", "medium", "high"], "ultra")).toBe("medium"); + }); + + test("a ladder with no interior rung collapses thorough and stays inside the range", () => { + expect(mapEffortToLevel("glance", ["low", "high"])).toBe("low"); + expect(mapEffortToLevel("measured", ["low", "high"])).toBe("low"); + expect(mapEffortToLevel("thorough", ["low", "high"])).toBe("high"); + expect(mapEffortToLevel("exhaustive", ["low", "high"])).toBe("high"); + expect(mapEffortToLevel("thorough", ["low", "medium", "high"], "high")).toBe("high"); + for (const intent of ["glance", "measured", "thorough", "exhaustive"] as const) { + expect(mapEffortToLevel(intent, ["medium"])).toBe("medium"); + } + }); + + test("a model without reasoning levels gets no effort", () => { + expect(mapEffortToLevel("thorough", [])).toBeNull(); + expect(mapEffortToLevel("thorough", ["turbo"])).toBeNull(); + }); +}); + +describe("classifyRoleModelCandidates", () => { + test("splits priced models across tiers by price rank and leaves unpriced ones out", () => { + const classified = classifyRoleModelCandidates([ + candidate("big", 30), candidate("small", 1), candidate("mid", 5), candidate("mystery", null), + ]); + expect(classified.map(c => [c.model, c.tier, c.tierSource])).toEqual([ + ["big", "frontier", "price"], ["small", "fast", "price"], ["mid", "standard", "price"], ["mystery", null, null], + ]); + }); + + test("a single priced model is frontier and a user mapping wins over price", () => { + expect(classifyRoleModelCandidates([candidate("only", 3)])[0]!.tier).toBe("frontier"); + const classified = classifyRoleModelCandidates( + [candidate("cheap", 1), candidate("dear", 9), candidate("local", null)], + { fast: ["local"], frontier: ["cheap"] }, + ); + expect(classified.map(c => [c.model, c.tier, c.tierSource])).toEqual([ + ["cheap", "frontier", "mapping"], ["dear", "frontier", "price"], ["local", "fast", "mapping"], + ]); + }); + + test("two priced models take the top two tiers, so a standard role gets the cheaper one", () => { + const classified = classifyRoleModelCandidates([candidate("dear", 9), candidate("cheap", 1)]); + expect(classified.map(c => [c.model, c.tier])).toEqual([["dear", "frontier"], ["cheap", "standard"]]); + expect(cheapestSufficientCandidate(classified, "fast")!.model).toBe("cheap"); + expect(cheapestSufficientCandidate(classified, "standard")!.model).toBe("cheap"); + expect(cheapestSufficientCandidate(classified, "frontier")!.model).toBe("dear"); + }); +}); + +describe("cheapestSufficientCandidate", () => { + const classified = classifyRoleModelCandidates([ + candidate("p20", 20), candidate("p1", 1), candidate("p4", 4), candidate("p05", 0.5), + ]); + + test("prefers the lowest sufficient tier, then the lowest price", () => { + expect(classified.map(c => c.tier)).toEqual(["frontier", "standard", "standard", "fast"]); + expect(cheapestSufficientCandidate(classified, "fast")!.model).toBe("p05"); + expect(cheapestSufficientCandidate(classified, "standard")!.model).toBe("p1"); + expect(cheapestSufficientCandidate(classified, "frontier")!.model).toBe("p20"); + }); + + test("steps up a tier when the requested one is empty and returns null when none reach it", () => { + const noStandard = classifyRoleModelCandidates( + [candidate("fast", null), candidate("top", null)], + { fast: ["fast"], frontier: ["top"] }, + ); + expect(cheapestSufficientCandidate(noStandard, "standard")!.model).toBe("top"); + const onlyFast = classifyRoleModelCandidates([candidate("local", null)], { fast: ["local"] }); + expect(cheapestSufficientCandidate(onlyFast, "standard")).toBeNull(); + }); +}); + +describe("buildRoleProposals", () => { + const sized = (tier: "fast" | "frontier", effort: "glance" | "exhaustive"): RoleSizingOutcome => ({ + sizing: { tier, effort, rationale: "r", moveUpIf: "u", moveDownIf: "d" }, + }); + + test("proposes effort only for roles whose file already sets one, and keeps unsized reasons", () => { + const classified = classifyRoleModelCandidates([candidate("cheap", 1, FULL, "medium"), candidate("dear", 9)]); + const proposals = buildRoleProposals( + [ + { role: "explorer", model: "dear", effort: "high" }, + { role: "worker", model: null, effort: null }, + { role: "vague", model: null, effort: null }, + ], + new Map([["explorer", sized("fast", "glance")], ["worker", sized("frontier", "exhaustive")], ["vague", { unsized: "no JSON" }]]), + classified, + ); + expect(proposals[0]).toMatchObject({ status: "proposed", proposedModel: "cheap", proposedEffort: "low", tier: "fast" }); + expect(proposals[1]).toMatchObject({ status: "proposed", proposedModel: "dear", proposedEffort: null }); + expect(proposals[2]).toEqual({ role: "vague", model: null, effort: null, status: "unsized", reason: "no JSON" }); + }); + + test("reports a role no candidate can serve as unassigned", () => { + const [proposal] = buildRoleProposals( + [{ role: "architect", model: null, effort: null }], + new Map([["architect", sized("frontier", "exhaustive")]]), + classifyRoleModelCandidates([candidate("local", null)], { fast: ["local"] }), + ); + expect(proposal).toMatchObject({ status: "unassigned", proposedModel: null, reason: "no available model is classified frontier or above" }); + }); +}); diff --git a/tests/routing/codex-role-sizing.test.ts b/tests/routing/codex-role-sizing.test.ts new file mode 100644 index 00000000000..b24337c6b22 --- /dev/null +++ b/tests/routing/codex-role-sizing.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { + ROLE_INSTRUCTIONS_EXCERPT_CHARS, + ROLE_SIZING_SYSTEM_PROMPT, + SIZING_EFFORTS, + SIZING_TIERS, + buildRoleSizingUserMessage, + parseRoleSizingResponse, + roleInstructionsExcerpt, +} from "../../src/codex/role-sizing"; + +const GOOD = { + tier: "standard", + effort: "measured", + rationale: "Reads code and reports findings; wrong answers are cheap to catch.", + move_up_if: "It starts deciding architecture.", + move_down_if: "It only greps.", +}; + +describe("role sizing rubric", () => { + test("names every tier and effort and asks for JSON without model names", () => { + for (const word of [...SIZING_TIERS, ...SIZING_EFFORTS]) expect(ROLE_SIZING_SYSTEM_PROMPT).toContain(word); + expect(ROLE_SIZING_SYSTEM_PROMPT).toContain("Do not name concrete model products"); + expect(ROLE_SIZING_SYSTEM_PROMPT).toContain('"move_down_if"'); + }); + + test("the user message carries each role's name and excerpt", () => { + const message = buildRoleSizingUserMessage([{ role: "explorer", instructions: "Search the repo." }]); + expect(message).toContain('"name": "explorer"'); + expect(message).toContain('"instructions": "Search the repo."'); + }); +}); + +describe("roleInstructionsExcerpt", () => { + test("joins description and developer_instructions and cuts at the excerpt length", () => { + const toml = 'name = "x"\ndescription = "Finds files."\ndeveloper_instructions = """\n' + "a".repeat(3000) + '\n"""\n'; + const excerpt = roleInstructionsExcerpt(toml)!; + expect(excerpt.startsWith("Finds files.\n\naaa")).toBe(true); + expect(excerpt.length).toBe(ROLE_INSTRUCTIONS_EXCERPT_CHARS); + }); + + test("is null for a role with nothing to read or a file that is not TOML", () => { + expect(roleInstructionsExcerpt('name = "x"\nmodel = "m"\n')).toBeNull(); + expect(roleInstructionsExcerpt("model = = broken")).toBeNull(); + expect(roleInstructionsExcerpt('\ufeffdescription = "ok"\n')).toBe("ok"); + }); +}); + +describe("parseRoleSizingResponse", () => { + test("accepts a valid answer and one surrounding code fence", () => { + const body = JSON.stringify({ roles: { explorer: GOOD } }); + for (const text of [body, "\x60\x60\x60json\n" + body + "\n\x60\x60\x60"]) { + expect(parseRoleSizingResponse(text, ["explorer"]).get("explorer")).toEqual({ + sizing: { + tier: "standard", + effort: "measured", + rationale: GOOD.rationale, + moveUpIf: GOOD.move_up_if, + moveDownIf: GOOD.move_down_if, + }, + }); + } + }); + + test("marks every role unsized when the answer is not the JSON shape", () => { + for (const text of ["Sure! explorer is standard.", "[]", '{"explorer":{}}', 'Here: {"roles":{}}']) { + const out = parseRoleSizingResponse(text, ["explorer", "worker"]); + expect(out.get("explorer")).toHaveProperty("unsized"); + expect(out.get("worker")).toHaveProperty("unsized"); + } + }); + + test("marks only the bad role unsized and never guesses its fields", () => { + const cases: Record = { + badTier: { ...GOOD, tier: "premium" }, + badEffort: { ...GOOD, effort: "high" }, + noRationale: { ...GOOD, rationale: " " }, + noMoveDown: { tier: GOOD.tier, effort: GOOD.effort, rationale: GOOD.rationale, move_up_if: GOOD.move_up_if }, + notObject: "standard", + }; + const roles = ["ok", ...Object.keys(cases), "missing"]; + const out = parseRoleSizingResponse(JSON.stringify({ roles: { ok: GOOD, ...cases } }), roles); + expect(out.get("ok")).toHaveProperty("sizing"); + for (const role of roles.slice(1)) expect(out.get(role), role).toHaveProperty("unsized"); + expect(out.get("missing")).toEqual({ unsized: "the sizing model did not size this role" }); + }); + + test("ignores keys beyond the five it reads and keeps them out of the sizing", () => { + const out = parseRoleSizingResponse(JSON.stringify({ + roles: { explorer: { ...GOOD, confidence: 0.8, notes: ["cheap"] } }, + }), ["explorer"]); + expect(out.get("explorer")).toEqual({ + sizing: { + tier: "standard", + effort: "measured", + rationale: GOOD.rationale, + moveUpIf: GOOD.move_up_if, + moveDownIf: GOOD.move_down_if, + }, + }); + }); + + test("ignores roles the model invented", () => { + const out = parseRoleSizingResponse(JSON.stringify({ roles: { explorer: GOOD, ghost: GOOD } }), ["explorer"]); + expect([...out.keys()]).toEqual(["explorer"]); + }); +}); diff --git a/tests/server/codex-role-auto-assign-routes.test.ts b/tests/server/codex-role-auto-assign-routes.test.ts new file mode 100644 index 00000000000..b6a155250f3 --- /dev/null +++ b/tests/server/codex-role-auto-assign-routes.test.ts @@ -0,0 +1,156 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { listCatalogNativeSlugs, type CatalogModel } from "../../src/codex/catalog"; +import { handleManagementAPI } from "../../src/server/management-api"; +import type { RoleSizingCall } from "../../src/server/management/codex-role-auto-assign"; +import type { OcxConfig } from "../../src/types"; +import { ManagementRequest as Request } from "../helpers/management-auth"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const EXPLORER = 'name = "explorer"\ndescription = "Read-only search."\nmodel = "stub/big"\nmodel_reasoning_effort = "high"\n'; +const WORKER = 'name = "worker"\ndeveloper_instructions = """\nChange code across modules.\n"""\n'; +const BARE = 'name = "bare"\n'; +const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE }; +let root = ""; +let calls: RoleSizingCall[] = []; +let answer: { text: string; error?: string } = { text: "" }; + +function sized(tier: string, effort: string) { + return { tier, effort, rationale: "why", move_up_if: "up", move_down_if: "down" }; +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "ocx-role-auto-assign-")); + mkdirSync(join(root, "codex", "agents"), { recursive: true }); + writeFileSync(join(root, "codex", "agents", "explorer.toml"), EXPLORER); + writeFileSync(join(root, "codex", "agents", "worker.toml"), WORKER); + writeFileSync(join(root, "codex", "agents", "bare.toml"), BARE); + writeFileSync(join(root, "codex", "config.toml"), 'model = "stub/sizer"\n'); + installLazyCodex(); + process.env.CODEX_HOME = join(root, "codex"); + process.env.HOME = join(root, "home"); + process.env.USERPROFILE = join(root, "home"); + calls = []; + answer = { text: JSON.stringify({ roles: { explorer: sized("fast", "glance"), worker: sized("frontier", "exhaustive") } }) }; +}); + +afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + } + removeTreeWithRetry(root); +}); + +const routed = (id: string, efforts: string[]): CatalogModel => ({ id, provider: "stub", reasoningEfforts: efforts, defaultReasoningEffort: "medium" }); + +const LAZYCODEX_ENABLED = '[plugins."omo@sisyphuslabs"]\nenabled = true\n'; + +function installLazyCodex(): void { + const plugin = join(root, "codex", "plugins", "cache", "sisyphuslabs", "omo", "5.1.1"); + mkdirSync(plugin, { recursive: true }); + writeFileSync(join(plugin, "lazycodex-install.json"), "{}"); + writeFileSync(join(root, "codex", "config.toml"), 'model = "stub/sizer"\n' + LAZYCODEX_ENABLED); +} + +const config = { + port: 10100, + providers: {}, + defaultProvider: "openai", + disabledModels: listCatalogNativeSlugs(), + codexRoleTiers: { fast: ["stub/small"], frontier: ["stub/big"] }, +} as unknown as OcxConfig; + +const deps = { + fetchAllModels: async () => [routed("small", ["low", "medium", "high"]), routed("big", ["low", "medium", "high", "xhigh"])], + completeCodexRoleSizing: async (call: RoleSizingCall) => { + calls.push(call); + return answer; + }, +}; + +async function call(path: string, init?: RequestInit) { + const response = await handleManagementAPI(new Request(`http://localhost${path}`, init), new URL(`http://localhost${path}`), config, deps); + expect(response).not.toBeNull(); + return { status: response!.status, body: await response!.json() as Record }; +} + +function snapshot(): Record { + const dir = join(root, "codex", "agents"); + return Object.fromEntries(readdirSync(dir).map(name => [name, readFileSync(join(dir, name), "utf8")])); +} + +describe("POST /api/codex-agent-roles/auto-assign", () => { + test("proposes a model and effort per role through one sizing call and writes nothing", async () => { + const before = snapshot(); + const result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }); + expect(result.status).toBe(200); + expect(snapshot()).toEqual(before); + expect(calls).toHaveLength(1); + expect(calls[0]!.model).toBe("stub/sizer"); + expect(calls[0]!.user).toContain("Read-only search."); + expect(calls[0]!.user).not.toContain('"bare"'); + expect(result.body.sizingModel).toBe("stub/sizer"); + const byRole = Object.fromEntries((result.body.proposals as any[]).map(p => [p.role, p])); + expect(byRole.explorer).toMatchObject({ status: "proposed", model: "stub/big", effort: "high", proposedModel: "stub/small", proposedEffort: "low", tier: "fast" }); + expect(byRole.worker).toMatchObject({ status: "proposed", proposedModel: "stub/big", proposedEffort: null, tier: "frontier" }); + expect(byRole.bare.status).toBe("unsized"); + }); + + test("an unusable sizing answer or a failed call leaves roles unsized", async () => { + answer = { text: "explorer should be fast" }; + let result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: JSON.stringify({ model: "stub/other" }) }); + expect(calls[0]!.model).toBe("stub/other"); + expect((result.body.proposals as any[]).every(p => p.status === "unsized")).toBe(true); + answer = { text: "", error: "role sizing HTTP 502: boom" }; + result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }); + expect(result.body.sizingError).toBe("HTTP 502"); + expect((result.body.proposals as any[]).find(p => p.role === "worker").reason).toContain("the sizing call failed"); + }); + + test("a failed sizing call never echoes upstream text or exception messages", async () => { + const leaks = [ + "role sizing HTTP 500: /Users/example/.codex/auth.json account=acct_123 key=sk-live-abc", + "connect ECONNREFUSED while opening /Users/example/secret.sock for acct_123", + ]; + for (const error of leaks) { + answer = { text: "", error }; + const result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }); + const serialized = JSON.stringify(result.body); + expect(serialized).not.toContain("/Users/example"); + expect(serialized).not.toContain("acct_123"); + expect(serialized).not.toContain("sk-live-abc"); + } + }); + + test("refuses without a sizing model and never calls one", async () => { + writeFileSync(join(root, "codex", "config.toml"), LAZYCODEX_ENABLED); + const result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: "{}" }); + expect(result.status).toBe(409); + expect(result.body.code).toBe("no_sizing_model"); + expect(calls).toHaveLength(0); + }); + + test("refuses without LazyCodex before reading the body or calling the sizing model", async () => { + writeFileSync(join(root, "codex", "config.toml"), 'model = "stub/sizer"\n'); + const before = snapshot(); + const result = await call("/api/codex-agent-roles/auto-assign", { method: "POST", body: JSON.stringify({ model: "stub/other" }) }); + expect(result.status).toBe(409); + expect(result.body.code).toBe("lazycodex_not_detected"); + expect(calls).toHaveLength(0); + expect(snapshot()).toEqual(before); + }); +}); + +describe("PUT /api/codex-agent-roles/{role} with an effort", () => { + test("writes model and effort together and rejects an unknown effort", async () => { + const put = (body: unknown) => call("/api/codex-agent-roles/explorer", { method: "PUT", body: JSON.stringify(body) }); + expect((await put({ model: "stub/small", effort: "turbo" })).status).toBe(400); + expect(snapshot()["explorer.toml"]).toBe(EXPLORER); + const result = await put({ model: "stub/small", effort: "low" }); + expect(result.body).toMatchObject({ ok: true, model: "stub/small", effort: "low" }); + expect(snapshot()["explorer.toml"]).toBe(EXPLORER.replace("stub/big", "stub/small").replace('"high"', '"low"')); + }); +}); From de8afe2e86e4006220be42dd817d96b7c8523869 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 1 Oct 2026 21:50:21 +0900 Subject: [PATCH 05/61] feat(subagents): suggest a delegation model by sizing the work (carry #6274) (#6389) Carries #6274 by @LilMGenius. It adds Suggest next to the Subagents page's "Model to call first": the user describes the delegated work, one sizing call applies the role-sizing rubric, and the deterministic mapper proposes the cheapest sufficient offered model and an effort. POST /api/injection-model/suggest is read-only, and "Use this" saves through the existing PUT /api/injection-model. On the CLI, ocx agent injection suggest [--apply] skips settings that already match. The maintainer follow-up sanitizes sizing failures on this path as #6367 did for role auto-assign. Co-authored-by: LilMGenius --- .../030_wp3_delegation_suggest.md | 5 + .../content/docs/guides/sub-agent-surface.md | 8 + .../src/content/docs/reference/cli/agents.md | 5 + .../content/docs/reference/management-api.md | 1 + .../subagents-workspace/DelegationSuggest.tsx | 139 +++++++++++++ .../SubagentDelegationSection.tsx | 26 ++- .../SubagentsWorkspace.tsx | 2 + gui/src/i18n/de.ts | 8 + gui/src/i18n/en.ts | 8 + gui/src/i18n/fr.ts | 8 + gui/src/i18n/ja.ts | 8 + gui/src/i18n/ko.ts | 8 + gui/src/i18n/ru.ts | 8 + gui/src/i18n/tr.ts | 8 + gui/src/i18n/vi.ts | 8 + gui/src/i18n/zh-TW.ts | 8 + gui/src/i18n/zh.ts | 8 + gui/src/pages/Subagents.tsx | 1 + .../integrations/LazyCodexRoleAutoAssign.tsx | 23 +-- gui/src/pages/integrations/sizing-labels.ts | 17 ++ gui/src/styles-subagents-workspace.css | 18 ++ .../subagents-delegation-suggest.test.tsx | 182 ++++++++++++++++++ scripts/test-layout/layout.json | 1 + .../ocx/references/01_management_surface.md | 28 ++- src/cli/agent.ts | 57 ++++++ src/cli/capabilities.ts | 23 +++ src/codex/role-auto-assign.ts | 7 +- src/codex/role-sizing-limits.ts | 2 + src/codex/role-sizing.ts | 18 +- .../management/agent-settings-routes.ts | 59 ++++-- .../management/codex-role-auto-assign.ts | 71 ++++++- src/server/management/route-registry.ts | 1 + structure/subagents.md | 16 ++ tests/cli/cli-capabilities.test.ts | 2 - tests/cli/cli-headless-parity.test.ts | 41 ++++ .../injection-model-suggest-routes.test.ts | 175 +++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + tests/routing/codex-role-sizing.test.ts | 8 + .../codex-role-auto-assign-routes.test.ts | 2 + 39 files changed, 973 insertions(+), 46 deletions(-) create mode 100644 gui/src/components/subagents-workspace/DelegationSuggest.tsx create mode 100644 gui/src/pages/integrations/sizing-labels.ts create mode 100644 gui/tests/subagents-delegation-suggest.test.tsx create mode 100644 src/codex/role-sizing-limits.ts create mode 100644 tests/codex-integration/injection-model-suggest-routes.test.ts diff --git a/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md b/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md index 7240b6fe7b3..831261e4bce 100644 --- a/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md +++ b/devlog/_plan/261001_omo_lazycodex_carry/030_wp3_delegation_suggest.md @@ -12,6 +12,11 @@ Source: LilMGenius:feat/subagent-default-sizing head `b180fcec0a4c3ff63b9f230acc ## Acceptance +**Re-verification at P (2026-10-01, dev `da13a02727` = #6367).** Pinned `refs/omo/6274` is still `b180fcec0a`. The replay `6c97ca9a4f..b180fcec0a` on `codex/omo-delegation-suggest` hit one conflict in `gui/src/pages/integrations/LazyCodexRoleAutoAssign.tsx`. #6274 moves `TIER_LABEL`/`EFFORT_LABEL` into `sizing-labels.ts`, while #6367 hoisted `alreadySet` to module scope at the same spot. Resolved by taking #6274's move and keeping the module-scope `alreadySet` (gate 3). The rest applied cleanly (12 commits). + +Gate 4 compatibility repair `881bb588f4` "fix(carry): keep sizing failure text out of delegation suggest responses": #6274's delegated-work suggest path reuses the sizing completion and still echoed raw error text, the same leak class the #6367 security review found. It now goes through `publicSizingError`, with a regression in `tests/codex-integration/injection-model-suggest-routes.test.ts`. `agent-settings-routes.ts` is 1,943 lines, under the 2,000 ceiling. React Doctor shows 0 diagnostics on the changed GUI files, and the privacy scan passes. + + - Exact-head required checks pass with the gate 5 coverage assertion; diff equals L3 plus any listed "fix(carry)" compatibility commits. - The polite live region stays mounted and announces running and completion text (`gui/tests/subagents-delegation-suggest.test.tsx`). - `--apply` skips an already-matching setting and the suggest route is read-only until `PUT /api/injection-model` (`tests/codex-integration/injection-model-suggest-routes.test.ts`). diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index fc79bb1fc9e..a0a2eee2970 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -88,6 +88,13 @@ The dashboard's **Sub-agent delegation** controls three related settings: - `injectionEffort` is the optional `reasoning_effort` to request for that model. - `injectionPrompt` replaces the built-in v2 guidance text. +Not sure which model to delegate to? **Suggest** beside the delegation model asks for a short +description of the work Codex usually hands off, sizes it with one call to your default Codex model, +and proposes the cheapest sufficient model and an effort from the list the picker offers, with the +reasoning and the signals that would move it up or down. Nothing changes until you choose +**Use this**, which saves exactly like picking the model and effort by hand. Tiers come from +`codexRoleTiers` or price, as in [role auto-assign](/guides/integrations/#auto-assign). + `multiAgentGuidanceEnabled` defaults to on and is the master switch for opencodex-authored guidance on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text. @@ -243,6 +250,7 @@ Use `ocx agent` for delegation, roster, effort-cap, and fallback settings: ```bash ocx agent status ocx agent injection set --model anthropic/claude-sonnet-5 --effort xhigh +ocx agent injection suggest "read-only searches across the repo" --apply ocx agent subagents set gpt-5.6-sol,anthropic/claude-sonnet-5 ocx agent fallback set gpt-5.6-luna,xai/grok-4.5 --poll-ms 60000 ocx effort set --subagent max diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 3473fc53b48..cd82eaf71c0 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -34,6 +34,11 @@ prints a proposed model and effort per role without writing anything. `--apply` through the same write as `set`, skipping and naming the roles whose model and effort already match. See [Auto-assign](/guides/integrations/#auto-assign). +`ocx agent injection suggest ` does the same for the delegation model: it sizes the described +work, proposes the cheapest sufficient model and an effort from the delegation picker's list, and writes +nothing unless `--apply` is given, which saves through the same write as `injection set`. See +[Delegation model and effort](/guides/sub-agent-surface/#delegation-model-and-effort). + `ocx agent sidecar web --list` and `ocx agent sidecar vision --list` print the models the server currently offers for each sidecar — the exact filtered set the dashboard picker shows (picker-visible rows plus the login-entitled Luna/Haiku auth slots, intersected with executor diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 41e40d28302..f3a4ad710f9 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -75,6 +75,7 @@ route-specific results rather than repeating this table. | --- | --- | --- | | `GET, PUT /api/v2` | Read or change native multi-agent v2 mode and thread settings | 400 invalid settings; 502 transition or persistence failure | | `GET, PUT /api/injection-model` | Read or set the injected sub-agent model, effort, prompt, and guidance settings | 400 invalid model, effort, or body | +| `POST /api/injection-model/suggest` | Size a described delegated workload and propose a delegation model and effort without writing | 400 invalid work or model; 409 no sizing model | | `GET, PUT /api/effort-caps` | Read or set global and sub-agent reasoning-effort ceilings | 400 invalid ladder value | | `GET, PUT /api/subagent-models` | Read or order the models advertised to sub-agents | 400 invalid list or more than five models | | `GET, PUT /api/subagent-model-fallback` | Read or set the ordered fallback chain and poll interval | 400 invalid list or poll interval | diff --git a/gui/src/components/subagents-workspace/DelegationSuggest.tsx b/gui/src/components/subagents-workspace/DelegationSuggest.tsx new file mode 100644 index 00000000000..0d10f170f07 --- /dev/null +++ b/gui/src/components/subagents-workspace/DelegationSuggest.tsx @@ -0,0 +1,139 @@ +/** + * Suggest for the delegation model: the user describes the work Codex hands off, one sizing call + * returns a tier and an effort, and the server picks the cheapest sufficient model among the + * options this page offers. The proposal is only shown; Use this goes through the page's own + * delegation save, the same write the model and effort selects make. + */ +import { useState } from "react"; +import { ROLE_INSTRUCTIONS_EXCERPT_CHARS } from "../../../../src/codex/role-sizing-limits"; +import { readJsonOrThrow } from "../../fetch-json"; +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; +import { formatNamespacedModelId } from "../../provider-icons"; +import type { RoleProposal } from "../../pages/integrations/LazyCodexRoleAutoAssign"; +import { EFFORT_LABEL, TIER_LABEL } from "../../pages/integrations/sizing-labels"; +import type { DelegationModelOption, DelegationPatch } from "../../pages/use-subagent-delegation"; + +interface Suggestion { + sizingModel: string; + sizingError: string | null; + proposal: Omit; +} + +export default function DelegationSuggest({ + apiBase, + model, + effort, + available, + saving, + onAccept, +}: { + apiBase: string; + model: string; + effort: string; + available: DelegationModelOption[]; + saving: boolean; + onAccept: (patch: DelegationPatch) => void; +}) { + const t = useT(); + const [work, setWork] = useState(""); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const label = (slug: string) => { + const option = available.find(o => o.namespaced === slug); + return option ? formatNamespacedModelId(`${option.provider}/${option.model}`, t) : slug; + }; + + const run = async () => { + setRunning(true); + setError(null); + try { + const response = await fetch(`${apiBase}/api/injection-model/suggest`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ work: work.trim() }), + }); + setResult(await readJsonOrThrow(response, t("sub.suggest.failed")) ?? null); + } catch (caught) { + setResult(null); + setError(caught instanceof Error && caught.message ? caught.message : t("sub.suggest.failed")); + } finally { + setRunning(false); + } + }; + + const p = result?.proposal; + const alreadySet = !!p?.proposedModel && p.proposedModel === model && (p.proposedEffort ?? "") === effort; + const announcement = running ? t("sub.suggest.running") : result ? t("sub.suggest.title") : ""; + + return ( +
+
{announcement}
+ +
+