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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions skills/smart-router-orchestrator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ The existing `ENABLE_SMART_ROUTER_ORCHESTRATOR=1` opt-in remains supported when
`SMART_ROUTER_CONFIG_VERSION` is unset; `subagent_only_v0` explicitly leaves orchestration off.
The feature is off by default; routing alone installs only `smart-router`.

`SKILL.claude.md` and `SKILL.codex.md` hold each agent's instructions. UG installs
the selected file as `SKILL.md` and injects that same version through the hooks.
Role definitions and other bundle resources remain shared.

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.
Expand Down
146 changes: 146 additions & 0 deletions skills/smart-router-orchestrator/SKILL.codex.md
Original file line number Diff line number Diff line change
@@ -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:<role>"`.
**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.
3 changes: 3 additions & 0 deletions src/ucode/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
LOCALHOST = "localhost"
LOOPBACK_HOST = "127.0.0.1"

AGENT_CLAUDE = "claude"
AGENT_CODEX = "codex"

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"
Expand Down
22 changes: 16 additions & 6 deletions src/ucode/skills.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ def _validate_skill_name(skill_name: str) -> None:
raise ValueError(f"Invalid skill name: {skill_name!r}")


def skill_entrypoint(skill_dir: Path, agent: str) -> Path:
entrypoint = skill_dir / f"SKILL.{agent}.md"
return entrypoint if entrypoint.is_file() else skill_dir / "SKILL.md"


def _remove_skill_path(destination: Path) -> bool:
"""Remove an existing skill path so an install is an exact replacement."""
if destination.is_symlink() or destination.is_file():
Expand All @@ -39,14 +44,18 @@ def _remove_skill_path(destination: Path) -> bool:
return False


def _bundle_digest(skill_dir: Path) -> str | None:
def _bundle_digest(skill_dir: Path, *, entrypoint: Path | None = None) -> str | None:
"""Digest a skill's paths and contents, or return None for an invalid bundle."""
if skill_dir.is_symlink() or not skill_dir.is_dir():
return None

digest = hashlib.sha256()
try:
for path in sorted(skill_dir.rglob("*")):
paths = {path.relative_to(skill_dir): path for path in skill_dir.rglob("*")}
if entrypoint is not None:
# Compare the bundle as installed, with the selected instructions at SKILL.md.
paths[Path("SKILL.md")] = entrypoint
for relative, path in sorted(paths.items()):
if path.is_symlink():
return None
if path.is_file():
Expand All @@ -57,8 +66,7 @@ def _bundle_digest(skill_dir: Path) -> str | None:
content = b""
else:
return None
relative = path.relative_to(skill_dir).as_posix().encode()
digest.update(kind + b"\0" + relative + b"\0" + content)
digest.update(kind + b"\0" + relative.as_posix().encode() + b"\0" + content)
except OSError:
return None
return digest.hexdigest()
Expand All @@ -72,9 +80,10 @@ def install_skill(skill_name: str, agent: str, home: Path | None = None) -> Path
except KeyError:
raise ValueError(f"Unsupported skill agent: {agent!r}") from None
source = _skills_source() / skill_name
if not (source / "SKILL.md").is_file():
entrypoint = skill_entrypoint(source, agent)
if not entrypoint.is_file():
raise RuntimeError(f"Unity Gateway's `{skill_name}` skill resource is missing.")
source_digest = _bundle_digest(source)
source_digest = _bundle_digest(source, entrypoint=entrypoint)
if source_digest is None:
raise RuntimeError(f"Unity Gateway's `{skill_name}` skill resource is invalid.")

Expand All @@ -83,6 +92,7 @@ def install_skill(skill_name: str, agent: str, home: Path | None = None) -> Path
if _bundle_digest(destination) != source_digest:
_remove_skill_path(destination)
shutil.copytree(source, destination)
shutil.copy2(entrypoint, destination / "SKILL.md")
return destination


Expand Down
20 changes: 13 additions & 7 deletions src/ucode/smart_routing/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from pathlib import Path

from ucode import skills
from ucode.constants import ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR
from ucode.constants import (
AGENT_CLAUDE,
AGENT_CODEX,
ENABLE_SMART_ROUTER_ORCHESTRATOR_ENV_VAR,
)
from ucode.smart_routing.config import resolve_environment
from ucode.smart_routing.hooks import sync_managed_hooks
from ucode.smart_routing.session_env import effective_environment, session_env_path
Expand Down Expand Up @@ -62,13 +66,13 @@ def add_claude_agents(plugin_dir: Path) -> None:
def sync_hooks(doc: dict, *, agent: str) -> None:
groups = {}
if feature_enabled():
argv = [sys.executable, "-m", HOOK_MODULE]
argv = [sys.executable, "-m", HOOK_MODULE, "--agent", agent]
hook = {
"type": "command",
"command": shlex.join(argv),
"timeout": 5,
}
if agent == "codex":
if agent == AGENT_CODEX:
hook["command_windows"] = subprocess.list2cmdline(argv)
groups = {
"UserPromptSubmit": [{"hooks": [hook]}],
Expand All @@ -77,7 +81,7 @@ def sync_hooks(doc: dict, *, agent: str) -> None:
sync_managed_hooks(doc, HOOK_MODULE, groups)


def hook_output(payload: object) -> dict | None:
def hook_output(payload: object, *, agent: str) -> dict | None:
if not isinstance(payload, dict) or payload.get("agent_id"):
return None
event = payload.get("hook_event_name")
Expand All @@ -89,7 +93,7 @@ def hook_output(payload: object) -> dict | None:
if enabled():
directory = skill_directory()
try:
workflow = (directory / "SKILL.md").read_text(encoding="utf-8")
workflow = skills.skill_entrypoint(directory, agent).read_text(encoding="utf-8")
except (OSError, UnicodeError):
return None
context = (
Expand All @@ -100,12 +104,14 @@ def hook_output(payload: object) -> dict | None:


def main() -> None:
argparse.ArgumentParser(description=__doc__).parse_args()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--agent", choices=(AGENT_CLAUDE, AGENT_CODEX), required=True)
args = parser.parse_args()
try:
payload = json.load(sys.stdin)
except (OSError, UnicodeError, ValueError):
return
output = hook_output(payload)
output = hook_output(payload, agent=args.agent)
if output is not None:
print(json.dumps(output))

Expand Down
4 changes: 4 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ 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.

`test_skills.py` checks agent-specific instruction installation, bundle updates,
and matching prompt/compaction hook content for Claude and Codex. It also covers
shared `SKILL.md` bundles. These are component checks, not live agent execution.

`test_smart_routing_config.py` includes a 162-case Cartesian component oracle: all three legacy
routing flags take unset, `0`, and `1`, while the selector takes unset or one of the five
supported presets, including the customer first-prompt-and-subagent mode. It uses the named
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ timeout. Offline PTY checks cover this ordering.
The shared wait also runs before CUJ4's Claude preset sessions exit, after delegated-task
evidence is checked, because Claude can resume a child after its first completed answer.

Agent-specific bundled instruction selection and prompt/compaction hook content
are covered in `../test_skills.py`; those component checks do not run live agents.

`TestChildStdoutLaunch` in `../test_cli.py` covers clean Claude print-mode and
Codex exec/app-server stdout, early launch errors, and forwarding through ug's `--`.
Token membership before the agent's `--` intentionally also matches option values and prompt
Expand Down
Loading
Loading