diff --git a/skills/smart-router-orchestrator/README.md b/skills/smart-router-orchestrator/README.md index c22625d0a..8da51d8ee 100644 --- a/skills/smart-router-orchestrator/README.md +++ b/skills/smart-router-orchestrator/README.md @@ -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. diff --git a/skills/smart-router-orchestrator/SKILL.md b/skills/smart-router-orchestrator/SKILL.claude.md similarity index 100% rename from skills/smart-router-orchestrator/SKILL.md rename to skills/smart-router-orchestrator/SKILL.claude.md diff --git a/skills/smart-router-orchestrator/SKILL.codex.md b/skills/smart-router-orchestrator/SKILL.codex.md new file mode 100644 index 000000000..21787f23b --- /dev/null +++ b/skills/smart-router-orchestrator/SKILL.codex.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/src/ucode/constants.py b/src/ucode/constants.py index 030be1aa8..7119699fe 100644 --- a/src/ucode/constants.py +++ b/src/ucode/constants.py @@ -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" diff --git a/src/ucode/skills.py b/src/ucode/skills.py index 0adb49688..5aa4ac75f 100644 --- a/src/ucode/skills.py +++ b/src/ucode/skills.py @@ -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(): @@ -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(): @@ -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() @@ -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.") @@ -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 diff --git a/src/ucode/smart_routing/orchestrator.py b/src/ucode/smart_routing/orchestrator.py index 61d9681ed..a09aecb22 100644 --- a/src/ucode/smart_routing/orchestrator.py +++ b/src/ucode/smart_routing/orchestrator.py @@ -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 @@ -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]}], @@ -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") @@ -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 = ( @@ -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)) diff --git a/tests/README.md b/tests/README.md index d479c8110..6421ddd4b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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 diff --git a/tests/integration/README.md b/tests/integration/README.md index cd4809d9c..e81b10ae2 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -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 diff --git a/tests/test_skills.py b/tests/test_skills.py index 958162354..a55737bf0 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,10 +1,12 @@ """Tests for skills shipped with Unity Gateway.""" +import shlex from pathlib import Path import pytest from ucode import skills +from ucode.smart_routing import orchestrator, session_env def _write_skill(root: Path, name: str, content: str = "version one") -> Path: @@ -28,24 +30,34 @@ def bundled_skill(tmp_path, monkeypatch): @pytest.mark.parametrize( ("agent", "root"), [("claude", ".claude/skills"), ("codex", ".codex/skills")] ) -def test_copies_named_skill_to_agent_directory(bundled_skill, agent, root): +@pytest.mark.parametrize("agent_specific", [False, True]) +def test_copies_named_skill_to_agent_directory(bundled_skill, agent, root, agent_specific): source_skill, home = bundled_skill _write_skill(source_skill.parent, "second-skill") + if agent_specific: + source_skill.joinpath("SKILL.md").unlink() + for name in ("claude", "codex"): + source_skill.joinpath(f"SKILL.{name}.md").write_text(f"{name} instructions") installed = skills.install_skill(skills.SMART_ROUTER_SKILL, agent, home) assert installed == home / root / "smart-router" - assert installed.joinpath("SKILL.md").read_text() == "version one" + expected = f"{agent} instructions" if agent_specific else "version one" + assert installed.joinpath("SKILL.md").read_text() == expected assert installed.joinpath("references/details.md").read_text() == "details" assert not home.joinpath(root, "second-skill").exists() -def test_reinstall_replaces_changed_bundle_and_skips_unchanged_bundle(bundled_skill, monkeypatch): +@pytest.mark.parametrize("entrypoint", ["SKILL.md", "SKILL.codex.md"]) +def test_reinstall_replaces_changed_bundle_and_skips_unchanged_bundle( + bundled_skill, monkeypatch, entrypoint +): source_skill, home = bundled_skill + source_skill.joinpath(entrypoint).write_text("version one") installed = skills.install_skill(skills.SMART_ROUTER_SKILL, "codex", home) installed.joinpath("stale.txt").write_text("remove me") - source_skill.joinpath("SKILL.md").write_text("version two") + source_skill.joinpath(entrypoint).write_text("version two") installed = skills.install_skill(skills.SMART_ROUTER_SKILL, "codex", home) @@ -63,6 +75,39 @@ def fail_copy(*_args, **_kwargs): assert installed_again == installed +@pytest.mark.parametrize("agent", ["claude", "codex"]) +@pytest.mark.parametrize( + "payload", + [ + {"hook_event_name": "UserPromptSubmit"}, + {"hook_event_name": "SessionStart", "source": "compact"}, + ], +) +def test_orchestrator_injects_the_installed_agent_instructions( + bundled_skill, monkeypatch, agent, payload +): + source_skill, home = bundled_skill + source = _write_skill(source_skill.parent, skills.SMART_ROUTER_ORCHESTRATOR_SKILL) + source.joinpath("SKILL.md").unlink() + for name in ("claude", "codex"): + source.joinpath(f"SKILL.{name}.md").write_text(f"{name} instructions") + installed = skills.install_skill(skills.SMART_ROUTER_ORCHESTRATOR_SKILL, agent, home) + monkeypatch.setenv("ENABLE_SMART_ROUTER_ORCHESTRATOR", "1") + monkeypatch.setenv("ENABLE_SMART_ROUTING_SUBAGENT_ONLY", "1") + session_env.start_session() + + settings = {} + orchestrator.sync_hooks(settings, agent=agent) + hook = settings["hooks"][payload["hook_event_name"]][0]["hooks"][0] + assert shlex.split(hook["command"])[-2:] == ["--agent", agent] + output = orchestrator.hook_output(payload, agent=agent) + + assert output is not None + context = output["hookSpecificOutput"]["additionalContext"] + assert context.endswith(f"\n\n{agent} instructions") + assert context.endswith(installed.joinpath("SKILL.md").read_text()) + + def test_uninstalls_one_skill_from_agent_roots(bundled_skill): source_skill, home = bundled_skill _write_skill(source_skill.parent, "second-skill")