diff --git a/AGENTS.md b/AGENTS.md index 804b5ce6f..156609e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,7 @@ Fields live in `~/.claude/ucode-settings.json` and the OS-managed settings file | Tracing | Ignore | Create/replace | The seven `CLAUDE_CODE_*`/`OTEL_*` trace keys and `otelHeadersHelper`; only when the config enables tracing | | `managedMcpServers` | Ignore | Merge | Add/update the config's MCP server entries; other entries left alone | | Smart-routing hooks | Merge | Merge | `PreToolUse`, `SessionStart`, `SubagentStart`; only `ug`'s own marked handlers, other hooks left alone | +| Smart Router Orchestrator hooks | Merge | Merge | Launch-only `UserPromptSubmit` and compact `SessionStart` handlers for smart-routed sessions with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`; read the same session controls as routing | @@ -86,5 +87,6 @@ Fields live in `~/.codex/ucode.config.toml` and `/etc/codex/managed_config.toml` | `http_headers` | Merge | Merge | In `[model_providers.Databricks]`; merge `ug`'s routing headers by name, admin headers added under managed config | | `model_catalog_json` | Create/replace | Create/replace | In `~/.codex/config.toml`; `ug`'s own catalog reference, for a static model list | | `mcp_servers` | Ignore | Merge | Managed file; add/update the config's MCP server entries, other entries left alone | +| Smart Router and Smart Router Orchestrator hooks | Merge | Merge | Launch-only `PreToolUse`, plus `UserPromptSubmit` and compact `SessionStart` with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`; Codex combines them with its native hook sources and applies project trust; `features.hooks` is enabled for a smart-routed launch | diff --git a/README.md b/README.md index e19175612..d76cc92b4 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,44 @@ is enabled, ug warns and falls back to subagent routing because the first-prompt wrapper requires a Unix terminal. The generated shell hooks expect Git Bash; PowerShell-only setups are not covered. +### Smart Router Orchestrator + +Smart-routed Claude and Codex sessions install `smart-router`. Set +`ENABLE_SMART_ROUTER_ORCHESTRATOR=1` at launch to also install and activate Smart Router +Orchestrator through the bundled `smart-router-orchestrator` skill; orchestration is off by +default. For example: + +```bash +ENABLE_SMART_ROUTER_ORCHESTRATOR=1 ENABLE_SMART_ROUTING_SUBAGENT_ONLY=1 ug claude +``` + +Use `ug codex` in the same command for Codex. Smart Router Orchestrator assigns bounded work +to explorer, researcher, worker, tester, and reviewer roles while the root plans, +integrates, and verifies results. Easy tasks and explicit requests not to delegate +stay in the root. + +Claude and Codex routing panels add `[orchestrator on]` to the `Subagent` line +only when orchestration is active. When it is off, they show the normal subagent +name. The label reports the session mode; it does not identify whether a particular +delegation came from the workflow or an explicit user request. + +Once opted in, orchestration follows the existing smart-routing launch eligibility +and session controls. Turning Smart Router off through its skill stops new automatic delegation; +turning it on restores orchestration only in opted-in sessions. Explicit user +requests for subagents still use normal harness behavior while routing is off. +Stored skill files do not activate orchestration when the feature flag is unset or +`ENABLE_SMART_ROUTER_ORCHESTRATOR=0`, or in non-routed sessions. Existing Isaac pilot gating +and UG launch exclusions still apply. + +Hooks refresh orchestration state before each prompt and after compaction. A +state change made outside the conversation is observed at the next hook; model +routing still checks the controls for each subagent. + +UG supplies its own hooks; Codex combines them with existing hooks and applies +project trust. Smart routing selects subagent models; separate role-model +preferences are ignored and their files are left untouched. See the bundled +[Smart Router Orchestrator documentation](skills/smart-router-orchestrator/README.md) for details. + ## Managed Files `ug` backs up files before overwriting them. `ug revert` restores backups. diff --git a/skills/orchestrate/README.md b/skills/orchestrate/README.md deleted file mode 100644 index c8d4da4c8..000000000 --- a/skills/orchestrate/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# UG model orchestrator - -UG bundles the `orchestrate` workflow, five Claude role definitions, and the -existing role-preference helper from `model-orchestrator` 0.4.10. Smart-routed -Claude and Codex launches install this skill alongside `smart-router`. - -The workflow is injected before root prompts and after compaction. Its activation -and model-resolution checks require a UG smart-routing session and read the same -session controls as the routing hooks. Turning Smart Router off stops new -automatic delegation and supersedes the previous workflow. Turning it on restores -both features. An installed skill or saved model preference cannot enable them. -Explicit user requests for subagents still use native harness behavior while routing -is off, without the orchestrator's model-resolution helper or role models. -User instructions take precedence, and easy tasks remain in the root. - -Claude loads the bundled roles as `ug-smart-router:` in its temporary -routing plugin. Codex uses native spawning with per-call model preferences. -Role instructions belong in each task prompt because routing may replace the -requested Claude role or Codex model. Hook approval in the native `/hooks` UI -is still required where the harness prompts for it. - -## Existing installations and preferences - -UG suppresses installed `model-orchestrator` marketplace plugins for every Claude -and Codex launch, including Isaac-synced Codex registrations and launches with -smart routing off. The old activation hook does not check routing state, so its -plugin is disabled through native per-launch settings. Saved registrations, -unrelated plugins and hooks, and launches outside UG are unaffected. - -This covers marketplace installations; manually copied activation hooks or -development copies passed through `--plugin-dir` need to be removed separately. - -Existing `.model-orchestrator.json` project preferences and -`$XDG_CONFIG_HOME/model-orchestrator/config.json` user preferences keep their -format and precedence. Claude custom agent names and ownership hashes are -unchanged. Bundled defaults remain Sonnet for Claude and `gpt-5.6-luna` at `max` -effort for Codex; routing determines the final model. The helper reads Codex's -catalog using UG's managed, profile, then user config precedence, including -`CODEX_HOME`, rather than Isaac's catalog environment variable. - -The [skill](SKILL.md) documents `show`, `set`, and `unconfigure`. Run its helper -with the launching `UCODE_SMART_ROUTER_PYTHON`, not an arbitrary Python on PATH. -Only `show` requires enabled routing; changing or removing preferences does not -activate orchestration. Configuration retains the original ownership checks -and interrupted-write recovery. Preference operations use UG's existing file -lock and wait for an operation in the same scope to finish. User-edited agents -are preserved and reported for reconciliation. - -## Attribution - -Migrated from the Databricks `model-orchestrator` plugin 0.4.10 by Arnav Singhvi. -Originally adapted from -[donvito/codex-astra-luna-orchestrator](https://github.com/donvito/codex-astra-luna-orchestrator/tree/21710352ec201f8634874d8298e0eca694e298a8) -under Apache-2.0; see [LICENSE.upstream](LICENSE.upstream). UG changes add shared -routing-state checks, launch-scoped activation and Claude roles, UG catalog -discovery, and cross-platform locking. diff --git a/skills/orchestrate/SKILL.md b/skills/orchestrate/SKILL.md deleted file mode 100644 index 0509b3a37..000000000 --- a/skills/orchestrate/SKILL.md +++ /dev/null @@ -1,201 +0,0 @@ ---- -name: orchestrate -description: Coordinate substantive development with native subagents while Unity Gateway smart routing is enabled. Follow the routing-state check before using this workflow. Skip easy tasks and explicit no-subagent requests. -model: inherit -argument-hint: "[task, configure, or unconfigure]" -metadata: - version: "1.0.0" ---- - -# Model orchestrator - -## Smart-routing gate - -This workflow is active only in a UG-launched smart-routing session while routing -is enabled. Installed skill files, old context, and model preferences do not -enable it. Before **every new delegation under this workflow, including a retry**, -run the resolution command below with the launching `$UCODE_SMART_ROUTER_PYTHON` -interpreter. It checks the same session controls as the routing hooks. If the -interpreter or session marker is absent, or resolution reports routing off, do -not use this workflow. -Do not set routing flags or create a session to bypass this check. - -Turning Smart Router off also turns this workflow off immediately and supersedes -earlier orchestration instructions. Do not start new automatic delegation or use -orchestrator role models as a fallback. Continue in the root unless the user -explicitly requests a subagent; honor that request using the native tool and normal harness -model selection, without this workflow or its resolution helper. Keep routing off -and collect results from existing children. Turning Smart Router back on restores -this workflow. Use the `smart-router` skill only when the user asks to change routing. - -## Workflow - -Follow user overrides. Keep the active root model and reasoning effort. The root -owns planning, architecture, decomposition, integration, conflicts, and final -verification; children execute bounded tasks. Model defaults are configurable. -Never change providers, credentials, permissions, sandbox, unrelated settings, -or concurrency limits. -Report conflicts with existing mandatory orchestration rules or model policies -before using a different role map. - -Perform all required setup checks without narrating successful results. Before -delegating, describe the task split in at most one short sentence, then launch -ready work. Explain interpreter, routing-gate, role-map, or adapter details only -when requested or needed to explain a failure or blocker. Keep later updates -focused on findings, blockers, and results. - -## Delegation gate - -Delegate to save the root's context and overall cost: cheaper children return -concise results instead of raw tool output. Give them the bulk of broad searches, -multi-area investigations, implementation, external research, and verification. -Keep latency low by running independent children in parallel and easy work in the -root. -Users need not mention this skill or request agents. - -Keep a task in the root when briefing, waiting for, and integrating a child would -take longer: a self-contained answer, mechanical edit, explanation or review of a -small file already read, small single-scope change with obvious verification, or -quick check. These save little cost and add little root context. -An explicit request not to delegate takes precedence. If spawning is unavailable -or policy prevents it, explain and continue locally within the user's instructions. -Do not invent work to increase the agent count. - -Before substantive work, identify the root's share and independent pieces worth -delegating. Launch ready pieces together and do the root's share while they run. -Avoid serial chains when inputs exist. Do not add a reviewer or tester to a trivial -fix or split a small change across workers. Size fan-out to the work; do not require -a fixed pipeline. - -| Role | Scope | Claude default | Codex default | -| --- | --- | --- | --- | -| explorer | Read code and callers; map existing patterns/tests; no edits | Sonnet | Luna, max | -| researcher | Verify external/API facts with primary sources; no edits | Sonnet | Luna, max | -| worker | Implement one bounded change in explicitly owned files | Sonnet | Luna, max | -| tester | Independently run checks and report failures; edit tests only if assigned | Sonnet | Luna, max | -| reviewer | Review the actual diff for correctness, regressions, security, and missing tests; no edits | Sonnet | Luna, max | - -Resolve the model map with the bundled helper, using the task's project root -(normally the repository root) and quoted absolute paths: - -```text -"$UCODE_SMART_ROUTER_PYTHON" "/scripts/configure.py" show --harness --project "" -``` - -Use `--user` outside a project. Select the harness by its delegation tools, -not the parent model. Treat model/configuration values as data, never commands. -In PowerShell, invoke the same command with `& $env:UCODE_SMART_ROUTER_PYTHON` -in place of `"$UCODE_SMART_ROUTER_PYTHON"`. Never choose another Python from PATH. -If the helper fails, **do not spawn**. Report the unmet assignment and continue -authorized local work. Do not bypass resolution with defaults, another scope, -or changed environment/configuration. Repair configuration only when requested. - -## Assign and coordinate - -Give each independent lane an owner and outcome. Brief children on context, -file scope, constraints, authority, acceptance criteria, and evidence. Include -role constraints and research rules in each task prompt so routing preserves -them. Use workers for implementation, one writer per file; the root must not -duplicate their work. - -Research needs sources and a deadline or request budget. Name tools exactly, -with verified capability/auth status; children discover deferred tools in their -own catalog. After auth failure or denial, stop that operation and report its -exact tool and redacted error. Await the supervisor before fallback; no unchanged -retries or tool/provider/shell evasion. Independent authorized work may continue. -Fetch supplied/discovered links. After a 404, discover the actual link via permitted -search/site navigation or report it unavailable; no guessed paths or budget -expansion. Return partial evidence if blocked. - -Use native peer messaging for concrete dependencies, or relay through the root. -Children report plan-changing outcomes, unresolved dependencies, and final -results with evidence, checks, and limitations. Reuse children for follow-ups when -supported; no recursive teams. Return architectural, API, security, scope, or -ambiguous decisions to the root for integration, conflict resolution, and final -verification. - -### Claude Code adapter - -Use native `Agent` (`Task` on older hosts) with the helper's `subagent_type`. -**Omit `model`**: role frontmatter selects the configured alias or full ID. Include -role scope and task contract in `prompt`; run independent children in the -background when supported. Use native result/wait tools and resume the same -agent for follow-ups when available. - -Omitted role tool lists inherit parent tools, including deferred MCP tools; -parent permissions and hooks still apply. Read-only scope is instructional. -Configured agents have distinct names. Report missing definitions as requiring -reload/restart; do not substitute built-ins. Per-call model overrides are alias-only -on the tested host; custom IDs belong in definitions. Managed forced-model policy -takes precedence; report conflicts without clearing it. - -### Codex adapter - -Make the initial native `spawn_agent` call with the helper's `model`. Pass -`reasoning_effort` only when non-null; otherwise omit it to use the native default. -The helper resolves equivalent spellings against the active catalog when available. -Attempt its model even if absent from the tool's partial preview. Do not retry -another spelling, invent aliases, or substitute a successor. Send the role scope -and contract in `message`; use `fork_turns="none"` if overrides require fresh context. Use the -host's native follow-up, message, wait, and close tools. Do not choose a custom -role that pins a different model or effort. - -Native spawning needs no role TOMLs or global `[agents]` defaults. Never simulate -delegation with nested CLIs. Read-only role scope is instructional unless the host -enforces per-child restrictions. - -### Recover a Codex delegation - -Before every retry, check these conditions in order: - -1. Did `spawn_agent` return a child ID for this assignment? If yes, **never spawn - a replacement**, even after closing it. An error from wait, notification, or - the child provider is a child failure, not a rejected spawn. Report it unmet. -2. Is the error permission, authentication, or capacity related? Stop. No alias - retry, inherited fallback, or changes to permissions, credentials, or limits. -3. Did `spawn_agent` itself reject the model/effort before returning any child ID? - Only this selection failure (or a schema without overrides) permits recovery. - -Require `allow_inherited_fallback: true` from successful resolution for the -assigned role (bundled defaults only). Honor explicit settings and conversation/ -policy constraints; never change roles or configuration to evade them. - -If eligible and the routing-state check still passes, disclose the failure and -**attempt one native spawn omitting both -`model` and `reasoning_effort`**, with the same contract and fresh context (`fork_turns="none"` -when exposed). The routing hook selects the model. Do not assume routing ran or -fallback will succeed. Never use this retry when routing is off. If forbidden -or unsuccessful, stop retrying and report the error and unmet assignment. - -## Integrate and verify - -Read child evidence, inspect worker diffs, and spot-check cited paths without -redoing their scope. Run the smallest independent checks of the requested outcome. -Resolve conflicts and findings before handoff. Account for every required child; -a launch or success-shaped summary alone is not completion. For empty or unrelated -results, or an already-supplied task request, clarify once with the same child. -Verify its evidence; if still unusable, report the unmet assignment without -respawning. Report unavailable models, tools, and substitutions. - -Finish with the concrete result, verification actually performed, and material -remaining limitations. Do not claim cost or speed improvements without measurements. - -## Configure / unconfigure - -Only change preferences when requested. The helper supports: - -```text -"$UCODE_SMART_ROUTER_PYTHON" "/scripts/configure.py" set --harness --role --model [--effort ] <--project |--user> -"$UCODE_SMART_ROUTER_PYTHON" "/scripts/configure.py" unconfigure <--project |--user> -``` - -User defaults live in `$XDG_CONFIG_HOME/model-orchestrator/config.json` (normally -`~/.config`); project-root `.model-orchestrator.json` overrides them. `set` without -`--effort` uses native defaults; Claude inherits session effort if unset. -Refresh stale Claude definitions by rerunning `set` with saved model and effort -in the same scope. This updates owned, unedited definitions while preserving -other preferences and unrelated/edited files; see README upgrades. Restart after -setup/regeneration. Bundled defaults need no setup. UG loads the bundled Claude -roles as `ug-smart-router:` only for a routed launch. Smart routing can -replace the requested model and role, so always include role instructions in -the delegated prompt and use runtime evidence to identify the model that ran. diff --git a/skills/orchestrate/LICENSE.upstream b/skills/smart-router-orchestrator/LICENSE.upstream similarity index 100% rename from skills/orchestrate/LICENSE.upstream rename to skills/smart-router-orchestrator/LICENSE.upstream diff --git a/skills/smart-router-orchestrator/README.md b/skills/smart-router-orchestrator/README.md new file mode 100644 index 000000000..9e5aad4ed --- /dev/null +++ b/skills/smart-router-orchestrator/README.md @@ -0,0 +1,42 @@ +# Smart Router Orchestrator + +UG bundles the `smart-router-orchestrator` workflow and five Claude role definitions. +Smart-routed Claude and Codex launches install and +activate this skill alongside `smart-router` only with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`. +The feature is off by default; routing alone installs only `smart-router`. + +The workflow is injected before root prompts and after compaction. The hook checks +the feature flag, UG session, and current routing controls. The skill uses that +activation context without running a separate check before delegation. +Turning Smart Router off through its skill stops new automatic delegation and +supersedes the previous workflow. Turning it on restores orchestration only in +opted-in sessions. A change made outside the conversation is observed at the next +prompt or compaction; model routing still checks the controls for each subagent. +An installed skill or saved model preference cannot enable orchestration. +Explicit user requests for subagents still use native harness behavior while routing +is off, without the Smart Router Orchestrator workflow. +User instructions take precedence, and easy tasks remain in the root. + +Claude loads the bundled roles as `ug-smart-router:` in its temporary +routing plugin. Both agents delegate without model or reasoning-effort overrides; +the routing hook selects the model. +Role instructions belong in each task prompt because routing may replace the +requested Claude role or Codex model. Hook approval in the native `/hooks` UI +is still required where the harness prompts for it. + +## Model selection + +Separate role-model preferences are not used by the UG workflow. Existing +`.model-orchestrator.json` project preferences, +`$XDG_CONFIG_HOME/model-orchestrator/config.json` user preferences, and generated +custom Claude agents are left untouched. The bundled workflow uses the router's +model selection and requires no preference setup, locking, or recovery. + +## Attribution + +Migrated from the Databricks `model-orchestrator` plugin 0.4.10 by Arnav Singhvi. +Originally adapted from +[donvito/codex-astra-luna-orchestrator](https://github.com/donvito/codex-astra-luna-orchestrator/tree/21710352ec201f8634874d8298e0eca694e298a8) +under Apache-2.0; see [LICENSE.upstream](LICENSE.upstream). UG changes add shared +routing-state checks and launch-scoped activation and Claude roles, and delegate +model selection to smart routing. diff --git a/skills/smart-router-orchestrator/SKILL.md b/skills/smart-router-orchestrator/SKILL.md new file mode 100644 index 000000000..21787f23b --- /dev/null +++ b/skills/smart-router-orchestrator/SKILL.md @@ -0,0 +1,146 @@ +--- +name: smart-router-orchestrator +description: Smart Router Orchestrator coordinates substantive development with native subagents when ENABLE_SMART_ROUTER_ORCHESTRATOR=1 and Unity Gateway smart routing is enabled. Follow UG's activation context. Skip easy tasks and explicit no-subagent requests. +model: inherit +argument-hint: "[task]" +metadata: + version: "1.1.0" +--- + +# Smart Router Orchestrator + +## Activation + +UG's prompt and compaction hooks activate this workflow only in an eligible +smart-routing session launched with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1` while routing is on. +Follow the latest UG activation context and successful Smart Router toggles; +installed skill files and old context do not enable it. Use that context without +running a separate pre-delegation check. Do not set flags or create a session to +activate this workflow. + +Turning Smart Router off through its skill stops this workflow and supersedes +earlier orchestration instructions. Do not start new automatic delegation or use +Smart Router Orchestrator role models as a fallback. Continue in the root unless the user +explicitly requests a subagent; honor that request using the native tool and normal harness +model selection, without this workflow. Keep routing off +and collect results from existing children. Turning Smart Router back on restores +this workflow only if the session was launched with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`. +Use the `smart-router` skill only when the user asks to change routing. + +## Workflow + +Follow user overrides. Keep the active root model and reasoning effort. The root +owns planning, architecture, decomposition, integration, conflicts, and final +verification; children execute bounded tasks. Smart routing selects child models; +do not apply separate role-model preferences or reasoning-effort overrides. +Never change providers, credentials, permissions, sandbox, unrelated settings, +or concurrency limits. +Report conflicts with existing mandatory orchestration rules or model policies. + +Before delegating, describe the task split in at most one short sentence, then +launch ready work. Explain adapter details only +when requested or needed to explain a failure or blocker. Keep later updates +focused on findings, blockers, and results. + +## Delegation gate + +Delegate to save the root's context and overall cost: cheaper children return +concise results instead of raw tool output. Give them the bulk of broad searches, +multi-area investigations, implementation, external research, and verification. +Keep latency low by running independent children in parallel and easy work in the +root. +Users need not mention this skill or request agents. + +Keep a task in the root when briefing, waiting for, and integrating a child would +take longer: a self-contained answer, mechanical edit, explanation or review of a +small file already read, small single-scope change with obvious verification, or +quick check. These save little cost and add little root context. +An explicit request not to delegate takes precedence. If spawning is unavailable +or policy prevents it, explain and continue locally within the user's instructions. +Do not invent work to increase the agent count. + +Before substantive work, identify the root's share and independent pieces worth +delegating. Launch ready pieces together and do the root's share while they run. +Avoid serial chains when inputs exist. Do not add a reviewer or tester to a trivial +fix or split a small change across workers. Size fan-out to the work; do not require +a fixed pipeline. + +| Role | Scope | +| --- | --- | +| explorer | Read code and callers; map existing patterns/tests; no edits | +| researcher | Verify external/API facts with primary sources; no edits | +| worker | Implement one bounded change in explicitly owned files | +| tester | Independently run checks and report failures; edit tests only if assigned | +| reviewer | Review the actual diff for correctness, regressions, security, and missing tests; no edits | + +## Assign and coordinate + +Give each independent lane an owner and outcome. Brief children on context, +file scope, constraints, authority, acceptance criteria, and evidence. Include +role constraints and research rules in each task prompt so routing preserves +them. Use workers for implementation, one writer per file; the root must not +duplicate their work. + +Research needs sources and a deadline or request budget. Name tools exactly, +with verified capability/auth status; children discover deferred tools in their +own catalog. After auth failure or denial, stop that operation and report its +exact tool and redacted error. Await the supervisor before fallback; no unchanged +retries or tool/provider/shell evasion. Independent authorized work may continue. +Fetch supplied/discovered links. After a 404, discover the actual link via permitted +search/site navigation or report it unavailable; no guessed paths or budget +expansion. Return partial evidence if blocked. + +Use native peer messaging for concrete dependencies, or relay through the root. +Children report plan-changing outcomes, unresolved dependencies, and final +results with evidence, checks, and limitations. Reuse children for follow-ups when +supported; no recursive teams. Return architectural, API, security, scope, or +ambiguous decisions to the root for integration, conflict resolution, and final +verification. + +### Claude Code adapter + +Use native `Agent` (`Task` on older hosts) with `subagent_type="ug-smart-router:"`. +**Omit `model`**: the routing hook selects it. Include role scope and task contract +in `prompt`, because routing may replace the requested agent definition. Run +independent children in the background when supported. Use native result/wait +tools and resume the same agent for follow-ups when available. + +Omitted role tool lists inherit parent tools, including deferred MCP tools; +parent permissions and hooks still apply. Read-only scope is instructional. +Report missing bundled definitions as requiring reload/restart; do not substitute +custom agents with saved model preferences. Managed forced-model policy takes +precedence; report conflicts without clearing it. + +### Codex adapter + +Use native `spawn_agent` without `model` or `reasoning_effort` overrides. The routing +hook selects the model. Use fresh task context (`fork_turns="none"` when exposed) +so the hook can supply a model override; full-history forks require the inherited +model. Send the needed context, role scope, and contract in `message`; use the +host's native follow-up, message, wait, and close tools. Do not choose a custom +role that pins a model or effort. + +Native spawning needs no role TOMLs or global `[agents]` defaults. Never simulate +delegation with nested CLIs. Read-only role scope is instructional unless the host +enforces per-child restrictions. + +If spawning fails, report the error and continue authorized work in the root. +Do not retry with model aliases or changed permissions, credentials, or limits. +Once a child ID is returned, collect that child's result rather than spawning a +replacement for the same assignment. + +## Integrate and verify + +Read child evidence, inspect worker diffs, and spot-check cited paths without +redoing their scope. Run the smallest independent checks of the requested outcome. +Resolve conflicts and findings before handoff. Account for every required child; +a launch or success-shaped summary alone is not completion. For empty or unrelated +results, or an already-supplied task request, clarify once with the same child. +Verify its evidence; if still unusable, report the unmet assignment without +respawning. Report unavailable models, tools, and substitutions. + +Finish with the concrete result, verification actually performed, and material +remaining limitations. Do not claim cost or speed improvements without measurements. + +Use runtime evidence to identify the model that ran; submitting a delegation +alone does not prove that its routing hook executed. diff --git a/skills/orchestrate/agents/explorer.md b/skills/smart-router-orchestrator/agents/explorer.md similarity index 100% rename from skills/orchestrate/agents/explorer.md rename to skills/smart-router-orchestrator/agents/explorer.md diff --git a/skills/orchestrate/agents/researcher.md b/skills/smart-router-orchestrator/agents/researcher.md similarity index 100% rename from skills/orchestrate/agents/researcher.md rename to skills/smart-router-orchestrator/agents/researcher.md diff --git a/skills/orchestrate/agents/reviewer.md b/skills/smart-router-orchestrator/agents/reviewer.md similarity index 100% rename from skills/orchestrate/agents/reviewer.md rename to skills/smart-router-orchestrator/agents/reviewer.md diff --git a/skills/orchestrate/agents/tester.md b/skills/smart-router-orchestrator/agents/tester.md similarity index 100% rename from skills/orchestrate/agents/tester.md rename to skills/smart-router-orchestrator/agents/tester.md diff --git a/skills/orchestrate/agents/worker.md b/skills/smart-router-orchestrator/agents/worker.md similarity index 100% rename from skills/orchestrate/agents/worker.md rename to skills/smart-router-orchestrator/agents/worker.md diff --git a/skills/smart-router/SKILL.md b/skills/smart-router/SKILL.md index b21e2680a..439f8d4a8 100644 --- a/skills/smart-router/SKILL.md +++ b/skills/smart-router/SKILL.md @@ -1,9 +1,9 @@ --- name: smart-router -description: Enable or disable Unity Gateway subagent model routing for the current smart-routed Claude or Codex session. +description: Enable or disable Unity Gateway subagent model routing for the current smart-routed Claude or Codex session, along with Smart Router Orchestrator when opted in. allowed-tools: Bash("$UCODE_SMART_ROUTER_PYTHON" -m ucode.cli claude --enable-smart-routing), Bash("$UCODE_SMART_ROUTER_PYTHON" -m ucode.cli claude --disable-smart-routing), Bash("$UCODE_SMART_ROUTER_PYTHON" -m ucode.cli codex --enable-smart-routing), Bash("$UCODE_SMART_ROUTER_PYTHON" -m ucode.cli codex --disable-smart-routing) metadata: - version: "1.1.0" + version: "1.2.0" --- # Smart Router @@ -22,5 +22,12 @@ If `UCODE_SMART_ROUTER_PYTHON` or `UCODE_SESSION_ENV_FILE` is unset, ask the use to restart through an updated Unity Gateway with smart routing enabled. With no argument, explain that only `on` and `off` are accepted. Do not edit the state file. -This affects subsequent subagent model selection in the current session, not the root model or -first prompt. Return the command's result. +This affects subsequent subagent model selection in the current session, not the root model +or first prompt. In sessions launched with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`, it also controls Smart Router +Orchestrator. When turned off, earlier Smart Router Orchestrator instructions +are superseded: do not start new automatic delegation or fall back to Smart Router Orchestrator role models. +Continue in the root unless the user explicitly requests a subagent. Honor that request using +native tools and normal harness model selection, without Smart Router Orchestrator; keep routing off. +Existing children can finish. When turned on, apply the `smart-router-orchestrator` +skill to further work only if the session was launched with `ENABLE_SMART_ROUTER_ORCHESTRATOR=1`. +Do not change the orchestration feature flag. Return the command's result. diff --git a/src/ucode/constants.py b/src/ucode/constants.py index 1db9930e6..38fe7e774 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -5,6 +5,7 @@ ENABLE_SMART_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_V2" ENABLE_SUBAGENT_ROUTING_ENV_VAR = "ENABLE_SMART_ROUTING_SUBAGENT_ONLY" +ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR = "ENABLE_SMART_ROUTER_ORCHESTRATOR" SMART_ROUTING_ENV_KEYS = ( ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR, diff --git a/src/ucode/skills.py b/src/ucode/skills.py index 5f80137ad..0adb49688 100644 --- a/src/ucode/skills.py +++ b/src/ucode/skills.py @@ -12,6 +12,8 @@ _LEGACY_SKILL_ROOTS = (".agents/skills",) _SKILL_NAME_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") SMART_ROUTER_SKILL = "smart-router" +# Install the delegation workflow in the same eligible sessions as Smart Router. +SMART_ROUTER_ORCHESTRATOR_SKILL = "smart-router-orchestrator" def _skills_source() -> Path: diff --git a/src/ucode/smart_routing/codex_routing.py b/src/ucode/smart_routing/codex_routing.py index 9569bc14d..6d6137d28 100644 --- a/src/ucode/smart_routing/codex_routing.py +++ b/src/ucode/smart_routing/codex_routing.py @@ -15,10 +15,11 @@ # Python modules are singletons so patching this name patches the one call site. import urllib.request # noqa: F401 from collections.abc import Callable +from dataclasses import replace from typing import Any from ucode.config_io import APP_DIR -from ucode.smart_routing import routing +from ucode.smart_routing import orchestrator, routing from ucode.smart_routing.routing import RoutingDecision ROUTER_NAME = routing.ROUTER_NAME @@ -109,6 +110,7 @@ def route_pre_tool_use( def record(payload, task, decision, requested): routing.write_decision_record(DECISIONS_PATH, payload, task, decision, requested) + name_suffix = " [orchestrator on]" if orchestrator.enabled() else "" return routing.route_spawn_tool( payload, is_spawn_agent=is_spawn_agent_tool, @@ -117,7 +119,7 @@ def record(payload, task, decision, requested): ), default_task_label="Codex subagent task", model_id_mapper=codex_model_id, - notice_config=SUBAGENT_NOTICE_CONFIG, + notice_config=replace(SUBAGENT_NOTICE_CONFIG, name_suffix=name_suffix), record_decision=record, ) diff --git a/src/ucode/smart_routing/orchestrator.py b/src/ucode/smart_routing/orchestrator.py new file mode 100644 index 000000000..c86d4b0a5 --- /dev/null +++ b/src/ucode/smart_routing/orchestrator.py @@ -0,0 +1,113 @@ +"""Activate Smart Router Orchestrator in opted-in smart-routing sessions.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import shutil +import subprocess +import sys +from collections.abc import Mapping +from pathlib import Path + +from ucode import skills +from ucode.constants import ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR +from ucode.smart_routing.hooks import sync_managed_hooks +from ucode.smart_routing.session_env import effective_environment, session_env_path + +HOOK_MODULE = "ucode.smart_routing.orchestrator" +DISABLED_CONTEXT = ( + "Smart Router Orchestrator is off for this session. This supersedes earlier " + "Smart Router Orchestrator instructions. Continue in the root unless the user explicitly " + "requests subagents; use native tools and the current Smart Router setting for those requests. " + "Collect results from children already running." +) + + +def feature_enabled(env: Mapping[str, str] | None = None) -> bool: + source = os.environ if env is None else env + return source.get(ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR) == "1" + + +def enabled(env: Mapping[str, str] | None = None) -> bool: + from ucode.smart_routing.v2 import smart_routing_enabled + + source = os.environ if env is None else env + if not feature_enabled(source): + return False + if source.get("ISAAC_LAUNCH_MODE", "").strip().lower() == "omni": + return False + try: + # The marker is created only after UG selects a supported routing launch. + if not session_env_path(source).is_file(): + return False + except (RuntimeError, OSError): + return False + return smart_routing_enabled(effective_environment(source)) + + +def skill_directory() -> Path: + return skills._skills_source() / skills.SMART_ROUTER_ORCHESTRATOR_SKILL + + +def add_claude_agents(plugin_dir: Path) -> None: + """Load roles alongside the router's exact-model agents, only for this launch.""" + if feature_enabled(): + shutil.copytree(skill_directory() / "agents", plugin_dir / "agents", dirs_exist_ok=True) + + +def sync_hooks(doc: dict, *, agent: str) -> None: + groups = {} + if feature_enabled(): + argv = [sys.executable, "-m", HOOK_MODULE] + hook = { + "type": "command", + "command": shlex.join(argv), + "timeout": 5, + } + if agent == "codex": + hook["command_windows"] = subprocess.list2cmdline(argv) + groups = { + "UserPromptSubmit": [{"hooks": [hook]}], + "SessionStart": [{"matcher": "compact", "hooks": [hook]}], + } + sync_managed_hooks(doc, HOOK_MODULE, groups) + + +def hook_output(payload: object) -> dict | None: + if not isinstance(payload, dict) or payload.get("agent_id"): + return None + event = payload.get("hook_event_name") + if event != "UserPromptSubmit" and not ( + event == "SessionStart" and payload.get("source") == "compact" + ): + return None + context = DISABLED_CONTEXT + if enabled(): + directory = skill_directory() + try: + workflow = (directory / "SKILL.md").read_text(encoding="utf-8") + except (OSError, UnicodeError): + return None + context = ( + "Smart Router Orchestrator is on for this session. Apply the workflow below.\n" + f"Skill directory: {directory}\n\n{workflow}" + ) + return {"hookSpecificOutput": {"hookEventName": event, "additionalContext": context}} + + +def main() -> None: + argparse.ArgumentParser(description=__doc__).parse_args() + try: + payload = json.load(sys.stdin) + except (OSError, UnicodeError, ValueError): + return + output = hook_output(payload) + if output is not None: + print(json.dumps(output)) + + +if __name__ == "__main__": + main() diff --git a/src/ucode/smart_routing/routing.py b/src/ucode/smart_routing/routing.py index 3d11f3d33..cd6d7b499 100644 --- a/src/ucode/smart_routing/routing.py +++ b/src/ucode/smart_routing/routing.py @@ -134,6 +134,7 @@ class SubagentNoticeConfig: prompt_field: str display_model_mapper: Callable[[str], str] | None = None leading_newline: bool = False + name_suffix: str = "" def name(self, tool_input: dict[str, Any]) -> str | None: return _nonempty_string(tool_input.get(self.name_field)) @@ -153,10 +154,13 @@ def message( routed_model: str, tool_input: dict[str, Any], ) -> str: + subagent_name = self.name(tool_input) + if self.name_suffix: + subagent_name = f"{subagent_name or 'subagent'}{self.name_suffix}" message = decision.display_message( model_label=self.display_model(decision.model, routed_model), is_subagent=True, - subagent_name=self.name(tool_input), + subagent_name=subagent_name, prompt=self.prompt(tool_input), ) return f"\n{message}" if self.leading_newline else message diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index 50790dfac..5e4b1879b 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -23,11 +23,11 @@ from ucode.config_io import ( APP_DIR, read_json_safe, - read_toml_safe, write_json_file, write_text_file, ) from ucode.constants import ( + ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR, ENABLE_SMART_ROUTING_ENV_VAR, ENABLE_SUBAGENT_ROUTING_ENV_VAR, LOOPBACK_HOST, @@ -47,8 +47,8 @@ acquire_exclusive_file_lock, release_file_lock, ) -from ucode.skills import SMART_ROUTER_SKILL, install_skill -from ucode.smart_routing import claude_routing, codex_interposer, routing +from ucode.skills import SMART_ROUTER_ORCHESTRATOR_SKILL, SMART_ROUTER_SKILL, install_skill +from ucode.smart_routing import claude_routing, codex_interposer, orchestrator, routing from ucode.smart_routing.claude_hooks import ( FIRST_PROMPT_SOCKET_ENV, sync_first_prompt_hook, @@ -84,10 +84,14 @@ class ClaudeRoutingSetupError(RuntimeError): def _prepare_smart_router_session(agent: str) -> Path: - try: - install_skill(SMART_ROUTER_SKILL, agent, config_io.APP_DIR.parent) - except (OSError, RuntimeError) as exc: - print_warning(f"Could not install the Smart Router skill: {exc}") + skills = [SMART_ROUTER_SKILL] + if orchestrator.feature_enabled(): + skills.append(SMART_ROUTER_ORCHESTRATOR_SKILL) + for skill in skills: + try: + install_skill(skill, agent, config_io.APP_DIR.parent) + except (OSError, RuntimeError) as exc: + print_warning(f"Could not install the {skill} skill: {exc}") return start_session() @@ -332,6 +336,7 @@ def _write_routed_claude_plugin(plugin_dir: Path, model_ids: list[str]) -> None: ] ), ) + orchestrator.add_claude_agents(plugin_dir) def _request_claude_routing_decision( @@ -407,10 +412,13 @@ def route_claude_pre_tool_use( route.decision, route.routed_model, ) + agent_name = claude_routing.SUBAGENT_NOTICE_CONFIG.name(route.tool_input) or "subagent" + if orchestrator.enabled(): + agent_name += " [orchestrator on]" routing_message = claude_routing.SUBAGENT_NOTICE_CONFIG.message( route.decision, route.routed_model, - route.tool_input, + {**route.tool_input, "subagent_type": agent_name}, ) updated_input = { **{key: value for key, value in route.tool_input.items() if key != "model"}, @@ -523,6 +531,7 @@ def launch_claude( if not isinstance(env, dict): raise RuntimeError("Claude settings 'env' must be an object for smart routing.") env.pop("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY", None) + env[ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR] = "1" if orchestrator.feature_enabled() else "0" if route_first_prompt: env[ENABLE_SMART_ROUTING_ENV_VAR] = "1" else: @@ -538,6 +547,7 @@ def launch_claude( sync_smart_routing_hooks(settings, routing_state, enabled=True) if route_first_prompt: sync_first_prompt_hook(settings, hook_executable) + orchestrator.sync_hooks(settings, agent="claude") model_setting = _ClaudeModelSettingGuard(user_settings_path) def route_prompt(prompt: str) -> claude_pty.FirstPromptRoute: @@ -603,22 +613,15 @@ def _cached_routing_models(state: dict) -> list[str]: return routing_models(state) -def _codex_home_config_path() -> Path: - codex_home = os.environ.get("CODEX_HOME") - if codex_home: - return Path(codex_home).expanduser() / "config.toml" - return Path.home() / ".codex" / "config.toml" - - -def _v2_pre_tool_use_hooks(state: dict, available_models: list[str]) -> list[dict]: - doc = read_toml_safe(_codex_home_config_path()) - configured_hooks = doc.get("hooks") - existing = configured_hooks.get("PreToolUse") if isinstance(configured_hooks, dict) else None - return merge_pre_tool_use_hooks( - existing if isinstance(existing, list) else [], - state, - available_models=available_models, - ) +def _v2_hooks(state: dict, available_models: list[str]) -> dict: + # Codex combines hook sources itself; copying user hooks here would register them twice. + doc = { + "hooks": { + "PreToolUse": merge_pre_tool_use_hooks([], state, available_models=available_models), + } + } + orchestrator.sync_hooks(doc, agent="codex") + return doc["hooks"] def launch_codex( @@ -659,9 +662,8 @@ def launch_codex( catalog_path = custom_catalog_path() if catalog_path is not None: overlay["model_catalog_json"] = str(catalog_path) - overlay["hooks"] = { - "PreToolUse": _v2_pre_tool_use_hooks(state, available_models), - } + overlay["hooks"] = _v2_hooks(state, available_models) + overlay["features.hooks"] = True session_env_path = _prepare_smart_router_session("codex") # Codex constructs tool subprocess environments through its shell policy. # Pass both the session marker and its launching interpreter through that policy. diff --git a/tests/README.md b/tests/README.md index 578fc73ff..dc5cf381e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -131,6 +131,20 @@ that Claude settings and Codex's shell policy carry the interpreter and session These are component checks; they do not establish native skill permission matching or PowerShell execution. +The toggle integration journeys run with `ENABLE_SMART_ROUTER_ORCHESTRATOR` unset and with +`ENABLE_SMART_ROUTER_ORCHESTRATOR=1`. They require only `smart-router` by default and both +bundled skills when opted in, verify the saved session controls and native +tool-result confirmation after each toggle, and explicitly request their children, +including while routing is off. +`test_integration_evidence.py` checks native tool-result extraction for both agents, +including collapsed-output records, and excludes user echoes and assistant claims. + +Dedicated regression coverage is missing for root-only Smart Router Orchestrator activation, +compaction, retained skills in ineligible sessions, +role-contract preservation, and isolation from legacy preference files. Codex's +native hook merging and project trust, automatic delegation, and execution of +pre-existing hooks remain outside the integration suite. + The portable Windows routing test checks native executable forwarding, generated hooks/plugins, caller arguments, and cleanup without Unix imports. It does not establish live Windows hook execution or interactive routing. @@ -180,7 +194,7 @@ integration utilities; only CUJ-specific evidence correlation stays in a test fi | `test_ug_codex_app_reports_unknown_argument` | Pass an invalid option directly to `ug codex app`, routing off/on | Real Codex parser error and status preserved | | `test_ug_codex_app_server_client_initializes` | Connect a stdio client, direct/`--` separator, routing off/on | Actual JSON-RPC initialize response; no non-JSON stdout; no routing | | `test_smart_routing_claude_route_subagent_hook`, `test_smart_routing_codex_route_subagent_hook` | Pipe a real PreToolUse spawn payload to the installed route-subagent hook with subagent-only routing enabled | Allow decision against the live router; requested model replaced by a routed agent definition (Claude) or bundled catalog slug (Codex) from the offered models; one audited decision matching the session and task | -| `test_smart_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | All three native children complete; only enabled phases show the subagent banner and produce a live routing decision correlated with the child; no first-prompt routing wrapper; normal exit | +| `test_smart_router_skill_toggles_claude_subagent_routing`, `test_smart_router_skill_toggles_codex_subagent_routing` | Configure, launch a real subagent-only TUI with orchestration unset or opted in, then spawn tagged children while invoking the installed Smart Router skill to switch routing on -> off -> on in the same session | Only `smart-router` is installed by default; opt-in also installs `smart-router-orchestrator`; all three native children complete; only routing-enabled phases show the subagent banner and produce a live routing decision correlated with the child; no first-prompt routing wrapper; normal exit | | `test_ug_configure_claude_repeat_and_revert`, `test_ug_configure_codex_repeat_and_revert` | Configure twice over user settings; complete a task; revert twice | Settings preserved; no bearer in ug state; generated config removed; status unconfigured | | `test_ug_configure_claude_cleans_stale_skills_mcp_on_workspace_switch` | Configure the first workspace, register its skills MCP, switch to a second real workspace, and use Claude | Old registration removed from Claude and the new workspace state; old workspace bucket preserved; repeat configure stays clean; real file task completes on the second workspace | | `test_ug_configure_claude_rejects_invalid_credentials`, `test_ug_configure_codex_rejects_invalid_credentials` | Configure with a rejected bearer against the real workspace | Authentication failure; no successful saved setup | diff --git a/tests/integration/README.md b/tests/integration/README.md index 14ebef990..9bf85f70b 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -281,6 +281,20 @@ PATH conflicts for the Smart Router skill have subprocess/component coverage in `ug` first in PATH. The live journeys above do not inject a second installation or establish PowerShell command execution. +The toggle journeys run with `ENABLE_SMART_ROUTER_ORCHESTRATOR` unset and with +`ENABLE_SMART_ROUTER_ORCHESTRATOR=1`. They require only `smart-router` by default and both +`smart-router-orchestrator` and `smart-router` when opted in. They verify the saved session +controls, a new CLI confirmation in the native tool-result records, and a new +assistant answer after each skill invocation. +Collapsed terminal output is allowed; the answer need not repeat the CLI's exact wording. +Each following child still verifies whether a routing decision occurred. +Their off-phase child is an explicit user-requested delegation; +these journeys do not establish automatic orchestration behavior. Root-only +activation, compaction, retained skills in ineligible sessions, +role-contract preservation, and isolation from legacy preference files +lack dedicated regression coverage. Codex's native hook merging, project trust, +and execution of pre-existing hooks are not exercised by this integration suite. + The portable `../test_claude_windows_smart_routing.py` checks the Windows subagent-only fallback without Unix imports. Native Windows TUI and hook execution remain outside this integration suite. diff --git a/tests/integration/test_ug_smart_routing_hooks.py b/tests/integration/test_ug_smart_routing_hooks.py index 1d8318c30..fc4c92db9 100644 --- a/tests/integration/test_ug_smart_routing_hooks.py +++ b/tests/integration/test_ug_smart_routing_hooks.py @@ -9,13 +9,17 @@ """ import json +from pathlib import Path import pytest from utils.evidence import ( SubagentCalculation, + agent_sessions, assert_subagent_routed, - assistant_answer_contains, + assistant_answers, + is_child_session, read_jsonl, + tool_outputs, ) from utils.managed import use_managed_config_fixture from utils.terminal import AgentTerminal @@ -115,21 +119,51 @@ def _toggle_with_skill(tui, session, agent: str, enabled: bool) -> None: for path in skill_root.iterdir() if path.is_dir() and path.name not in ignored_skills ) - assert installed_skills == ["smart-router"], installed_skills + expected_skills = ( + ["smart-router", "smart-router-orchestrator"] + if session.env.get("ENABLE_SMART_ROUTER_ORCHESTRATOR") == "1" + else ["smart-router"] + ) + assert installed_skills == expected_skills, installed_skills state = "on" if enabled else "off" invocation = f"/smart-router {state}" if agent == "claude" else f"$smart-router {state}" - confirmation = f"{state} for this session" + controls = list(Path(session.env["TMPDIR"]).glob("ug-session-env-*/env.json")) + assert len(controls) == 1, controls + expected = ( + {} + if enabled + else { + "ENABLE_SMART_ROUTING_V2": "0", + "ENABLE_SMART_ROUTING_SUBAGENT_ONLY": "0", + } + ) + assert json.loads(controls[0].read_text()) != expected + + confirmation = f"Smart Router is {state} for this session" + + def completion_counts(): + answers = confirmations = 0 + for path, records in agent_sessions(session, agent).items(): + if is_child_session(agent, path, records): + continue + answers += len(assistant_answers(agent, records)) + confirmations += sum(confirmation in output for output in tool_outputs(agent, records)) + return answers, confirmations + + before_answers, before_confirmations = completion_counts() tui.submit(invocation) + + def toggled(_screen): + answers, confirmations = completion_counts() + return ( + json.loads(controls[0].read_text()) == expected + and confirmations > before_confirmations + and answers > before_answers + ) + tui.wait_for( - lambda screen: ( - "Smart Router" in screen - and confirmation in screen - and ( - assistant_answer_contains(session, agent, confirmation) - or assistant_answer_contains(session, agent, f"**{state}** for this session") - ) - ), + toggled, f"the installed Smart Router skill to turn routing {state}", timeout=120, ) @@ -244,19 +278,30 @@ def test_smart_routing_codex_route_subagent_hook(live_session, workspace): @pytest.mark.live @pytest.mark.claude @pytest.mark.managed_fixture -def test_smart_router_skill_toggles_claude_subagent_routing(live_session, workspace): - """Scenario: launch Claude with subagent routing enabled, spawn a child, invoke the +@pytest.mark.parametrize( + "orchestration_enabled", [False, True], ids=["routing-only", "orchestration"] +) +def test_smart_router_skill_toggles_claude_subagent_routing( + live_session, workspace, tmp_path, orchestration_enabled +): + """Scenario: launch Claude with subagent routing enabled and orchestration unset + or opted in through ENABLE_SMART_ROUTER_ORCHESTRATOR=1, spawn a child, invoke the installed Smart Router skill to turn routing off, spawn another child, turn routing back on through the skill, and spawn a third child in the same real TUI session. - Expected: Smart Router is the only user-installed Claude skill; all three uniquely tagged + Expected: only Smart Router is installed by default; opting in also installs smart-router-orchestrator. + Each invocation records the CLI confirmation in the native transcript and changes the saved + routing controls, even with collapsed terminal output; all three uniquely tagged calculations complete in native child sessions; only the first and third show the subagent-routing banner and produce live gateway decisions correlated with those children. No first-prompt routing wrapper starts. """ session = live_session + session.env["TMPDIR"] = str(tmp_path) session.env["ENABLE_SMART_ROUTING_V2"] = "1" session.env["ENABLE_SMART_ROUTING_SUBAGENT_ONLY"] = "1" + if orchestration_enabled: + session.env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "1" use_managed_config_fixture(session, "claude_smart_routing") session.run( "configure", @@ -286,19 +331,30 @@ def test_smart_router_skill_toggles_claude_subagent_routing(live_session, worksp @pytest.mark.live @pytest.mark.codex @pytest.mark.managed_fixture -def test_smart_router_skill_toggles_codex_subagent_routing(live_session, workspace): - """Scenario: launch Codex with subagent routing enabled, spawn a child, invoke the +@pytest.mark.parametrize( + "orchestration_enabled", [False, True], ids=["routing-only", "orchestration"] +) +def test_smart_router_skill_toggles_codex_subagent_routing( + live_session, workspace, tmp_path, orchestration_enabled +): + """Scenario: launch Codex with subagent routing enabled and orchestration unset + or opted in through ENABLE_SMART_ROUTER_ORCHESTRATOR=1, spawn a child, invoke the installed Smart Router skill to turn routing off, spawn another child, turn routing back on through the skill, and spawn a third child in the same real TUI session. - Expected: Smart Router is the only user-installed Codex skill; all three uniquely tagged + Expected: only Smart Router is installed by default; opting in also installs smart-router-orchestrator. + Each invocation records the CLI confirmation in the native transcript and changes the saved + routing controls, even with collapsed terminal output; all three uniquely tagged calculations complete in native child sessions; only the first and third show the subagent-routing banner and produce live gateway decisions correlated with those children. No first-prompt interposer starts. """ session = live_session + session.env["TMPDIR"] = str(tmp_path) session.env["ENABLE_SMART_ROUTING_V2"] = "1" session.env["ENABLE_SMART_ROUTING_SUBAGENT_ONLY"] = "1" + if orchestration_enabled: + session.env["ENABLE_SMART_ROUTER_ORCHESTRATOR"] = "1" use_managed_config_fixture(session, "codex_smart_routing") session.run( "configure", diff --git a/tests/integration/utils/evidence.py b/tests/integration/utils/evidence.py index 938fb7eba..d3f77a893 100644 --- a/tests/integration/utils/evidence.py +++ b/tests/integration/utils/evidence.py @@ -52,6 +52,38 @@ def assistant_answers(agent: str, records: list[dict]) -> list[str]: return helper.assistant_answers(records) if helper is not None else [] +def tool_outputs(agent: str, records: list[dict]) -> list[str]: + """Read native tool results even when their terminal output is collapsed.""" + contents = [] + for record in records: + if agent == "claude" and record.get("type") == "user": + contents.extend( + part.get("content") + for part in record.get("message", {}).get("content", []) + if isinstance(part, dict) + and part.get("type") == "tool_result" + and not part.get("is_error") + ) + if agent == "codex" and record.get("type") == "response_item": + payload = record.get("payload", {}) + if payload.get("type") in {"function_call_output", "custom_tool_call_output"}: + contents.append(payload.get("output")) + + outputs = [] + for content in contents: + if isinstance(content, str): + outputs.append(content) + elif isinstance(content, list): + outputs.extend( + part["text"] + for part in content + if isinstance(part, dict) + and part.get("type") in {"text", "input_text"} + and isinstance(part.get("text"), str) + ) + return outputs + + def is_child_session(agent: str, path: str, records: list[dict]) -> bool: return _AGENT_HELPERS.get(agent, codex).is_child_session(path, records) diff --git a/tests/test_claude_smart_routing_v2.py b/tests/test_claude_smart_routing_v2.py index 6179e3a75..103062622 100644 --- a/tests/test_claude_smart_routing_v2.py +++ b/tests/test_claude_smart_routing_v2.py @@ -610,6 +610,7 @@ def fake_select(workspace, token, task, route_options, resolve, **kwargs): } def test_routes_agent_prompt_with_initialized_model_menu(self, tmp_path, monkeypatch): + monkeypatch.setenv("ENABLE_SMART_ROUTER_ORCHESTRATOR", "0") captured = {} decisions_path = tmp_path / "decisions.jsonl" monkeypatch.setattr(v2.claude_routing, "DECISIONS_PATH", decisions_path) diff --git a/tests/test_codex_routing.py b/tests/test_codex_routing.py index f18ea859f..9df7997ce 100644 --- a/tests/test_codex_routing.py +++ b/tests/test_codex_routing.py @@ -153,6 +153,7 @@ def test_router_failure_fails_open(monkeypatch): def test_spawn_rewrite_preserves_original_input(monkeypatch): + monkeypatch.setenv("ENABLE_SMART_ROUTER_ORCHESTRATOR", "0") encrypted_message = {"encrypted": "opaque-ciphertext"} payload = { "tool_name": "collaborationspawn_agent", @@ -204,7 +205,14 @@ def test_spawn_rewrite_preserves_original_input(monkeypatch): assert hook["permissionDecisionReason"] == expected_message -def test_spawn_rewrite_uses_codex_model_id_for_uc_endpoint(monkeypatch): +def test_spawn_rewrite_uses_codex_model_id_for_uc_endpoint(monkeypatch, tmp_path): + session_file = tmp_path / "env.json" + session_file.write_text("{}") + monkeypatch.setenv("UCODE_SESSION_ENV_FILE", str(session_file)) + monkeypatch.setenv("ENABLE_SMART_ROUTER_ORCHESTRATOR", "1") + monkeypatch.setenv("ENABLE_SMART_ROUTING_SUBAGENT_ONLY", "1") + monkeypatch.setenv("ENABLE_SMART_ROUTING_V2", "1") + monkeypatch.delenv("ISAAC_LAUNCH_MODE", raising=False) monkeypatch.setattr( codex_routing, "request_routing_decision", @@ -230,12 +238,13 @@ def test_spawn_rewrite_uses_codex_model_id_for_uc_endpoint(monkeypatch): expected_message = ( "\n┌───────────────────────────────────────────────────────────────────────────┐\n" "│ Using Unity Gateway Smart Router - Subagent │\n" - "│ Subagent : routing-smoke-test │\n" + "│ Subagent : routing-smoke-test [orchestrator on] │\n" "│ Selected Model : gpt-5.6-luna │\n" "└───────────────────────────────────────────────────────────────────────────┘" ) assert output["systemMessage"] == expected_message assert output["hookSpecificOutput"]["updatedInput"]["model"] == "gpt-5.6-luna" + assert output["hookSpecificOutput"]["updatedInput"]["task_name"] == "routing-smoke-test" def test_codex_model_id_maps_uc_gpt_models_to_codex_slugs(): diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 287e02516..1597a84b7 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -263,19 +263,16 @@ def start_interposer(*args, **kwargs): assert "--profile myprof" in hook_override assert "--model system.ai.gpt-5-6-sol" in hook_override assert "--model system.ai.glm-5-2" in hook_override - assert processes[0].argv[10:12] == [ - "--config", - ( - "shell_environment_policy.set.UCODE_SESSION_ENV_FILE=" - f'"{os.environ["UCODE_SESSION_ENV_FILE"]}"' - ), - ] - assert processes[0].argv[12:14] == [ - "--config", + config_values = processes[0].argv[3:-2:2] + assert ( + "shell_environment_policy.set.UCODE_SESSION_ENV_FILE=" + f'"{os.environ["UCODE_SESSION_ENV_FILE"]}"' + ) in config_values + assert ( "shell_environment_policy.set.UCODE_SMART_ROUTER_PYTHON=" - + json.dumps(os.environ["UCODE_SMART_ROUTER_PYTHON"]), - ] - assert processes[0].argv[14:] == [ + + json.dumps(os.environ["UCODE_SMART_ROUTER_PYTHON"]) + ) in config_values + assert processes[0].argv[-2:] == [ "--listen", "ws://127.0.0.1:41001", ] @@ -420,7 +417,7 @@ def fake_exec(argv): assert os.environ[v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR] == "1" assert os.environ[v2.OAUTH_TOKEN_ENV_VAR] == "token" - def test_v2_pre_tool_hook_preserves_user_hooks(self, tmp_path, monkeypatch): + def test_v2_pre_tool_hook_leaves_saved_hooks_to_codex(self, tmp_path, monkeypatch): codex_home = tmp_path / ".codex" codex_home.mkdir() (codex_home / "config.toml").write_text( @@ -433,16 +430,18 @@ def test_v2_pre_tool_hook_preserves_user_hooks(self, tmp_path, monkeypatch): ) monkeypatch.setenv("CODEX_HOME", str(codex_home)) - configured = v2._v2_pre_tool_use_hooks( + before = (codex_home / "config.toml").read_bytes() + configured = v2._v2_hooks( {"workspace": WS, "profile": "myprof"}, ["system.ai.gpt-5-6-sol"], - ) + )["PreToolUse"] - assert configured[0]["hooks"][0]["command"] == "user-policy" - assert configured[1]["matcher"] == "Agent|.*spawn_agent$" - assert "--model system.ai.gpt-5-6-sol" in configured[1]["hooks"][0]["command"] + assert len(configured) == 1 + assert configured[0]["matcher"] == "Agent|.*spawn_agent$" + assert "--model system.ai.gpt-5-6-sol" in configured[0]["hooks"][0]["command"] + assert (codex_home / "config.toml").read_bytes() == before - def test_v2_pre_tool_hook_replaces_existing_ucode_hook(self, tmp_path, monkeypatch): + def test_v2_pre_tool_hook_uses_current_model(self, tmp_path, monkeypatch): monkeypatch.setattr("ucode.databricks.ug_binary", lambda: "/bin/ug") codex_home = tmp_path / ".codex" codex_home.mkdir() @@ -456,10 +455,10 @@ def test_v2_pre_tool_hook_replaces_existing_ucode_hook(self, tmp_path, monkeypat ) monkeypatch.setenv("CODEX_HOME", str(codex_home)) - configured = v2._v2_pre_tool_use_hooks( + configured = v2._v2_hooks( {"workspace": WS, "profile": "myprof"}, ["system.ai.gpt-5-6-sol"], - ) + )["PreToolUse"] routing_commands = [ hook["command"] diff --git a/tests/test_integration_evidence.py b/tests/test_integration_evidence.py index 910984916..cf04efc76 100644 --- a/tests/test_integration_evidence.py +++ b/tests/test_integration_evidence.py @@ -10,6 +10,7 @@ SubagentCalculation, assert_no_terminal_api_error, assistant_answer_contains, + tool_outputs, ) @@ -216,3 +217,76 @@ def test_completed_task_model_assertion_requires_exact_singleton(tmp_path, agent session = _transcript_session(tmp_path, agent, {"parent.jsonl": records}) with pytest.raises(AssertionError): evidence.assert_completed_task_model(session, agent, "value", "expected") + + +@pytest.mark.parametrize( + "agent,record", + [ + ( + "claude", + { + "type": "user", + "message": {"content": [{"type": "tool_result", "content": "confirmed"}]}, + }, + ), + ( + "claude", + { + "type": "user", + "message": { + "content": [ + { + "type": "tool_result", + "content": [{"type": "text", "text": "confirmed"}], + } + ] + }, + }, + ), + ( + "codex", + { + "type": "response_item", + "payload": {"type": "function_call_output", "output": "confirmed"}, + }, + ), + ( + "codex", + { + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "output": [{"type": "input_text", "text": "confirmed"}], + }, + }, + ), + ], +) +def test_tool_outputs_read_native_string_and_block_results(agent, record): + assert tool_outputs(agent, [record]) == ["confirmed"] + + +@pytest.mark.parametrize("agent", ["claude", "codex"]) +def test_tool_outputs_exclude_user_echoes_and_assistant_claims(agent): + records = [ + {"type": "user", "message": {"role": "user", "content": "confirmed"}}, + { + "type": "assistant", + "message": {"role": "assistant", "content": [{"type": "text", "text": "confirmed"}]}, + }, + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "confirmed"}], + }, + }, + { + "type": "user", + "message": { + "content": [{"type": "tool_result", "content": "confirmed", "is_error": True}] + }, + }, + ] + assert tool_outputs(agent, records) == []