From a7713875b5a3338f52fc475821d9b72a58008379 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 14:06:28 -0700 Subject: [PATCH 01/18] chore: commit auto-generated files --- src/autoskillit/agents/AGENTS.md | 4 +- .../agents/friction-batch-scanner.md | 26 ++++ .../agents/friction-category-analyzer.md | 31 ++++ src/autoskillit/agents/pr-source-reader.md | 26 ++++ src/autoskillit/agents/pr-synthesizer.md | 24 +++ .../agents/research-source-reader.md | 25 +++ .../agents/research-synthesizer.md | 27 ++++ .../core/types/_type_protocols_backend.py | 2 +- src/autoskillit/execution/backends/claude.py | 3 +- src/autoskillit/execution/backends/codex.py | 37 ++++- src/autoskillit/workspace/session_skills.py | 88 +++++++++-- tests/cli/test_cook_interactive.py | 87 +++++++++++ .../test_skill_semantic_authenticity.py | 30 ++++ tests/core/test_agent_definition.py | 22 +++ tests/core/test_backend_protocols.py | 2 +- .../execution/backends/test_codex_backend.py | 57 ++++++- .../test_skill_semantic_trace_conformance.py | 103 +++++++++++++ .../test_agent_definition_rendering.py | 7 +- tests/workspace/test_corridor_composition.py | 2 +- tests/workspace/test_session_skills_codex.py | 144 ++++++++++++++++++ .../workspace/test_session_skills_provider.py | 1 + .../test_session_skills_stale_path.py | 1 + 22 files changed, 724 insertions(+), 25 deletions(-) create mode 100644 src/autoskillit/agents/friction-batch-scanner.md create mode 100644 src/autoskillit/agents/friction-category-analyzer.md create mode 100644 src/autoskillit/agents/pr-source-reader.md create mode 100644 src/autoskillit/agents/pr-synthesizer.md create mode 100644 src/autoskillit/agents/research-source-reader.md create mode 100644 src/autoskillit/agents/research-synthesizer.md diff --git a/src/autoskillit/agents/AGENTS.md b/src/autoskillit/agents/AGENTS.md index 530114b12..5dffc41d6 100644 --- a/src/autoskillit/agents/AGENTS.md +++ b/src/autoskillit/agents/AGENTS.md @@ -61,7 +61,9 @@ Current packless agents: `wp-elaborator`, `session-log-reader`, `audit-impl-slic `pr-review-auditor-baseline`, `pr-review-auditor-v1-precision`, `pr-review-auditor-v2-contrastive`, `pr-review-auditor-v3-simulation`, `pr-review-auditor-reachability`, `pr-review-auditor-abstraction-surface`, `audit-impl-deviation-evaluator`, -`web-evidence-researcher`. +`web-evidence-researcher`, `pr-source-reader`, `pr-synthesizer`, +`research-source-reader`, `research-synthesizer`, `friction-batch-scanner`, +`friction-category-analyzer`. The web-evidence role's Codex definition owns its Luna/xhigh/read-only/live-web/no-descendants policy; callers do not pass model or permission overrides. Claude inherits the configured/default child model because this definition omits the top-level `model` key. diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md new file mode 100644 index 000000000..3c582cb7b --- /dev/null +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -0,0 +1,26 @@ +--- +name: friction-batch-scanner +description: "Use when one parent-assigned log batch must be scanned for friction evidence." +tools: [Read, Grep] +model: haiku +maxTurns: 30 +--- + +# Friction batch scanner + +Scan only the log files assigned by the parent, using the supplied signal patterns. +Search before reading bounded context around each hit. Confirm the event from that +context and do not read whole logs, inspect other files, diagnose root causes, +write files, or synthesize across batches. Report concrete file and line bounds; +do not guess when context is insufficient. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "scanned_files": ["parent-assigned log path"], + "events": [{"file": "log path", "line_start": 1, "line_end": 1, "category": "supplied category", "description": "one-line observed event"}], + "unknowns": ["unresolved hit or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/friction-category-analyzer.md b/src/autoskillit/agents/friction-category-analyzer.md new file mode 100644 index 000000000..6f20551b4 --- /dev/null +++ b/src/autoskillit/agents/friction-category-analyzer.md @@ -0,0 +1,31 @@ +--- +name: friction-category-analyzer +description: "Use when supplied indicators need validation for one parent-assigned friction category." +tools: [Read, Grep] +model: sonnet +maxTurns: 30 +--- + +# Friction category analyzer + +Analyze only the category, indicators, and log locations supplied by the parent. +Read bounded context at those locations to confirm or reclassify each indicator. +Do not discover other logs, write files, or synthesize the full audit. Separate +observations from inferences, cite file and line bounds, and retain unresolved +cases when the supplied evidence is insufficient. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "category": "parent-assigned category", + "confirmed_occurrences": 0, + "distinct_sessions": 0, + "evidence": [{"file": "log path", "line_start": 1, "line_end": 1, "sequence": "observed sequence", "blocker": "observed blocker"}], + "shared_pattern": "supported pattern or null", + "root_cause": "inference with basis or null", + "mitigations": ["concrete mitigation"], + "unknowns": ["unresolved indicator"] +} +``` diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md new file mode 100644 index 000000000..be51a364d --- /dev/null +++ b/src/autoskillit/agents/pr-source-reader.md @@ -0,0 +1,26 @@ +--- +name: pr-source-reader +description: "Use when one parent-specified PR source artifact must yield bounded evidence." +tools: [Read] +model: haiku +maxTurns: 20 +--- + +# PR source reader + +Read only the source artifact named by the parent. Extract the requested sections +faithfully and keep source headings or other location cues with each result. Do not +inspect other repository files, modify anything, use GitHub, or make the final PR +summary. If the artifact is missing or cannot answer a requested field, preserve +that gap instead of guessing. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "source": "parent-supplied path", + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "location": "heading or line cue"}], + "unknowns": ["unresolved field or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/pr-synthesizer.md b/src/autoskillit/agents/pr-synthesizer.md new file mode 100644 index 000000000..0f580d182 --- /dev/null +++ b/src/autoskillit/agents/pr-synthesizer.md @@ -0,0 +1,24 @@ +--- +name: pr-synthesizer +description: "Use when collected PR source evidence needs an overall pull request summary." +tools: [Read] +model: sonnet +maxTurns: 20 +--- + +# PR synthesizer + +Synthesize only the evidence supplied by the parent into a concise overall pull +request summary. Do not inspect the repository or GitHub, introduce unsupported +claims, write files, or create the pull request. Preserve material uncertainty and +conflicts in the supplied evidence. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "summary": "two or three evidence-grounded sentences", + "unknowns": ["material unresolved point"] +} +``` diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md new file mode 100644 index 000000000..c7a475f69 --- /dev/null +++ b/src/autoskillit/agents/research-source-reader.md @@ -0,0 +1,25 @@ +--- +name: research-source-reader +description: "Use when one parent-specified research artifact must yield bounded evidence." +tools: [Read] +model: haiku +maxTurns: 20 +--- + +# Research source reader + +Read only the research artifact named by the parent. Extract the requested report +or experiment-plan fields faithfully, retaining headings or other location cues. +Do not inspect unrelated files, synthesize a recommendation, select lenses, or +modify anything. Report absent or ambiguous fields without filling them in. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "source": "parent-supplied path", + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "location": "heading or line cue"}], + "unknowns": ["unresolved field or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/research-synthesizer.md b/src/autoskillit/agents/research-synthesizer.md new file mode 100644 index 000000000..8155fdb40 --- /dev/null +++ b/src/autoskillit/agents/research-synthesizer.md @@ -0,0 +1,27 @@ +--- +name: research-synthesizer +description: "Use when collected research evidence needs a direction and experiment-lens recommendation." +tools: [Read] +model: sonnet +maxTurns: 20 +--- + +# Research synthesizer + +Use only the report and experiment-plan evidence supplied by the parent. Produce +the requested directional recommendation or lens selection without inspecting the +repository, inventing findings, writing files, or invoking a lens. Select lens +slugs only from the parent's allowed table and state when the supplied evidence is +insufficient. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "recommendation": "one to three evidence-grounded sentences, or null", + "selected_lenses": ["allowed-lens-slug"], + "rationale": "brief evidence-grounded rationale", + "unknowns": ["material unresolved point"] +} +``` diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index 6c2f32e25..d0e9d9ecf 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -353,7 +353,7 @@ def setup_session_dir( parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: ... + ) -> frozenset[str] | None: ... def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 61abf80af..08279db6a 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -513,10 +513,11 @@ def setup_session_dir( parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: + ) -> frozenset[str] | None: del execution_role if explorer_binding_env: raise ValueError(_EXPLORER_BINDING_REJECTION_MESSAGE) + return None def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 16b08a787..5dbce7941 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -879,7 +879,8 @@ def _canonical_codex_model_effort( ) -_CODEX_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "review", "reviewer"}) +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "worker"}) +_CODEX_RESERVED_ONLY_AGENT_NAMES = frozenset({"review", "reviewer"}) def _preflight_agent_projection( @@ -893,7 +894,9 @@ def _preflight_agent_projection( duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: raise ValueError(f"duplicate Codex agent definitions: {duplicates}") - built_in_collisions = sorted(set(names) & _CODEX_BUILT_IN_AGENT_NAMES) + built_in_collisions = sorted( + set(names) & (CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | _CODEX_RESERVED_ONLY_AGENT_NAMES) + ) if built_in_collisions: raise ValueError(f"Codex built-in agent name collision: {built_in_collisions}") config_path = session_dir / "config.toml" @@ -1075,6 +1078,33 @@ def _register_agent_tomls( return len(registrations) // 4 +def _effective_agent_names(session_dir: Path) -> frozenset[str]: + """Return roles backed by readable TOML in the finalized generated home.""" + config = tomllib.loads((session_dir / "config.toml").read_text(encoding="utf-8")) + configured_agents = config.get("agents", {}) + if not isinstance(configured_agents, dict): + raise ValueError("Codex config agents table must be a mapping") + + effective = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) + for name, registration in configured_agents.items(): + if not name.strip() or not isinstance(registration, dict): + continue + config_file = registration.get("config_file") + if not isinstance(config_file, str) or not config_file.strip(): + continue + target = Path(config_file) + if not target.is_absolute(): + target = session_dir / target + try: + if not target.is_file(): + continue + tomllib.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + continue + effective.add(name) + return frozenset(effective) + + def _validate_existing_explorer_role_toml( toml_path: Path, definition: AgentDef, @@ -2198,7 +2228,7 @@ def setup_session_dir( agent_defs: tuple[AgentDef, ...] | None = None, explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: + ) -> frozenset[str]: assert self.source_codex_home is not None codex_home_source = self.source_codex_home config_path = session_dir / "config.toml" @@ -2278,6 +2308,7 @@ def setup_session_dir( session_dir, source_codex_home=codex_home_source, ) + return _effective_agent_names(session_dir) def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/workspace/session_skills.py b/src/autoskillit/workspace/session_skills.py index 1374a3f8d..fa41965f2 100644 --- a/src/autoskillit/workspace/session_skills.py +++ b/src/autoskillit/workspace/session_skills.py @@ -109,6 +109,20 @@ def _remove_and_verify(path: Path) -> bool: return True +def _remove_generated_home_skill_entry(path: Path) -> None: + """Remove one exact generated-home discovery entry without following it.""" + if not os.path.lexists(path): + return + if path.is_symlink(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + else: + raise RuntimeError(f"Refusing to remove invalid generated-home skill entry: {path}") + if os.path.lexists(path): + raise RuntimeError(f"Generated-home skill entry still exists after removal: {path}") + + def resolve_persistent_session_root( base_root: Path, backend: CodingAgentBackend, @@ -239,6 +253,8 @@ def unavailability_payload(self) -> dict[str, object]: def compile_session_skill_catalog( catalog: EffectiveSkillCatalogAuthority, backend: CodingAgentBackend, + *, + finalized_native_roles: frozenset[str] | None = None, ) -> CompiledSessionSkillCatalog: """Publish only skills whose mandatory semantics adapt on the selected backend.""" supported: list[SkillCatalogEntry] = [] @@ -264,6 +280,23 @@ def compile_session_skill_catalog( ) continue adaptation.validate_for(plan, backend=backend.name) + if finalized_native_roles is not None: + native_spawn_targets = { + adaptation.logical_role_mapping[spawn.role] for spawn in plan.child_spawns + } + missing_targets = sorted(native_spawn_targets - finalized_native_roles) + if missing_targets: + unavailable.append( + SkillUnavailableMetadata( + skill=skill.name, + backend=backend.name, + operation=SkillSemanticOperation.CHILD_SPAWN, + diagnostic=( + f"native child-spawn targets are unavailable: {missing_targets}" + ), + ) + ) + continue supported.append(cast(SkillCatalogEntry, skill)) filtered_names = {skill.name for skill in supported} namespace_sources = { @@ -874,7 +907,7 @@ def _initialize_bound_records( if persistent: _remove_and_verify(generated_home) - skills_dir = self._materialize_session( + skills_dir, finalized_records = self._materialize_session( generated_home, records, projection_context, @@ -891,7 +924,9 @@ def _initialize_bound_records( ) self._session_roots[session_id] = effective_root self._session_skills_subdirs[session_id] = owned_skills_subdir - self._session_skill_infos[session_id] = {member.name: member for member in records} + self._session_skill_infos[session_id] = { + member.name: member for member in finalized_records + } self._session_leases[session_id] = lease return initialized except BaseException as exc: @@ -926,7 +961,7 @@ def _materialize_session( compilation: CompiledSessionSkillCatalogAuthority | None = None, explorer_binding_env: _ExplorerBindingEnv | None = None, explorer_binding_env_factory: _ExplorerBindingEnvFactory | None = None, - ) -> ValidatedAddDir: + ) -> tuple[ValidatedAddDir, tuple[SkillAuthority, ...]]: backend = projection_context.backend add_dir = generated_home / SESSION_ADD_DIR_SUBDIR skills_base = add_dir / skills_subdir @@ -948,12 +983,6 @@ def _materialize_session( adaptation = backend.adapt_skill_semantics(plan) adaptation.validate_for(plan, backend=backend.name) - write_skill_unavailability_metadata( - add_dir, - compilation=compilation, - backend=backend.name if backend is not None else None, - ) - execution_role = ( effective_catalog.execution_role if effective_catalog is not None @@ -966,6 +995,7 @@ def _materialize_session( raise RuntimeError(f"Pre-launch check failed: {'; '.join(readiness.errors)}") if explorer_binding_env_factory is not None: explorer_binding_env = explorer_binding_env_factory(generated_home) + finalized_native_roles: frozenset[str] | None = None if backend is not None: setup_kwargs: _SessionSetupKwargs = { "parent_sandbox_mode": projection_context.parent_sandbox_mode, @@ -973,7 +1003,43 @@ def _materialize_session( } if explorer_binding_env is not None: setup_kwargs["explorer_binding_env"] = explorer_binding_env - backend.setup_session_dir(generated_home, **setup_kwargs) + finalized_native_roles = backend.setup_session_dir(generated_home, **setup_kwargs) + + if finalized_native_roles is not None and effective_catalog is not None: + assert backend is not None + reachability_compilation = compile_session_skill_catalog( + effective_catalog, + backend, + finalized_native_roles=finalized_native_roles, + ) + if compilation is not None and not isinstance( + compilation, CompiledSessionSkillCatalog + ): + raise SkillContractError( + "finalized native-role admission requires a concrete session compilation" + ) + prior_unavailable = compilation.unavailable if compilation is not None else () + compilation = CompiledSessionSkillCatalog( + backend=backend.name, + catalog=reachability_compilation.catalog, + unavailable=tuple( + sorted( + (*prior_unavailable, *reachability_compilation.unavailable), + key=lambda item: item.skill, + ) + ), + ) + effective_catalog = compilation.catalog + records = tuple(effective_catalog.skills) + discovery_root = generated_home / skills_subdir + for unavailable in reachability_compilation.unavailable: + _remove_generated_home_skill_entry(discovery_root / unavailable.skill) + + write_skill_unavailability_metadata( + add_dir, + compilation=compilation, + backend=backend.name if backend is not None else None, + ) ungated_context = SkillProjectionContext( cwd=projection_context.cwd, @@ -1022,7 +1088,7 @@ def _materialize_session( ) if layout_errors: raise RuntimeError("Session layout validation failed: " + "; ".join(layout_errors)) - return ValidatedAddDir(path=str(add_dir)) + return ValidatedAddDir(path=str(add_dir)), records @staticmethod def _create_inert_rollout_paths( diff --git a/tests/cli/test_cook_interactive.py b/tests/cli/test_cook_interactive.py index 57af74fa1..819240a81 100644 --- a/tests/cli/test_cook_interactive.py +++ b/tests/cli/test_cook_interactive.py @@ -310,6 +310,93 @@ def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: assert "defined as both" in guidance and "rejected" in guidance +def test_codex_cook_projects_only_spawnable_compose_pr_roles( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from autoskillit.core import SkillExecutionRole + from autoskillit.execution.backends.codex import CodexBackend, _effective_agent_names + from autoskillit.workspace import DefaultSkillResolver + + project_root = tmp_path / "project" + project_root.mkdir() + source_home = tmp_path / "home" / ".codex" + source_home.mkdir(parents=True) + (source_home / "config.toml").write_text( + 'cli_auth_credentials_store = "keyring"\n', + encoding="utf-8", + ) + (source_home / "auth.json").write_text("{}\n", encoding="utf-8") + backend = CodexBackend(source_codex_home=source_home) + catalog = DefaultSkillResolver().list_effective( + project_root, + SkillExecutionRole.SESSION, + cook_session=True, + ) + compose_pr = next(member for member in catalog.skills if member.name == "compose-pr") + assert compose_pr.semantic_plan is not None + adaptation = backend.adapt_skill_semantics(compose_pr.semantic_plan) + mapped_targets = { + adaptation.logical_role_mapping[spawn.role] + for spawn in compose_pr.semantic_plan.child_spawns + } + captured: dict[str, object] = {} + + @contextmanager + def cook_session_context(_self, **_kwargs: object): + yield CookSessionHandle( + view_id="codex-view", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + + def run_attempt(spec: CmdSpec, **kwargs: object) -> object: + generated_home = Path(spec.env["CODEX_HOME"]) + compose_projection = generated_home / "add-dir" / "skills" / "compose-pr" / "SKILL.md" + role_names = _effective_agent_names(generated_home) + captured["compose_projected"] = compose_projection.is_file() + captured["mapped_targets"] = mapped_targets + captured["role_names"] = role_names + kwargs["on_spawn"](101, 101) # type: ignore[operator] + kwargs["trace"].record_spawn() # type: ignore[union-attr] + kwargs["on_reaped"](101, 101) # type: ignore[operator] + return SimpleNamespace(pid=101, pgid=101, returncode=0) + + monkeypatch.chdir(project_root) + monkeypatch.setenv("MCP_CLIENT_BACKEND", "pre-test-backend") + monkeypatch.setattr(shutil, "which", lambda _name, **_kwargs: "/usr/bin/codex") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _project: False) + monkeypatch.setattr( + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: "", + ) + monkeypatch.setattr("autoskillit.core.write_registry_entry", lambda *args: None) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + run_attempt, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: None, + ) + monkeypatch.setattr(CodexBackend, "cook_session_context", cook_session_context) + monkeypatch.setattr( + CodexBackend, + "validate_interactive_invocation", + lambda _self, _spec: [], + ) + + cli.cook(backend=backend) + + assert captured["compose_projected"] is True + assert captured["mapped_targets"] == {"pr-source-reader", "pr-synthesizer"} + role_names = captured["role_names"] + assert isinstance(role_names, frozenset) + assert mapped_targets <= role_names + + def test_notification_capable_cook_has_no_pre_reveal_guidance( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/contracts/test_skill_semantic_authenticity.py b/tests/contracts/test_skill_semantic_authenticity.py index a0eb618a1..e688a62d3 100644 --- a/tests/contracts/test_skill_semantic_authenticity.py +++ b/tests/contracts/test_skill_semantic_authenticity.py @@ -122,3 +122,33 @@ def test_every_bundled_semantic_plan_adapts_on_every_registered_backend() -> Non violations.append(f"{skill_name}/{backend_name}: empty adaptation") assert plans assert not violations, "bundled semantic adaptation failures:\n" + "\n".join(violations) + + +def test_every_bundled_codex_child_spawn_targets_a_registered_role() -> None: + from autoskillit.core import load_bundled_agent_definitions + from autoskillit.execution.backends import CodexBackend + from autoskillit.execution.backends.codex import ( + CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES, + ) + from autoskillit.workspace import DefaultSkillResolver + + allowed = CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | { + definition.name for definition in load_bundled_agent_definitions() + } + plans = tuple( + (skill.name, skill.semantic_plan) + for skill in DefaultSkillResolver().list_all() + if skill.semantic_plan is not None + ) + violations: list[str] = [] + backend = CodexBackend() + for skill_name, plan in plans: + assert plan is not None + adaptation = backend.adapt_skill_semantics(plan) + targets = {adaptation.logical_role_mapping[spawn.role] for spawn in plan.child_spawns} + missing = sorted(targets - allowed) + if missing: + violations.append(f"{skill_name}: {missing}") + + assert plans + assert not violations, "unregistered bundled Codex child roles:\n" + "\n".join(violations) diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index 342a4c397..d126b3ed5 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -29,6 +29,15 @@ _EXPLORATION_BROKER_TOOLS = frozenset(f"{DIRECT_PREFIX}{tool}" for tool in EXPLORATION_TOOLS) +_SKILL_CHILD_ROLE_TOOLS = { + "pr-source-reader": ("Read",), + "pr-synthesizer": ("Read",), + "research-source-reader": ("Read",), + "research-synthesizer": ("Read",), + "friction-batch-scanner": ("Read", "Grep"), + "friction-category-analyzer": ("Read", "Grep"), +} + @pytest.mark.parametrize( ("name", "role_boundary"), @@ -107,6 +116,19 @@ def test_bundled_agent_catalog_loads_with_unique_digests() -> None: ) +def test_skill_child_roles_have_bounded_tools_and_usage_descriptions() -> None: + definitions = {definition.name: definition for definition in load_bundled_agent_definitions()} + + assert len(definitions) == 22 + assert _SKILL_CHILD_ROLE_TOOLS.keys() <= definitions.keys() + for name, expected_tools in _SKILL_CHILD_ROLE_TOOLS.items(): + definition = definitions[name] + assert definition.tools == expected_tools + assert definition.description.startswith("Use when ") + assert definition.description not in definition.body + assert not ({"Bash", "Write", "Edit"} & set(definition.tools)) + + def test_explicit_luna_projection_is_independent_from_claude_model(tmp_path: Path) -> None: path = tmp_path / "semantic-code-navigator.md" path.write_text( diff --git a/tests/core/test_backend_protocols.py b/tests/core/test_backend_protocols.py index 492efd826..a44ed09c0 100644 --- a/tests/core/test_backend_protocols.py +++ b/tests/core/test_backend_protocols.py @@ -352,7 +352,7 @@ def setup_session_dir( *, parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, - ) -> None: ... + ) -> frozenset[str] | None: ... def refresh_explorer_binding_env( self, diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 12b01cdb5..a06648cbf 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -1890,14 +1890,28 @@ def test_agent_toml_set_and_count_match_md_sources(self) -> None: def test_agent_toml_required_fields_present_and_nonempty(self) -> None: self._write_all_source_files() CodexBackend().setup_session_dir(self.session_dir) + required_new_roles = { + "friction-batch-scanner", + "friction-category-analyzer", + "pr-source-reader", + "pr-synthesizer", + "research-source-reader", + "research-synthesizer", + } definitions = { definition.name: definition for definition in load_agent_definitions(pkg_root() / "agents") } + generated_names = {path.stem for path in (self.session_dir / "agents").glob("*.toml")} + assert required_new_roles <= generated_names for toml_path in sorted((self.session_dir / "agents").glob("*.toml")): data = tomllib.loads(toml_path.read_text(encoding="utf-8")) assert data["name"], f"{toml_path.name}: name empty" assert data["description"], f"{toml_path.name}: description empty" + assert data["description"] == definitions[toml_path.stem].description + assert data["description"] != definitions[toml_path.stem].body + if toml_path.stem in required_new_roles: + assert data["description"].startswith("Use when ") assert data["developer_instructions"], ( f"{toml_path.name}: developer_instructions empty" ) @@ -2611,6 +2625,44 @@ def test_unrelated_ambient_agent_is_preserved_with_bundled_projection(self) -> N assert config["agents"]["wp-elaborator"]["config_file"] == ("agents/wp-elaborator.toml") assert not (self.session_dir / "agents" / "profile-specialist.toml").exists() + def test_setup_returns_only_finalized_spawnable_agent_names( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + valid_target = self.fake_home / "valid-ambient.toml" + valid_target.write_text('name = "valid-ambient"\n', encoding="utf-8") + source_relative_target = self.codex_home / "agents" / "source-only.toml" + source_relative_target.parent.mkdir() + source_relative_target.write_text('name = "source-only"\n', encoding="utf-8") + (self.codex_home / "config.toml").write_text( + self._CANONICAL_AUTOSKILLIT_MCP_CONFIG + + '\n[agents."valid-ambient"]\n' + + 'description = "absolute ambient role"\n' + + f'config_file = "{valid_target}"\n' + + '\n[agents."source-relative"]\n' + + 'description = "source-relative ambient role"\n' + + 'config_file = "agents/source-only.toml"\n', + encoding="utf-8", + ) + (self.codex_home / "auth.json").write_text("{}", encoding="utf-8") + monkeypatch.setenv(MCP_CLIENT_BACKEND_ENV_VAR, "pre-test-backend") + backend = CodexBackend() + + assert backend.ensure_pre_launch(session_dir=self.session_dir) == [] + role_names = backend.setup_session_dir(self.session_dir) + + eligible_bundled = { + definition.name + for definition in load_agent_definitions(pkg_root() / "agents") + if definition.name not in BUNDLED_EXPLORER_ROLES + } + assert role_names == frozenset( + {"default", "explorer", "worker", "valid-ambient"} | eligible_bundled + ) + finalized = tomllib.loads((self.session_dir / "config.toml").read_text(encoding="utf-8")) + assert set(finalized["agents"]) == ( + {"valid-ambient", "source-relative"} | eligible_bundled + ) + @pytest.mark.parametrize( "role", ( @@ -2686,10 +2738,11 @@ def test_duplicate_injected_roles_fail_before_mutation(self) -> None: assert (self.session_dir / "config.toml").read_text(encoding="utf-8") == original_config assert {path.name for path in self.session_dir.iterdir()} == {"config.toml"} - def test_built_in_agent_name_fails_before_mutation(self) -> None: + @pytest.mark.parametrize("role", ("worker", "review")) + def test_host_reserved_agent_name_fails_before_mutation(self, role: str) -> None: original_config = (self.session_dir / "config.toml").read_text(encoding="utf-8") definition = AgentDef( - name="explorer", + name=role, description="Reserved-role collision", tools=("Read",), model="sonnet", diff --git a/tests/execution/backends/test_skill_semantic_trace_conformance.py b/tests/execution/backends/test_skill_semantic_trace_conformance.py index 96eeea774..eb3b4deef 100644 --- a/tests/execution/backends/test_skill_semantic_trace_conformance.py +++ b/tests/execution/backends/test_skill_semantic_trace_conformance.py @@ -10,6 +10,8 @@ import pytest from autoskillit.core import ( + CODEX_EFFORT_MAPPING, + CODEX_MODEL_ALIASES, ChildModelPolicySpec, ChildSpawnSpec, ConcurrencySpec, @@ -405,6 +407,107 @@ def test_codex_semantic_policy_matches_generated_native_role_toml(tmp_path: Path ) +def test_compose_pr_real_codex_trace_spawns_then_joins_registered_roles() -> None: + skill_md = pkg_root() / "skills_extended" / "compose-pr" / "SKILL.md" + info = _skill_info_from_frontmatter("compose-pr", SkillSource.BUNDLED, skill_md) + assert not info.invalidities + assert info.semantic_plan is not None + plan = info.semantic_plan + adaptation = CodexBackend().adapt_skill_semantics(plan) + reader = adaptation.logical_role_mapping["pr-source-reader"] + synthesizer = adaptation.logical_role_mapping["pr-synthesizer"] + model, effort = adaptation.model_effort_policy[synthesizer] + assert (reader, synthesizer) == ("pr-source-reader", "pr-synthesizer") + assert (model, effort) == ( + CODEX_MODEL_ALIASES["sonnet"], + CODEX_EFFORT_MAPPING["sonnet"], + ) + + parent_events = [ + _codex_call( + "spawn-reader", + "spawn_agent", + { + "agent_type": reader, + "fork_turns": "none", + "task_name": "reader", + }, + ), + _codex_output("spawn-reader", {"task_name": "/root/reader"}), + _codex_call( + "spawn-synthesizer", + "spawn_agent", + { + "agent_type": synthesizer, + "fork_turns": "none", + "model": model, + "reasoning_effort": effort, + "task_name": "synthesizer", + }, + ), + _codex_output("spawn-synthesizer", {"task_name": "/root/synthesizer"}), + _codex_call("wait", "wait_agent", {"timeout_ms": 3_600_000}), + _codex_output("wait", {"timed_out": False}), + ] + for task_name in ("reader", "synthesizer"): + parent_events.append( + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": ( + "\n" + f'{{"agent_path":"/root/{task_name}","status":' + f'{{"completed":"child-delivery-complete {task_name}"}}}}\n' + "" + ), + } + ], + }, + } + ) + parent_events.append( + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "parent-delivery-complete"}], + }, + } + ) + child_events = [ + { + "type": "session_meta", + "payload": { + "id": f"child-{task_name}", + "parent_thread_id": "parent", + "agent_role": role, + "agent_path": f"/root/{task_name}", + "base_instructions": {"text": _DISCIPLINE_DIGEST}, + }, + } + for task_name, role in (("reader", reader), ("synthesizer", synthesizer)) + ] + + assert_generated_child_delivery( + parent_events, + child_events, + parent_id="parent", + agent_role=synthesizer, + output_discipline_digest=_DISCIPLINE_DIGEST, + backend="codex", + semantic_plan=plan, + semantic_adaptation=adaptation, + child_terminal_sentinel="child-delivery-complete", + parent_terminal_sentinel="parent-delivery-complete", + ) + + def test_dynamic_child_spawn_adapters_preserve_runtime_cardinality() -> None: role = "autoskillit:web-evidence-researcher" plan = SkillSemanticPlan( diff --git a/tests/workspace/test_agent_definition_rendering.py b/tests/workspace/test_agent_definition_rendering.py index ce4ce2b2f..364ffb051 100644 --- a/tests/workspace/test_agent_definition_rendering.py +++ b/tests/workspace/test_agent_definition_rendering.py @@ -221,11 +221,10 @@ def test_all_builtin_only_agents_rendered_byte_identical(self, tmp_path: Path) - if not any(tool.startswith("mcp__") for tool in defn.tools): originals[f"{defn.name}.md"] = (agents_dir / f"{defn.name}.md").read_bytes() - assert len(bundled_definitions) == 16, ( - "Adding session-log-reader and retiring pipeline-health-scanner must preserve " - "the bundled-agent count" + assert len(bundled_definitions) == 22, ( + "The bundled catalog includes the six skill-specific child roles" ) - assert len(originals) == 13, f"Expected 13 built-in-only agents, got {len(originals)}" + assert len(originals) == 19, f"Expected 19 built-in-only agents, got {len(originals)}" _render_agent_definitions(agents_dir, MARKETPLACE_PREFIX) diff --git a/tests/workspace/test_corridor_composition.py b/tests/workspace/test_corridor_composition.py index fe44984af..84abcc712 100644 --- a/tests/workspace/test_corridor_composition.py +++ b/tests/workspace/test_corridor_composition.py @@ -38,7 +38,7 @@ def _build_mock_backend(*, terminal: bool = False, session_scoped: bool = False) backend.conventions = BackendConventions() backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics backend.exploration_dispatch_renderer = MagicMock() - backend.setup_session_dir = MagicMock() + backend.setup_session_dir = MagicMock(return_value=None) backend.ensure_pre_launch = MagicMock(return_value=PreLaunchReadiness((), {})) return backend diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index 215a6e1bb..3ea4b60e9 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock @@ -45,6 +46,7 @@ def _make_codex_backend() -> MagicMock: b.capabilities = _CODEX_CAPABILITIES b.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR b.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + b.setup_session_dir.return_value = None b.validate_session_layout.return_value = [] b.adapt_skill_semantics.side_effect = CodexBackend().adapt_skill_semantics b.exploration_dispatch_renderer = CodexBackend().exploration_dispatch_renderer @@ -521,6 +523,148 @@ def test_profile_skills_are_projected_into_session_dir(tmp_path, monkeypatch) -> assert count == 1 +@pytest.mark.parametrize( + ("ambient_state", "helper_available"), + (("absent", False), ("invalid", False), ("valid", True)), +) +def test_manager_filters_child_spawn_skill_by_finalized_ambient_role( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ambient_state: str, + helper_available: bool, +) -> None: + from autoskillit.core import SkillSource + from autoskillit.execution.backends.codex import CodexBackend + from autoskillit.workspace import ( + DefaultSessionSkillManager, + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillInfo, + SkillsDirectoryProvider, + compile_session_skill_catalog, + ) + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + fake_home = tmp_path / "home" + source_home = fake_home / ".codex" + source_home.mkdir(parents=True) + (source_home / "auth.json").write_text("{}\n", encoding="utf-8") + config = 'cli_auth_credentials_store = "keyring"\n' + if ambient_state == "invalid": + source_target = source_home / "agents" / "helper.toml" + source_target.parent.mkdir() + source_target.write_text('name = "helper"\n', encoding="utf-8") + config += ( + "\n[agents.helper]\n" + 'description = "source-relative helper"\n' + 'config_file = "agents/helper.toml"\n' + ) + elif ambient_state == "valid": + valid_target = tmp_path / "ambient" / "helper.toml" + valid_target.parent.mkdir() + valid_target.write_text('name = "helper"\n', encoding="utf-8") + config += ( + f'\n[agents.helper]\ndescription = "absolute helper"\nconfig_file = "{valid_target}"\n' + ) + (source_home / "config.toml").write_text(config, encoding="utf-8") + + project_root = tmp_path / "project" + semantic_path = project_root / "skills" / "helper-skill" / "SKILL.md" + semantic_path.parent.mkdir(parents=True) + semantic_path.write_text( + "---\n" + "name: helper-skill\n" + "description: Delegate to an ambient helper.\n" + "semantic_version: 1\n" + "semantic_requirements:\n" + " logical_roles:\n" + " - name: helper\n" + " purpose: perform delegated work\n" + " child_spawns:\n" + " - role: helper\n" + " count: 1\n" + "---\n" + "Delegate the work.\n", + encoding="utf-8", + ) + helper_skill = _skill_info_from_frontmatter( + "helper-skill", + SkillSource.PROJECT_LOCAL, + semantic_path, + ) + unrelated = SkillInfo( + name="unrelated-skill", + source=SkillSource.PROJECT_LOCAL, + path=project_root / "skills" / "unrelated-skill" / "SKILL.md", + canonical_content=( + "---\n" + "name: unrelated-skill\n" + "description: Supported without child delegation.\n" + "---\n" + "Run directly.\n" + ), + ) + catalog = EffectiveSkillCatalog( + skills=tuple( + SkillCatalogEntry.from_skill_info(skill) for skill in (helper_skill, unrelated) + ), + execution_role=SkillExecutionRole.SESSION, + ) + backend = CodexBackend(source_codex_home=source_home) + provider = SkillsDirectoryProvider() + context = provider.catalog_projection_context( + catalog, + project_root, + backend=backend, + durable_scripts_root=pkg_root(), + ) + manager = DefaultSessionSkillManager( + provider, + ephemeral_root=tmp_path / "ephemeral", + persistent_roots={"codex": tmp_path / "persistent" / "codex-sessions"}, + ) + session_id = f"ambient-{ambient_state}" + expected_names = {"unrelated-skill"} + if helper_available: + expected_names.add("helper-skill") + + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setenv("MCP_CLIENT_BACKEND", "pre-test-backend") + with manager.managed_session( + session_id, + compile_session_skill_catalog(catalog, backend), + context, + ) as managed: + projected_root = Path(managed.skills_dir.path) / "skills" + projected_names = {entry.name for entry in projected_root.iterdir()} + metadata = json.loads( + (Path(managed.skills_dir.path) / "skill-unavailability.json").read_text( + encoding="utf-8" + ) + ) + unavailable = metadata["unavailable"] + + assert projected_names == expected_names + assert set(manager._session_skill_infos[session_id]) == expected_names + assert (managed.generated_home / "skills" / "unrelated-skill").is_symlink() + if helper_available: + assert unavailable == [] + assert (managed.generated_home / "skills" / "helper-skill").is_symlink() + else: + assert unavailable == [ + { + "backend": "codex", + "diagnostic": "native child-spawn targets are unavailable: ['helper']", + "operation": "child_spawn", + "skill": "helper-skill", + } + ] + assert not (managed.generated_home / "skills" / "helper-skill").exists() + + assert session_id not in manager._session_skill_infos + assert not (tmp_path / "persistent" / "codex-sessions" / session_id).exists() + + def test_missing_profile_skills_dir_does_not_raise(tmp_path, monkeypatch) -> None: """Profile projection returns 0 when ~/.codex/skills is absent.""" from autoskillit.execution.backends.codex import CodexBackend diff --git a/tests/workspace/test_session_skills_provider.py b/tests/workspace/test_session_skills_provider.py index fcbe2e571..18f705f5c 100644 --- a/tests/workspace/test_session_skills_provider.py +++ b/tests/workspace/test_session_skills_provider.py @@ -86,6 +86,7 @@ def _codex_backend() -> MagicMock: backend.capabilities = _CODEX_CAPABILITIES backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + backend.setup_session_dir.return_value = None backend.validate_session_layout.return_value = [] backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics return backend diff --git a/tests/workspace/test_session_skills_stale_path.py b/tests/workspace/test_session_skills_stale_path.py index 7d18ea4b4..b0a27b980 100644 --- a/tests/workspace/test_session_skills_stale_path.py +++ b/tests/workspace/test_session_skills_stale_path.py @@ -21,6 +21,7 @@ def _codex_backend() -> MagicMock: backend.capabilities = _CODEX_CAPABILITIES backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + backend.setup_session_dir.return_value = None backend.validate_session_layout.return_value = [] backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics return backend From 864ca4b38de559ca6475d6d288a9605f4eca95af Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 14:10:37 -0700 Subject: [PATCH 02/18] fix: satisfy role-resolution architecture guards --- .../execution/backends/_codex_config.py | 36 ++++++++++++++++- src/autoskillit/execution/backends/codex.py | 39 ++----------------- .../test_skill_semantic_trace_conformance.py | 1 - tests/infra/test_plugin_source_ratchets.py | 18 +++++++++ 4 files changed, 56 insertions(+), 38 deletions(-) diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 66f48e4fc..3636341d6 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -4,6 +4,7 @@ import hashlib import json +import tomllib from collections.abc import Mapping from pathlib import Path from types import MappingProxyType @@ -35,6 +36,12 @@ CODEX_MCP_STARTUP_TIMEOUT_SEC: float = 30.0 +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "worker"}) +_CODEX_RESERVED_ONLY_AGENT_NAMES = frozenset({"review", "reviewer"}) +_CODEX_AGENT_NAME_COLLISIONS = ( + CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | _CODEX_RESERVED_ONLY_AGENT_NAMES +) + # The configured history-retention requirement is derived from the largest # measured recipe exemption plus explicit serialized-response headroom. _MAX_RESPONSE_BACKSTOP_EXEMPTION_BYTES: int = max( @@ -501,8 +508,6 @@ def _serialize_toml(data: dict[str, Any]) -> str: def _read_codex_config(path: Path) -> ReadResult: - import tomllib - try: raw_bytes = path.read_bytes() except FileNotFoundError: @@ -515,6 +520,33 @@ def _read_codex_config(path: Path) -> ReadResult: return ReadResult.ok(data) +def effective_codex_agent_names(session_dir: Path) -> frozenset[str]: + """Return roles backed by readable TOML in the finalized generated home.""" + config = tomllib.loads((session_dir / "config.toml").read_text(encoding="utf-8")) + configured_agents = config.get("agents", {}) + if not isinstance(configured_agents, dict): + raise ValueError("Codex config agents table must be a mapping") + + effective = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) + for name, registration in configured_agents.items(): + if not name.strip() or not isinstance(registration, dict): + continue + config_file = registration.get("config_file") + if not isinstance(config_file, str) or not config_file.strip(): + continue + target = Path(config_file) + if not target.is_absolute(): + target = session_dir / target + try: + if not target.is_file(): + continue + tomllib.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + continue + effective.add(name) + return frozenset(effective) + + def _write_codex_config(path: Path, data: dict[str, Any], *, source: ReadResult) -> None: if source.is_corrupt: raise ValueError( diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 5dbce7941..3ec1c78a5 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -123,8 +123,10 @@ _validated_explorer_binding_envs, ) from autoskillit.execution.backends._codex_config import ( + _CODEX_AGENT_NAME_COLLISIONS, CODEX_RECIPE_DELIVERY_BUDGET, _format_toml_value, + effective_codex_agent_names, ensure_codex_mcp_registered, ) from autoskillit.execution.backends._codex_execution_identity import ( @@ -879,10 +881,6 @@ def _canonical_codex_model_effort( ) -CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "worker"}) -_CODEX_RESERVED_ONLY_AGENT_NAMES = frozenset({"review", "reviewer"}) - - def _preflight_agent_projection( session_dir: Path, definitions: tuple[AgentDef, ...], @@ -894,9 +892,7 @@ def _preflight_agent_projection( duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: raise ValueError(f"duplicate Codex agent definitions: {duplicates}") - built_in_collisions = sorted( - set(names) & (CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | _CODEX_RESERVED_ONLY_AGENT_NAMES) - ) + built_in_collisions = sorted(set(names) & _CODEX_AGENT_NAME_COLLISIONS) if built_in_collisions: raise ValueError(f"Codex built-in agent name collision: {built_in_collisions}") config_path = session_dir / "config.toml" @@ -1078,33 +1074,6 @@ def _register_agent_tomls( return len(registrations) // 4 -def _effective_agent_names(session_dir: Path) -> frozenset[str]: - """Return roles backed by readable TOML in the finalized generated home.""" - config = tomllib.loads((session_dir / "config.toml").read_text(encoding="utf-8")) - configured_agents = config.get("agents", {}) - if not isinstance(configured_agents, dict): - raise ValueError("Codex config agents table must be a mapping") - - effective = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) - for name, registration in configured_agents.items(): - if not name.strip() or not isinstance(registration, dict): - continue - config_file = registration.get("config_file") - if not isinstance(config_file, str) or not config_file.strip(): - continue - target = Path(config_file) - if not target.is_absolute(): - target = session_dir / target - try: - if not target.is_file(): - continue - tomllib.loads(target.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): - continue - effective.add(name) - return frozenset(effective) - - def _validate_existing_explorer_role_toml( toml_path: Path, definition: AgentDef, @@ -2308,7 +2277,7 @@ def setup_session_dir( session_dir, source_codex_home=codex_home_source, ) - return _effective_agent_names(session_dir) + return effective_codex_agent_names(session_dir) def refresh_explorer_binding_env( self, diff --git a/tests/execution/backends/test_skill_semantic_trace_conformance.py b/tests/execution/backends/test_skill_semantic_trace_conformance.py index eb3b4deef..8fd13c1ad 100644 --- a/tests/execution/backends/test_skill_semantic_trace_conformance.py +++ b/tests/execution/backends/test_skill_semantic_trace_conformance.py @@ -441,7 +441,6 @@ def test_compose_pr_real_codex_trace_spawns_then_joins_registered_roles() -> Non "agent_type": synthesizer, "fork_turns": "none", "model": model, - "reasoning_effort": effort, "task_name": "synthesizer", }, ), diff --git a/tests/infra/test_plugin_source_ratchets.py b/tests/infra/test_plugin_source_ratchets.py index 355a3941e..4db52320c 100644 --- a/tests/infra/test_plugin_source_ratchets.py +++ b/tests/infra/test_plugin_source_ratchets.py @@ -156,6 +156,24 @@ "Generated session homes are ephemeral lease-owned artifacts, and cleanup " "refuses symlinks before recursively removing the exact requested home.", ), + ( + "workspace/session_skills.py", + "_remove_generated_home_skill_entry", + "path.unlink", + ): ( + 1, + "Reachability filtering removes only the exact generated-home profile-skill " + "symlink selected by the finalized catalog; the source profile is untouched.", + ), + ( + "workspace/session_skills.py", + "_remove_generated_home_skill_entry", + "shutil.rmtree", + ): ( + 1, + "Reachability filtering removes only the exact generated-home profile-skill " + "directory selected by the finalized catalog; the source profile is untouched.", + ), ("workspace/session_skills.py", "resolve_ephemeral_root", "probe.unlink"): ( 1, "The writable-root probe removes only the sentinel file it created in the " From 6b044f24bdce8c6e69e23bfeb47d4339b2b40271 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 14:22:16 -0700 Subject: [PATCH 03/18] fix: preserve Codex role authority imports --- src/autoskillit/execution/backends/codex.py | 13 +++++++------ tests/cli/test_cook_interactive.py | 5 +++-- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 3ec1c78a5..315976a40 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -90,6 +90,7 @@ get_logger, load_bundled_agent_definitions, ) +from autoskillit.execution.backends import _codex_config as _codex_cfg from autoskillit.execution.backends._backend_cmd_builder_base import ( SHARED_BASELINE_ENV, BackendCmdBuilderBase, @@ -126,7 +127,6 @@ _CODEX_AGENT_NAME_COLLISIONS, CODEX_RECIPE_DELIVERY_BUDGET, _format_toml_value, - effective_codex_agent_names, ensure_codex_mcp_registered, ) from autoskillit.execution.backends._codex_execution_identity import ( @@ -875,10 +875,11 @@ def _canonical_codex_model_effort( """Translate the one canonical semantic policy used by agents and call sites.""" if model_class is None: return "", reasoning_effort - return ( - CODEX_MODEL_ALIASES[model_class], - reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class), - ) + model = CODEX_MODEL_ALIASES[model_class] + return model, reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class) + + +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = _codex_cfg.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES def _preflight_agent_projection( @@ -2277,7 +2278,7 @@ def setup_session_dir( session_dir, source_codex_home=codex_home_source, ) - return effective_codex_agent_names(session_dir) + return _codex_cfg.effective_codex_agent_names(session_dir) def refresh_explorer_binding_env( self, diff --git a/tests/cli/test_cook_interactive.py b/tests/cli/test_cook_interactive.py index 819240a81..fc3b0e027 100644 --- a/tests/cli/test_cook_interactive.py +++ b/tests/cli/test_cook_interactive.py @@ -315,7 +315,8 @@ def test_codex_cook_projects_only_spawnable_compose_pr_roles( tmp_path: Path, ) -> None: from autoskillit.core import SkillExecutionRole - from autoskillit.execution.backends.codex import CodexBackend, _effective_agent_names + from autoskillit.execution.backends._codex_config import effective_codex_agent_names + from autoskillit.execution.backends.codex import CodexBackend from autoskillit.workspace import DefaultSkillResolver project_root = tmp_path / "project" @@ -354,7 +355,7 @@ def cook_session_context(_self, **_kwargs: object): def run_attempt(spec: CmdSpec, **kwargs: object) -> object: generated_home = Path(spec.env["CODEX_HOME"]) compose_projection = generated_home / "add-dir" / "skills" / "compose-pr" / "SKILL.md" - role_names = _effective_agent_names(generated_home) + role_names = effective_codex_agent_names(generated_home) captured["compose_projected"] = compose_projection.is_file() captured["mapped_targets"] = mapped_targets captured["role_names"] = role_names From 07a8e63c4964becbcd38d28b9cb119a302430d97 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 15:07:09 -0700 Subject: [PATCH 04/18] fix: align Codex spawnable role authority type --- src/autoskillit/execution/backends/_codex_config.py | 4 ++-- tests/contracts/test_skill_semantic_authenticity.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 3636341d6..00680d317 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -36,10 +36,10 @@ CODEX_MCP_STARTUP_TIMEOUT_SEC: float = 30.0 -CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "worker"}) +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = ("default", "explorer", "worker") _CODEX_RESERVED_ONLY_AGENT_NAMES = frozenset({"review", "reviewer"}) _CODEX_AGENT_NAME_COLLISIONS = ( - CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | _CODEX_RESERVED_ONLY_AGENT_NAMES + frozenset(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | _CODEX_RESERVED_ONLY_AGENT_NAMES ) # The configured history-retention requirement is derived from the largest diff --git a/tests/contracts/test_skill_semantic_authenticity.py b/tests/contracts/test_skill_semantic_authenticity.py index e688a62d3..ea79d45bb 100644 --- a/tests/contracts/test_skill_semantic_authenticity.py +++ b/tests/contracts/test_skill_semantic_authenticity.py @@ -132,7 +132,7 @@ def test_every_bundled_codex_child_spawn_targets_a_registered_role() -> None: ) from autoskillit.workspace import DefaultSkillResolver - allowed = CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES | { + allowed = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | { definition.name for definition in load_bundled_agent_definitions() } plans = tuple( From f26e5a842c5229caa2332d42f175a21a7e5d5df4 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 17:16:33 -0700 Subject: [PATCH 05/18] fix(review): log unreadable Codex agent configs --- .../execution/backends/_codex_config.py | 8 ++++- .../execution/backends/test_codex_backend.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 00680d317..aa0daa7dd 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -541,7 +541,13 @@ def effective_codex_agent_names(session_dir: Path) -> frozenset[str]: if not target.is_file(): continue tomllib.loads(target.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError): + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + logger.warning( + "codex_agent_config_unreadable", + agent_name=name, + path=str(target), + error_type=type(exc).__name__, + ) continue effective.add(name) return frozenset(effective) diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index a06648cbf..6dd5c6bbb 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -2663,6 +2663,35 @@ def test_setup_returns_only_finalized_spawnable_agent_names( {"valid-ambient", "source-relative"} | eligible_bundled ) + def test_setup_warns_when_ambient_agent_config_is_unreadable(self) -> None: + invalid_target = self.codex_home / "invalid-ambient.toml" + invalid_target.write_text("[", encoding="utf-8") + (self.codex_home / "config.toml").write_text( + self._CANONICAL_AUTOSKILLIT_MCP_CONFIG + + '\n[agents."invalid-ambient"]\n' + + 'description = "invalid ambient role"\n' + + f'config_file = "{invalid_target}"\n', + encoding="utf-8", + ) + (self.codex_home / "auth.json").write_text("{}", encoding="utf-8") + backend = CodexBackend() + + assert backend.ensure_pre_launch(session_dir=self.session_dir) == [] + with structlog.testing.capture_logs() as cap_logs: + role_names = backend.setup_session_dir(self.session_dir) + + assert "invalid-ambient" not in role_names + warning = next( + entry for entry in cap_logs if entry.get("event") == "codex_agent_config_unreadable" + ) + assert warning == { + "agent_name": "invalid-ambient", + "path": str(invalid_target), + "error_type": "TOMLDecodeError", + "event": "codex_agent_config_unreadable", + "log_level": "warning", + } + @pytest.mark.parametrize( "role", ( From 81dea387fbb7a3140bfe6ea193b6fbb64a5f5b50 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 17:24:17 -0700 Subject: [PATCH 06/18] test(review): assert Codex config logger identity --- tests/execution/backends/test_codex_backend.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 6dd5c6bbb..70f3164ec 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -2689,6 +2689,7 @@ def test_setup_warns_when_ambient_agent_config_is_unreadable(self) -> None: "path": str(invalid_target), "error_type": "TOMLDecodeError", "event": "codex_agent_config_unreadable", + "logger": "autoskillit.execution.backends._codex_config", "log_level": "warning", } From d8b34a32aa31eb834e8b3a06410d8eb5bbbffb19 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:37:14 -0700 Subject: [PATCH 07/18] fix(review): diagnose ignored Codex agent registrations --- .../execution/backends/_codex_config.py | 16 +++++++++ .../execution/backends/test_codex_backend.py | 33 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index aa0daa7dd..e1b6f7f0e 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -530,15 +530,31 @@ def effective_codex_agent_names(session_dir: Path) -> frozenset[str]: effective = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) for name, registration in configured_agents.items(): if not name.strip() or not isinstance(registration, dict): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + reason="invalid_registration", + ) continue config_file = registration.get("config_file") if not isinstance(config_file, str) or not config_file.strip(): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + reason="invalid_config_file", + ) continue target = Path(config_file) if not target.is_absolute(): target = session_dir / target try: if not target.is_file(): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + path=str(target), + reason="missing_config_file", + ) continue tomllib.loads(target.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 70f3164ec..9c2ed0836 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -44,6 +44,7 @@ load_agent_definitions, pkg_root, ) +from autoskillit.execution.backends._codex_config import effective_codex_agent_names from autoskillit.execution.backends.codex import ( CODEX_ENV_PREFIX_DENYLIST, CodexBackend, @@ -2693,6 +2694,38 @@ def test_setup_warns_when_ambient_agent_config_is_unreadable(self) -> None: "log_level": "warning", } + def test_effective_agent_names_warns_for_ignored_registrations(self) -> None: + missing_target = self.session_dir / "agents" / "missing.toml" + (self.session_dir / "config.toml").write_text( + "[agents]\n" + 'invalid = "not a table"\n' + "[agents.missing_config]\n" + 'description = "missing config file"\n' + "[agents.missing_target]\n" + 'config_file = "agents/missing.toml"\n', + encoding="utf-8", + ) + + with structlog.testing.capture_logs() as cap_logs: + role_names = effective_codex_agent_names(self.session_dir) + + assert "invalid" not in role_names + assert "missing_config" not in role_names + assert "missing_target" not in role_names + assert [ + {key: entry[key] for key in ("agent_name", "path", "reason") if key in entry} + for entry in cap_logs + if entry.get("event") == "codex_agent_registration_ignored" + ] == [ + {"agent_name": "invalid", "reason": "invalid_registration"}, + {"agent_name": "missing_config", "reason": "invalid_config_file"}, + { + "agent_name": "missing_target", + "path": str(missing_target), + "reason": "missing_config_file", + }, + ] + @pytest.mark.parametrize( "role", ( From 2c4de878a98f390147b31ebb5391710623c12093 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:37:38 -0700 Subject: [PATCH 08/18] fix(review): constrain generated skill removal to exact children --- src/autoskillit/workspace/session_skills.py | 11 +++++++++-- tests/workspace/test_session_skills_codex.py | 12 ++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/workspace/session_skills.py b/src/autoskillit/workspace/session_skills.py index fa41965f2..77969a937 100644 --- a/src/autoskillit/workspace/session_skills.py +++ b/src/autoskillit/workspace/session_skills.py @@ -40,6 +40,7 @@ SkillSource, SkillSourceRef, ValidatedAddDir, + destination_location, get_logger, pkg_root, validate_skill_capability_roles, @@ -109,8 +110,14 @@ def _remove_and_verify(path: Path) -> bool: return True -def _remove_generated_home_skill_entry(path: Path) -> None: +def _remove_generated_home_skill_entry(discovery_root: Path, skill: str) -> None: """Remove one exact generated-home discovery entry without following it.""" + root_location = destination_location(discovery_root) + path = destination_location(discovery_root / skill) + if path.parent != root_location or path.name != skill: + raise SkillContractError( + f"generated-home skill removal requires one exact child entry: {skill!r}" + ) if not os.path.lexists(path): return if path.is_symlink(): @@ -1033,7 +1040,7 @@ def _materialize_session( records = tuple(effective_catalog.skills) discovery_root = generated_home / skills_subdir for unavailable in reachability_compilation.unavailable: - _remove_generated_home_skill_entry(discovery_root / unavailable.skill) + _remove_generated_home_skill_entry(discovery_root, unavailable.skill) write_skill_unavailability_metadata( add_dir, diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index 3ea4b60e9..1dabfa546 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -53,6 +53,18 @@ def _make_codex_backend() -> MagicMock: return b +def test_generated_home_skill_removal_rejects_non_child_path(tmp_path: Path) -> None: + discovery_root = tmp_path / "skills" + discovery_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + + with pytest.raises(SkillContractError, match="one exact child entry"): + session_skills._remove_generated_home_skill_entry(discovery_root, "../outside") + + assert outside.is_dir() + + def _catalog_context( manager, *, From f67e3bbb05a39a859cd8b3313b5cec06be209bef Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:37:54 -0700 Subject: [PATCH 09/18] fix(review): remove unrelated model effort rewrite --- src/autoskillit/execution/backends/codex.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 315976a40..85a39d847 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -875,8 +875,10 @@ def _canonical_codex_model_effort( """Translate the one canonical semantic policy used by agents and call sites.""" if model_class is None: return "", reasoning_effort - model = CODEX_MODEL_ALIASES[model_class] - return model, reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class) + return ( + CODEX_MODEL_ALIASES[model_class], + reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class), + ) CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = _codex_cfg.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES From 34935bce8c05c178ec564b6d1ce10dcd9860b896 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:38:10 -0700 Subject: [PATCH 10/18] fix(review): enforce compilation contract before recompiling --- src/autoskillit/workspace/session_skills.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/autoskillit/workspace/session_skills.py b/src/autoskillit/workspace/session_skills.py index 77969a937..439a91c24 100644 --- a/src/autoskillit/workspace/session_skills.py +++ b/src/autoskillit/workspace/session_skills.py @@ -1014,17 +1014,17 @@ def _materialize_session( if finalized_native_roles is not None and effective_catalog is not None: assert backend is not None - reachability_compilation = compile_session_skill_catalog( - effective_catalog, - backend, - finalized_native_roles=finalized_native_roles, - ) if compilation is not None and not isinstance( compilation, CompiledSessionSkillCatalog ): raise SkillContractError( "finalized native-role admission requires a concrete session compilation" ) + reachability_compilation = compile_session_skill_catalog( + effective_catalog, + backend, + finalized_native_roles=finalized_native_roles, + ) prior_unavailable = compilation.unavailable if compilation is not None else () compilation = CompiledSessionSkillCatalog( backend=backend.name, From edfe4a98ec7da62d9db4d59d95a27b5633549040 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:48:01 -0700 Subject: [PATCH 11/18] fix(review): preserve backend module line-limit contract --- src/autoskillit/execution/backends/codex.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 85a39d847..315976a40 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -875,10 +875,8 @@ def _canonical_codex_model_effort( """Translate the one canonical semantic policy used by agents and call sites.""" if model_class is None: return "", reasoning_effort - return ( - CODEX_MODEL_ALIASES[model_class], - reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class), - ) + model = CODEX_MODEL_ALIASES[model_class] + return model, reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class) CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = _codex_cfg.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES From 6d1de0ffbd6ccaf9bb9bad15853a2a77b0bbd1ad Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:48:07 -0700 Subject: [PATCH 12/18] test: assert Codex prelaunch readiness contract --- tests/execution/backends/test_codex_backend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 9c2ed0836..a04b96b05 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -2648,7 +2648,7 @@ def test_setup_returns_only_finalized_spawnable_agent_names( monkeypatch.setenv(MCP_CLIENT_BACKEND_ENV_VAR, "pre-test-backend") backend = CodexBackend() - assert backend.ensure_pre_launch(session_dir=self.session_dir) == [] + assert not backend.ensure_pre_launch(session_dir=self.session_dir).errors role_names = backend.setup_session_dir(self.session_dir) eligible_bundled = { @@ -2677,7 +2677,7 @@ def test_setup_warns_when_ambient_agent_config_is_unreadable(self) -> None: (self.codex_home / "auth.json").write_text("{}", encoding="utf-8") backend = CodexBackend() - assert backend.ensure_pre_launch(session_dir=self.session_dir) == [] + assert not backend.ensure_pre_launch(session_dir=self.session_dir).errors with structlog.testing.capture_logs() as cap_logs: role_names = backend.setup_session_dir(self.session_dir) From a24833d5efe7f7625cefceba8e6ce969d398b4ce Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 18:56:34 -0700 Subject: [PATCH 13/18] chore: keep Codex backend within line budget --- src/autoskillit/execution/backends/codex.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 315976a40..41eefc50c 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -872,7 +872,6 @@ def _canonical_codex_model_effort( model_class: str | None, reasoning_effort: str | None = None, ) -> tuple[str, str | None]: - """Translate the one canonical semantic policy used by agents and call sites.""" if model_class is None: return "", reasoning_effort model = CODEX_MODEL_ALIASES[model_class] From 01e3114e9d179e771f627b9f2a81c619233eafeb Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 19:05:22 -0700 Subject: [PATCH 14/18] fix(review): enforce child agent model and turn policies --- .../agents/friction-batch-scanner.md | 2 +- .../agents/friction-category-analyzer.md | 2 +- src/autoskillit/agents/pr-source-reader.md | 2 +- .../agents/research-source-reader.md | 2 +- tests/core/test_agent_definition.py | 24 ++++++++++++------- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md index 3c582cb7b..a82c86a45 100644 --- a/src/autoskillit/agents/friction-batch-scanner.md +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -3,7 +3,7 @@ name: friction-batch-scanner description: "Use when one parent-assigned log batch must be scanned for friction evidence." tools: [Read, Grep] model: haiku -maxTurns: 30 +maxTurns: 80 --- # Friction batch scanner diff --git a/src/autoskillit/agents/friction-category-analyzer.md b/src/autoskillit/agents/friction-category-analyzer.md index 6f20551b4..8506072ac 100644 --- a/src/autoskillit/agents/friction-category-analyzer.md +++ b/src/autoskillit/agents/friction-category-analyzer.md @@ -3,7 +3,7 @@ name: friction-category-analyzer description: "Use when supplied indicators need validation for one parent-assigned friction category." tools: [Read, Grep] model: sonnet -maxTurns: 30 +maxTurns: 80 --- # Friction category analyzer diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md index be51a364d..775ec2127 100644 --- a/src/autoskillit/agents/pr-source-reader.md +++ b/src/autoskillit/agents/pr-source-reader.md @@ -2,7 +2,7 @@ name: pr-source-reader description: "Use when one parent-specified PR source artifact must yield bounded evidence." tools: [Read] -model: haiku +model: sonnet maxTurns: 20 --- diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md index c7a475f69..059867142 100644 --- a/src/autoskillit/agents/research-source-reader.md +++ b/src/autoskillit/agents/research-source-reader.md @@ -2,7 +2,7 @@ name: research-source-reader description: "Use when one parent-specified research artifact must yield bounded evidence." tools: [Read] -model: haiku +model: sonnet maxTurns: 20 --- diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index d126b3ed5..a1428e7ca 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -29,13 +29,13 @@ _EXPLORATION_BROKER_TOOLS = frozenset(f"{DIRECT_PREFIX}{tool}" for tool in EXPLORATION_TOOLS) -_SKILL_CHILD_ROLE_TOOLS = { - "pr-source-reader": ("Read",), - "pr-synthesizer": ("Read",), - "research-source-reader": ("Read",), - "research-synthesizer": ("Read",), - "friction-batch-scanner": ("Read", "Grep"), - "friction-category-analyzer": ("Read", "Grep"), +_SKILL_CHILD_ROLE_EXPECTATIONS = { + "pr-source-reader": (("Read",), "sonnet", 20), + "pr-synthesizer": (("Read",), "sonnet", 20), + "research-source-reader": (("Read",), "sonnet", 20), + "research-synthesizer": (("Read",), "sonnet", 20), + "friction-batch-scanner": (("Read", "Grep"), "haiku", 80), + "friction-category-analyzer": (("Read", "Grep"), "sonnet", 80), } @@ -120,10 +120,16 @@ def test_skill_child_roles_have_bounded_tools_and_usage_descriptions() -> None: definitions = {definition.name: definition for definition in load_bundled_agent_definitions()} assert len(definitions) == 22 - assert _SKILL_CHILD_ROLE_TOOLS.keys() <= definitions.keys() - for name, expected_tools in _SKILL_CHILD_ROLE_TOOLS.items(): + assert _SKILL_CHILD_ROLE_EXPECTATIONS.keys() <= definitions.keys() + for name, ( + expected_tools, + expected_model, + expected_max_turns, + ) in _SKILL_CHILD_ROLE_EXPECTATIONS.items(): definition = definitions[name] assert definition.tools == expected_tools + assert definition.model == expected_model + assert definition.max_turns == expected_max_turns assert definition.description.startswith("Use when ") assert definition.description not in definition.body assert not ({"Bash", "Write", "Edit"} & set(definition.tools)) From c79abc25e4203d029ff1640c61ed9bdc50320c3c Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 19:06:08 -0700 Subject: [PATCH 15/18] docs: explain Haiku scanner swarm policy --- src/autoskillit/agents/friction-batch-scanner.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md index a82c86a45..1ed82b3c3 100644 --- a/src/autoskillit/agents/friction-batch-scanner.md +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -8,6 +8,7 @@ maxTurns: 80 # Friction batch scanner +This role is the homogeneous worker in a high-fan-out log-batch scanning swarm. Scan only the log files assigned by the parent, using the supplied signal patterns. Search before reading bounded context around each hit. Confirm the event from that context and do not read whole logs, inspect other files, diagnose root causes, From aa06e4ac05aa9512f2562932408b9194cdb2da91 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 19:34:25 -0700 Subject: [PATCH 16/18] fix(review): define native Codex child role policies --- .../agents/friction-batch-scanner.md | 4 +++ .../agents/friction-category-analyzer.md | 4 +++ src/autoskillit/agents/pr-source-reader.md | 4 +++ src/autoskillit/agents/pr-synthesizer.md | 4 +++ .../agents/research-source-reader.md | 4 +++ .../agents/research-synthesizer.md | 4 +++ src/autoskillit/core/types/_type_backend.py | 6 ++-- tests/core/test_agent_definition.py | 29 +++++++++++++++---- tests/execution/test_model_alias_registry.py | 1 + 9 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md index 1ed82b3c3..58ea81eb5 100644 --- a/src/autoskillit/agents/friction-batch-scanner.md +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -4,6 +4,10 @@ description: "Use when one parent-assigned log batch must be scanned for frictio tools: [Read, Grep] model: haiku maxTurns: 80 +codex: + model: gpt-5.6-luna + reasoning_effort: medium + sandbox_mode: read-only --- # Friction batch scanner diff --git a/src/autoskillit/agents/friction-category-analyzer.md b/src/autoskillit/agents/friction-category-analyzer.md index 8506072ac..0d81a7746 100644 --- a/src/autoskillit/agents/friction-category-analyzer.md +++ b/src/autoskillit/agents/friction-category-analyzer.md @@ -4,6 +4,10 @@ description: "Use when supplied indicators need validation for one parent-assign tools: [Read, Grep] model: sonnet maxTurns: 80 +codex: + model: gpt-5.6-terra + reasoning_effort: xhigh + sandbox_mode: read-only --- # Friction category analyzer diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md index 775ec2127..eeb0cc552 100644 --- a/src/autoskillit/agents/pr-source-reader.md +++ b/src/autoskillit/agents/pr-source-reader.md @@ -4,6 +4,10 @@ description: "Use when one parent-specified PR source artifact must yield bounde tools: [Read] model: sonnet maxTurns: 20 +codex: + model: gpt-5.6-luna + reasoning_effort: xhigh + sandbox_mode: read-only --- # PR source reader diff --git a/src/autoskillit/agents/pr-synthesizer.md b/src/autoskillit/agents/pr-synthesizer.md index 0f580d182..c7cf2b0a7 100644 --- a/src/autoskillit/agents/pr-synthesizer.md +++ b/src/autoskillit/agents/pr-synthesizer.md @@ -4,6 +4,10 @@ description: "Use when collected PR source evidence needs an overall pull reques tools: [Read] model: sonnet maxTurns: 20 +codex: + model: gpt-5.6-terra + reasoning_effort: high + sandbox_mode: read-only --- # PR synthesizer diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md index 059867142..f56879f21 100644 --- a/src/autoskillit/agents/research-source-reader.md +++ b/src/autoskillit/agents/research-source-reader.md @@ -4,6 +4,10 @@ description: "Use when one parent-specified research artifact must yield bounded tools: [Read] model: sonnet maxTurns: 20 +codex: + model: gpt-5.6-luna + reasoning_effort: xhigh + sandbox_mode: read-only --- # Research source reader diff --git a/src/autoskillit/agents/research-synthesizer.md b/src/autoskillit/agents/research-synthesizer.md index 8155fdb40..56010286e 100644 --- a/src/autoskillit/agents/research-synthesizer.md +++ b/src/autoskillit/agents/research-synthesizer.md @@ -4,6 +4,10 @@ description: "Use when collected research evidence needs a direction and experim tools: [Read] model: sonnet maxTurns: 20 +codex: + model: gpt-5.6-terra + reasoning_effort: xhigh + sandbox_mode: read-only --- # Research synthesizer diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index fbd23d63a..9d948cd63 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -264,9 +264,11 @@ class BackendCapabilities: } ) -CODEX_MODEL_ALIASES_LAST_VERIFIED: str = "2026-08-10" +CODEX_MODEL_ALIASES_LAST_VERIFIED: str = "2026-08-13" -CODEX_VALID_MODEL_IDS: frozenset[str] = frozenset({"gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol"}) +CODEX_VALID_MODEL_IDS: frozenset[str] = frozenset( + {"gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"} +) CODEX_VALID_REASONING_EFFORTS: frozenset[str] = frozenset( {"low", "medium", "high", "xhigh", "max", "ultra"} ) diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index a1428e7ca..dcf8ad5b8 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -30,12 +30,24 @@ _EXPLORATION_BROKER_TOOLS = frozenset(f"{DIRECT_PREFIX}{tool}" for tool in EXPLORATION_TOOLS) _SKILL_CHILD_ROLE_EXPECTATIONS = { - "pr-source-reader": (("Read",), "sonnet", 20), - "pr-synthesizer": (("Read",), "sonnet", 20), - "research-source-reader": (("Read",), "sonnet", 20), - "research-synthesizer": (("Read",), "sonnet", 20), - "friction-batch-scanner": (("Read", "Grep"), "haiku", 80), - "friction-category-analyzer": (("Read", "Grep"), "sonnet", 80), + "pr-source-reader": (("Read",), "sonnet", 20, "gpt-5.6-luna", "xhigh"), + "pr-synthesizer": (("Read",), "sonnet", 20, "gpt-5.6-terra", "high"), + "research-source-reader": (("Read",), "sonnet", 20, "gpt-5.6-luna", "xhigh"), + "research-synthesizer": (("Read",), "sonnet", 20, "gpt-5.6-terra", "xhigh"), + "friction-batch-scanner": ( + ("Read", "Grep"), + "haiku", + 80, + "gpt-5.6-luna", + "medium", + ), + "friction-category-analyzer": ( + ("Read", "Grep"), + "sonnet", + 80, + "gpt-5.6-terra", + "xhigh", + ), } @@ -125,11 +137,16 @@ def test_skill_child_roles_have_bounded_tools_and_usage_descriptions() -> None: expected_tools, expected_model, expected_max_turns, + expected_codex_model, + expected_reasoning_effort, ) in _SKILL_CHILD_ROLE_EXPECTATIONS.items(): definition = definitions[name] assert definition.tools == expected_tools assert definition.model == expected_model assert definition.max_turns == expected_max_turns + assert definition.codex.model == expected_codex_model + assert definition.codex.reasoning_effort == expected_reasoning_effort + assert definition.codex.sandbox_mode == "read-only" assert definition.description.startswith("Use when ") assert definition.description not in definition.body assert not ({"Bash", "Write", "Edit"} & set(definition.tools)) diff --git a/tests/execution/test_model_alias_registry.py b/tests/execution/test_model_alias_registry.py index 7a8270d48..4e57e2a1f 100644 --- a/tests/execution/test_model_alias_registry.py +++ b/tests/execution/test_model_alias_registry.py @@ -51,6 +51,7 @@ def test_codex_native_model_allowlist_preserves_compatibility() -> None: assert is_valid_codex_model_id("gpt-5.6-sol") assert is_valid_codex_model_id("gpt-5.6-luna") + assert is_valid_codex_model_id("gpt-5.6-terra") assert is_valid_codex_model_id("gpt-5.5") assert not is_valid_codex_model_id("gpt-5.4") assert not is_valid_codex_model_id("gpt-5.4-mini") From ee17a4dd5bb7bfd8ffa5bf820446a147ba774486 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 19:58:42 -0700 Subject: [PATCH 17/18] fix(review): remove twenty-turn agent caps --- .../agents/audit-impl-deviation-evaluator.md | 2 +- src/autoskillit/agents/pr-source-reader.md | 2 +- src/autoskillit/agents/pr-synthesizer.md | 2 +- src/autoskillit/agents/repository-impact-profiler.md | 2 +- src/autoskillit/agents/research-source-reader.md | 2 +- src/autoskillit/agents/research-synthesizer.md | 2 +- src/autoskillit/agents/semantic-code-navigator.md | 2 +- tests/core/test_agent_definition.py | 12 ++++++++---- 8 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/autoskillit/agents/audit-impl-deviation-evaluator.md b/src/autoskillit/agents/audit-impl-deviation-evaluator.md index 93299d68d..d351417d9 100644 --- a/src/autoskillit/agents/audit-impl-deviation-evaluator.md +++ b/src/autoskillit/agents/audit-impl-deviation-evaluator.md @@ -3,7 +3,7 @@ name: audit-impl-deviation-evaluator description: "Evaluates a single deviation justification against audit findings. Checks honesty, intent preservation, and evidence quality — returns ACCEPT, ACCEPT_WITH_NOTE, or REJECT." tools: [Bash] model: sonnet -maxTurns: 20 +maxTurns: 80 --- # audit-impl-deviation-evaluator diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md index eeb0cc552..899a9637e 100644 --- a/src/autoskillit/agents/pr-source-reader.md +++ b/src/autoskillit/agents/pr-source-reader.md @@ -3,7 +3,7 @@ name: pr-source-reader description: "Use when one parent-specified PR source artifact must yield bounded evidence." tools: [Read] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: xhigh diff --git a/src/autoskillit/agents/pr-synthesizer.md b/src/autoskillit/agents/pr-synthesizer.md index c7cf2b0a7..ab5fffb7e 100644 --- a/src/autoskillit/agents/pr-synthesizer.md +++ b/src/autoskillit/agents/pr-synthesizer.md @@ -3,7 +3,7 @@ name: pr-synthesizer description: "Use when collected PR source evidence needs an overall pull request summary." tools: [Read] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-terra reasoning_effort: high diff --git a/src/autoskillit/agents/repository-impact-profiler.md b/src/autoskillit/agents/repository-impact-profiler.md index e197ecfc4..69d216535 100644 --- a/src/autoskillit/agents/repository-impact-profiler.md +++ b/src/autoskillit/agents/repository-impact-profiler.md @@ -3,7 +3,7 @@ name: repository-impact-profiler description: "Terminal read-only specialist for repository impact and consumer-surface profiling." tools: [mcp__autoskillit__submit_exploration_query, mcp__autoskillit__get_exploration_page, mcp__autoskillit__resume_exploration_context] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: max diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md index f56879f21..a53aea56b 100644 --- a/src/autoskillit/agents/research-source-reader.md +++ b/src/autoskillit/agents/research-source-reader.md @@ -3,7 +3,7 @@ name: research-source-reader description: "Use when one parent-specified research artifact must yield bounded evidence." tools: [Read] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: xhigh diff --git a/src/autoskillit/agents/research-synthesizer.md b/src/autoskillit/agents/research-synthesizer.md index 56010286e..e6ea9e6f3 100644 --- a/src/autoskillit/agents/research-synthesizer.md +++ b/src/autoskillit/agents/research-synthesizer.md @@ -3,7 +3,7 @@ name: research-synthesizer description: "Use when collected research evidence needs a direction and experiment-lens recommendation." tools: [Read] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-terra reasoning_effort: xhigh diff --git a/src/autoskillit/agents/semantic-code-navigator.md b/src/autoskillit/agents/semantic-code-navigator.md index dfb9e8542..76fe0483f 100644 --- a/src/autoskillit/agents/semantic-code-navigator.md +++ b/src/autoskillit/agents/semantic-code-navigator.md @@ -3,7 +3,7 @@ name: semantic-code-navigator description: "Terminal read-only specialist for structural and semantic repository navigation." tools: [mcp__autoskillit__submit_exploration_query, mcp__autoskillit__get_exploration_page, mcp__autoskillit__resume_exploration_context] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: max diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index dcf8ad5b8..c082137a2 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -30,10 +30,10 @@ _EXPLORATION_BROKER_TOOLS = frozenset(f"{DIRECT_PREFIX}{tool}" for tool in EXPLORATION_TOOLS) _SKILL_CHILD_ROLE_EXPECTATIONS = { - "pr-source-reader": (("Read",), "sonnet", 20, "gpt-5.6-luna", "xhigh"), - "pr-synthesizer": (("Read",), "sonnet", 20, "gpt-5.6-terra", "high"), - "research-source-reader": (("Read",), "sonnet", 20, "gpt-5.6-luna", "xhigh"), - "research-synthesizer": (("Read",), "sonnet", 20, "gpt-5.6-terra", "xhigh"), + "pr-source-reader": (("Read",), "sonnet", 80, "gpt-5.6-luna", "xhigh"), + "pr-synthesizer": (("Read",), "sonnet", 80, "gpt-5.6-terra", "high"), + "research-source-reader": (("Read",), "sonnet", 80, "gpt-5.6-luna", "xhigh"), + "research-synthesizer": (("Read",), "sonnet", 80, "gpt-5.6-terra", "xhigh"), "friction-batch-scanner": ( ("Read", "Grep"), "haiku", @@ -122,6 +122,10 @@ def test_bundled_agent_catalog_loads_with_unique_digests() -> None: definitions = load_bundled_agent_definitions() assert definitions assert BUNDLED_EXPLORER_ROLES <= {definition.name for definition in definitions} + assert all( + definition.max_turns is not None and definition.max_turns >= 30 + for definition in definitions + ) assert len({definition.name for definition in definitions}) == len(definitions) assert len({agent_definition_digest(definition) for definition in definitions}) == len( definitions From f8e42a64891fd6e154b5f974c31a6eaf6682e44c Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 13 Aug 2026 21:18:53 -0700 Subject: [PATCH 18/18] refactor: strengthen specialized agent handoffs --- .../agents/friction-batch-scanner.md | 5 ++- .../agents/friction-category-analyzer.md | 9 ++++- src/autoskillit/agents/pr-source-reader.md | 8 +++- src/autoskillit/agents/pr-synthesizer.md | 7 +++- .../agents/research-source-reader.md | 9 ++++- .../agents/research-synthesizer.md | 8 +++- tests/core/test_agent_definition.py | 38 +++++++++++++++++++ 7 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md index 58ea81eb5..ffce12195 100644 --- a/src/autoskillit/agents/friction-batch-scanner.md +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -17,7 +17,8 @@ Scan only the log files assigned by the parent, using the supplied signal patter Search before reading bounded context around each hit. Confirm the event from that context and do not read whole logs, inspect other files, diagnose root causes, write files, or synthesize across batches. Report concrete file and line bounds; -do not guess when context is insufficient. +do not guess when context is insufficient. Account for every assigned file: list +anything not fully scanned as a coverage gap and state why scanning stopped. ## Completion shape @@ -26,6 +27,8 @@ do not guess when context is insufficient. "status": "answered | partial | blocked", "scanned_files": ["parent-assigned log path"], "events": [{"file": "log path", "line_start": 1, "line_end": 1, "category": "supplied category", "description": "one-line observed event"}], + "coverage_gaps": [{"file": "unscanned or partially scanned path", "reason": "concrete reason"}], + "stop_reason": "assigned scope complete | bounded pass exhausted | concrete blocker", "unknowns": ["unresolved hit or concrete blocker"] } ``` diff --git a/src/autoskillit/agents/friction-category-analyzer.md b/src/autoskillit/agents/friction-category-analyzer.md index 0d81a7746..1787fca99 100644 --- a/src/autoskillit/agents/friction-category-analyzer.md +++ b/src/autoskillit/agents/friction-category-analyzer.md @@ -16,7 +16,10 @@ Analyze only the category, indicators, and log locations supplied by the parent. Read bounded context at those locations to confirm or reclassify each indicator. Do not discover other logs, write files, or synthesize the full audit. Separate observations from inferences, cite file and line bounds, and retain unresolved -cases when the supplied evidence is insufficient. +cases when the supplied evidence is insufficient. Preserve each supplied +indicator's file-and-line identity, report whether it is confirmed, reclassified, +or unresolved with the evidence-grounded rationale, and do not silently discard +or average conflicting evidence. ## Completion shape @@ -26,10 +29,12 @@ cases when the supplied evidence is insufficient. "category": "parent-assigned category", "confirmed_occurrences": 0, "distinct_sessions": 0, - "evidence": [{"file": "log path", "line_start": 1, "line_end": 1, "sequence": "observed sequence", "blocker": "observed blocker"}], + "evidence": [{"file": "log path", "line_start": 1, "line_end": 1, "disposition": "confirmed | reclassified | unresolved", "rationale": "evidence-grounded basis", "sequence": "observed sequence", "blocker": "observed blocker"}], "shared_pattern": "supported pattern or null", "root_cause": "inference with basis or null", "mitigations": ["concrete mitigation"], + "conflicts": ["material conflicting evidence"], + "stop_reason": "all supplied indicators resolved | evidence exhausted | concrete blocker", "unknowns": ["unresolved indicator"] } ``` diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md index 899a9637e..1b1c88e54 100644 --- a/src/autoskillit/agents/pr-source-reader.md +++ b/src/autoskillit/agents/pr-source-reader.md @@ -16,7 +16,9 @@ Read only the source artifact named by the parent. Extract the requested section faithfully and keep source headings or other location cues with each result. Do not inspect other repository files, modify anything, use GitHub, or make the final PR summary. If the artifact is missing or cannot answer a requested field, preserve -that gap instead of guessing. +that gap instead of guessing. Mark each value as a literal extraction or bounded +summary and keep interpretation out of both. Account for every requested field in +the evidence or coverage gaps, then state why reading stopped. ## Completion shape @@ -24,7 +26,9 @@ that gap instead of guessing. { "status": "answered | partial | blocked", "source": "parent-supplied path", - "evidence": [{"field": "requested field", "value": "literal or bounded summary", "location": "heading or line cue"}], + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "representation": "literal | summary", "location": "heading or line cue"}], + "coverage_gaps": ["requested field not resolved and why"], + "stop_reason": "requested fields covered | artifact exhausted | concrete blocker", "unknowns": ["unresolved field or concrete blocker"] } ``` diff --git a/src/autoskillit/agents/pr-synthesizer.md b/src/autoskillit/agents/pr-synthesizer.md index ab5fffb7e..821ebf899 100644 --- a/src/autoskillit/agents/pr-synthesizer.md +++ b/src/autoskillit/agents/pr-synthesizer.md @@ -15,7 +15,9 @@ codex: Synthesize only the evidence supplied by the parent into a concise overall pull request summary. Do not inspect the repository or GitHub, introduce unsupported claims, write files, or create the pull request. Preserve material uncertainty and -conflicts in the supplied evidence. +conflicts in the supplied evidence. Trace every material summary claim to supplied +evidence locations, keep observations distinct from upstream inferences, and return +`partial` when coverage gaps prevent a complete summary. ## Completion shape @@ -23,6 +25,9 @@ conflicts in the supplied evidence. { "status": "answered | partial | blocked", "summary": "two or three evidence-grounded sentences", + "evidence_locations": ["supplied source and location supporting the summary"], + "conflicts": ["material conflict preserved from supplied evidence"], + "stop_reason": "summary supported | evidence exhausted | concrete blocker", "unknowns": ["material unresolved point"] } ``` diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md index a53aea56b..7ee75bf8f 100644 --- a/src/autoskillit/agents/research-source-reader.md +++ b/src/autoskillit/agents/research-source-reader.md @@ -15,7 +15,10 @@ codex: Read only the research artifact named by the parent. Extract the requested report or experiment-plan fields faithfully, retaining headings or other location cues. Do not inspect unrelated files, synthesize a recommendation, select lenses, or -modify anything. Report absent or ambiguous fields without filling them in. +modify anything. Report absent or ambiguous fields without filling them in. Mark +each value as a literal extraction or bounded summary and keep interpretation out +of both. Account for every requested field in the evidence or coverage gaps, then +state why reading stopped. ## Completion shape @@ -23,7 +26,9 @@ modify anything. Report absent or ambiguous fields without filling them in. { "status": "answered | partial | blocked", "source": "parent-supplied path", - "evidence": [{"field": "requested field", "value": "literal or bounded summary", "location": "heading or line cue"}], + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "representation": "literal | summary", "location": "heading or line cue"}], + "coverage_gaps": ["requested field not resolved and why"], + "stop_reason": "requested fields covered | artifact exhausted | concrete blocker", "unknowns": ["unresolved field or concrete blocker"] } ``` diff --git a/src/autoskillit/agents/research-synthesizer.md b/src/autoskillit/agents/research-synthesizer.md index e6ea9e6f3..79a0ec302 100644 --- a/src/autoskillit/agents/research-synthesizer.md +++ b/src/autoskillit/agents/research-synthesizer.md @@ -16,16 +16,22 @@ Use only the report and experiment-plan evidence supplied by the parent. Produce the requested directional recommendation or lens selection without inspecting the repository, inventing findings, writing files, or invoking a lens. Select lens slugs only from the parent's allowed table and state when the supplied evidence is -insufficient. +insufficient. Keep sourced findings, inference, and recommendation distinct; cite +the supplied evidence locations behind the recommendation, surface conflicts, and +abstain rather than collapse unresolved evidence into a direction. ## Completion shape ```json { "status": "answered | partial | blocked", + "findings": ["source-grounded finding kept distinct from inference"], "recommendation": "one to three evidence-grounded sentences, or null", "selected_lenses": ["allowed-lens-slug"], "rationale": "brief evidence-grounded rationale", + "evidence_locations": ["supplied source and location supporting the recommendation"], + "conflicts": ["material conflict preserved from supplied evidence"], + "stop_reason": "recommendation supported | evidence exhausted | concrete blocker", "unknowns": ["material unresolved point"] } ``` diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index c082137a2..558a443f7 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -156,6 +156,44 @@ def test_skill_child_roles_have_bounded_tools_and_usage_descriptions() -> None: assert not ({"Bash", "Write", "Edit"} & set(definition.tools)) +@pytest.mark.parametrize( + ("name", "contracts"), + [ + ( + "friction-batch-scanner", + ("coverage_gaps", "stop_reason", "assigned scope complete"), + ), + ( + "friction-category-analyzer", + ("disposition", "rationale", "conflicts", "stop_reason"), + ), + ( + "pr-source-reader", + ("representation", "coverage_gaps", "stop_reason"), + ), + ( + "pr-synthesizer", + ("evidence_locations", "conflicts", "upstream inferences", "stop_reason"), + ), + ( + "research-source-reader", + ("representation", "coverage_gaps", "stop_reason"), + ), + ( + "research-synthesizer", + ("findings", "evidence_locations", "conflicts", "stop_reason"), + ), + ], +) +def test_skill_child_roles_preserve_handoff_fidelity( + name: str, contracts: tuple[str, ...] +) -> None: + definition = load_agent_definition(pkg_root() / "agents" / f"{name}.md") + + for contract in contracts: + assert contract in definition.body + + def test_explicit_luna_projection_is_independent_from_claude_model(tmp_path: Path) -> None: path = tmp_path / "semantic-code-navigator.md" path.write_text(