From 091f419f6cab1528a2c30bb645d59fedce34559b Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 18:31:43 -0700 Subject: [PATCH 01/11] feat: model runtime-bound bundled agent roles --- .autoskillit/skills/eval-agent/SKILL.md | 12 +- src/autoskillit/core/__init__.pyi | 2 + src/autoskillit/core/plugins/__init__.py | 2 + src/autoskillit/core/plugins/_plugin_ids.py | 3 + .../core/types/launch/_type_skill_contract.py | 2 +- .../types/skill/_type_skill_plan_specs.py | 124 +++++++++++ .../core/types/skill/_type_skill_semantics.py | 156 +++++--------- .../execution/backends/_explorer_dispatch.py | 6 +- src/autoskillit/execution/backends/claude.py | 34 +++- src/autoskillit/execution/backends/codex.py | 45 +++- .../tools_execution/_fixed_batch_request.py | 4 +- .../tools/tools_execution/_managed_leaf.py | 2 +- .../workspace/session_skills/_catalog.py | 8 +- .../skill_capabilities/_semantic_plan.py | 1 + tests/_ambient_env_surface.py | 2 +- tests/arch/test_eval_agent_skill.py | 17 +- tests/core/test_skill_semantic_plan.py | 168 +++++++++++++++ .../backends/_conformance_assertions.py | 20 +- .../backends/test_join_conformance_traces.py | 1 + .../test_runtime_bound_role_adaptation.py | 192 ++++++++++++++++++ .../test_skill_session_contract_store.py | 12 +- tests/fakes.py | 3 +- tests/server/test_managed_fixed_batch.py | 42 ++++ tests/server/test_managed_leaf.py | 86 ++++++++ tests/workspace/test_skill_semantics.py | 85 ++++++++ 25 files changed, 886 insertions(+), 143 deletions(-) create mode 100644 src/autoskillit/core/types/skill/_type_skill_plan_specs.py create mode 100644 tests/execution/backends/test_runtime_bound_role_adaptation.py diff --git a/.autoskillit/skills/eval-agent/SKILL.md b/.autoskillit/skills/eval-agent/SKILL.md index 1ecd934217..c41c4073b3 100644 --- a/.autoskillit/skills/eval-agent/SKILL.md +++ b/.autoskillit/skills/eval-agent/SKILL.md @@ -12,6 +12,7 @@ semantic_requirements: logical_roles: - name: evaluated-agent purpose: run the named agent definition under evaluation against the prepared prompt and return its full output + runtime_bound: true child_spawns: - role: evaluated-agent count: 1 @@ -34,8 +35,8 @@ or evaluation. Runs one agent against one prompt and captures output. The skill receives its arguments as a single string. Parse these arguments: -- `--agent-name {name}` — The agent definition name (without `autoskillit:` prefix). - The skill prepends `autoskillit:` when calling the Agent tool. +- `--agent-name {name}` — The bundled agent definition name. + The backend-adapted contract determines the native agent type. - `--prompt-file {path}` — Absolute path to a file containing the prompt text. Example invocation: @@ -57,7 +58,7 @@ Example invocation: **ALWAYS:** - Parse `--agent-name` and `--prompt-file` from the ARGUMENTS string - Read the prompt file using the Read tool -- Invoke the agent via child delegation bound to the `evaluated-agent` logical role, targeting the named agent definition `autoskillit:{agent_name}` with the prompt file contents +- Invoke the bundled agent definition named by `--agent-name` via child delegation bound to the `evaluated-agent` logical role, using the backend-adapted contract for its native agent type and the prompt file contents - Write output JSON to `{{AUTOSKILLIT_TEMP}}/eval-agent/{agent_name}_output.json` (relative to the current working directory) using the Write tool - Emit `agent_output_path = ` as a structured output token (plain text, no markdown formatting on the token name) - The absolute path in the structured output token must be constructed by prepending the current working directory to the relative output path @@ -80,8 +81,9 @@ If the file cannot be read, proceed to Step 4 (error handling). ### Step 3: Invoke Agent and Capture Output Invoke the agent as a child delegation bound to the `evaluated-agent` logical role: -delegate to the named agent definition `autoskillit:{agent_name}`, passing the prompt -file contents verbatim as the delegation prompt. +delegate to the bundled agent definition named by `--agent-name`, using the +backend-adapted contract for its native agent type and passing the prompt file +contents verbatim as the delegation prompt. Capture the agent's full response text. diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 84fa451cfa..a26cad2a63 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -275,6 +275,7 @@ from .plugins._plugin_artifact_retirement import ( ) from .plugins._plugin_ids import _AUTOSKILLIT_INSTALL_ROOT_KEY as _AUTOSKILLIT_INSTALL_ROOT_KEY from .plugins._plugin_ids import _AUTOSKILLIT_PLUGIN_KEY as _AUTOSKILLIT_PLUGIN_KEY +from .plugins._plugin_ids import CLAUDE_PLUGIN_AGENT_NAMESPACE as CLAUDE_PLUGIN_AGENT_NAMESPACE from .plugins._plugin_ids import DIRECT_INSTALL_CACHE_SUBDIR as DIRECT_INSTALL_CACHE_SUBDIR from .plugins._plugin_ids import DIRECT_PREFIX as DIRECT_PREFIX from .plugins._plugin_ids import PLUGIN_PREFIX as PLUGIN_PREFIX @@ -544,6 +545,7 @@ from .types import ( from .types import CONTEXT_EXHAUSTION_MARKER as CONTEXT_EXHAUSTION_MARKER from .types import CORE_PACKS as CORE_PACKS from .types import DATA_MANIFEST_SOURCE_TYPES as DATA_MANIFEST_SOURCE_TYPES +from .types import DELEGATED_WORKER_ROLE as DELEGATED_WORKER_ROLE from .types import DISPATCH_ID_ENV_VAR as DISPATCH_ID_ENV_VAR from .types import DRY_WALKTHROUGH_VERIFIED_MARKER as DRY_WALKTHROUGH_VERIFIED_MARKER from .types import DURABLE_ARTIFACT_WRITERS as DURABLE_ARTIFACT_WRITERS diff --git a/src/autoskillit/core/plugins/__init__.py b/src/autoskillit/core/plugins/__init__.py index 238016d9dc..9948803642 100644 --- a/src/autoskillit/core/plugins/__init__.py +++ b/src/autoskillit/core/plugins/__init__.py @@ -77,6 +77,7 @@ from autoskillit.core.plugins._plugin_ids import ( _AUTOSKILLIT_INSTALL_ROOT_KEY, _AUTOSKILLIT_PLUGIN_KEY, + CLAUDE_PLUGIN_AGENT_NAMESPACE, DIRECT_INSTALL_CACHE_SUBDIR, DIRECT_PREFIX, PLUGIN_PREFIX, @@ -126,6 +127,7 @@ "CODEX_DISABLED_WEB_SEARCH_POLICY", "CODEX_EXPLORER_IDENTITY", "CodexAgentProjectionDef", + "CLAUDE_PLUGIN_AGENT_NAMESPACE", "DIRECT_INSTALL_CACHE_SUBDIR", "DIRECT_PREFIX", "INSTALLED_PLUGIN_ARTIFACT_MANIFEST_FIELDS", diff --git a/src/autoskillit/core/plugins/_plugin_ids.py b/src/autoskillit/core/plugins/_plugin_ids.py index 3e20d91e94..11291cf01c 100644 --- a/src/autoskillit/core/plugins/_plugin_ids.py +++ b/src/autoskillit/core/plugins/_plugin_ids.py @@ -20,6 +20,7 @@ __all__ = [ "DIRECT_INSTALL_CACHE_SUBDIR", "DIRECT_PREFIX", + "CLAUDE_PLUGIN_AGENT_NAMESPACE", "PLUGIN_PREFIX", "claude_plugin_tool_prefix", "find_qualified_autoskillit_tool_names", @@ -50,6 +51,8 @@ # session (--plugin-dir and marketplace alike): # mcp__plugin__<.mcp.json server key>__ PLUGIN_PREFIX = "mcp__plugin_autoskillit_autoskillit__" +# Claude Code registers plugin agents as :. +CLAUDE_PLUGIN_AGENT_NAMESPACE = "autoskillit:" _CLAUDE_TOOL_SEGMENT_INVALID_CHARS = re.compile(r"[^A-Za-z0-9_-]") _QUALIFIED_AUTOSKILLIT_TOOL_RE = re.compile( diff --git a/src/autoskillit/core/types/launch/_type_skill_contract.py b/src/autoskillit/core/types/launch/_type_skill_contract.py index de542461b2..384aa26e29 100644 --- a/src/autoskillit/core/types/launch/_type_skill_contract.py +++ b/src/autoskillit/core/types/launch/_type_skill_contract.py @@ -57,7 +57,7 @@ ) MANAGED_SKILL_FILENAME = "SKILL.md" # Static resources are now compiled into projected SKILL.md bytes. -SKILL_PROJECTION_VERSION = 10 +SKILL_PROJECTION_VERSION = 11 SKILL_SESSION_CONTRACT_SCHEMA_VERSION = 5 PARENT_SANDBOX_MODES: frozenset[str] = frozenset({"read-only", "workspace-write"}) _CANONICAL_IDENTIFIER_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z") diff --git a/src/autoskillit/core/types/skill/_type_skill_plan_specs.py b/src/autoskillit/core/types/skill/_type_skill_plan_specs.py new file mode 100644 index 0000000000..eda5de11ac --- /dev/null +++ b/src/autoskillit/core/types/skill/_type_skill_plan_specs.py @@ -0,0 +1,124 @@ +"""Primitive child and logical role specifications for semantic skill plans.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final + +from ..foundation._type_exceptions import ChildSpawnCardinalityError, SkillContractError + +__all__ = [ + "SKILL_MODEL_CLASS_REGISTRY", + "SKILL_REASONING_EFFORTS", + "ChildModelPolicySpec", + "ChildSpawnSpec", + "DELEGATED_WORKER_ROLE", + "LogicalRoleSpec", + "SkillModelClassDef", +] + + +@dataclass(frozen=True, slots=True) +class SkillModelClassDef: + """Static definition of one backend-neutral logical model class.""" + + description: str + + +SKILL_MODEL_CLASS_REGISTRY: Mapping[str, SkillModelClassDef] = MappingProxyType( + { + "haiku": SkillModelClassDef( + description="Lightweight logical class for focused delegated work", + ), + "sonnet": SkillModelClassDef( + description="Balanced logical class for general delegated work", + ), + "opus": SkillModelClassDef( + description="Highest-capability logical class for demanding delegated work", + ), + } +) + +SKILL_REASONING_EFFORTS: frozenset[str] = frozenset({"medium", "high"}) +DELEGATED_WORKER_ROLE: Final = "delegated-worker" + + +def _require_nonempty(value: str, field_name: str) -> None: + if not value.strip(): + raise SkillContractError(f"{field_name} must be non-empty") + + +@dataclass(frozen=True, slots=True) +class ChildSpawnSpec: + """Spawn one or more children that perform a named logical role.""" + + role: str + count: int | None = None + for_each: str | None = None + + def __post_init__(self) -> None: + _require_nonempty(self.role, "child spawn role") + has_count = self.count is not None + has_for_each = self.for_each is not None + if has_count == has_for_each: + raise ChildSpawnCardinalityError( + "child spawn requires exactly one cardinality authority: " + "count: or for_each: " + ) + if self.count is not None: + if type(self.count) is not int or self.count <= 0: + raise ChildSpawnCardinalityError( + "child spawn count must be a positive integer; use " + "count: or for_each: " + ) + if has_for_each and (not isinstance(self.for_each, str) or not self.for_each.strip()): + raise ChildSpawnCardinalityError( + "child spawn for_each must be a non-empty runtime collection name; use " + "count: or for_each: " + ) + + +@dataclass(frozen=True, slots=True) +class ChildModelPolicySpec: + """Semantic model-class and reasoning-effort policy for one logical role.""" + + role: str + model_class: str | None = None + reasoning_effort: str | None = None + + def __post_init__(self) -> None: + _require_nonempty(self.role, "child model policy role") + if self.model_class is not None and self.model_class not in SKILL_MODEL_CLASS_REGISTRY: + raise SkillContractError( + f"unknown semantic model class {self.model_class!r}; " + f"expected one of {sorted(SKILL_MODEL_CLASS_REGISTRY)}" + ) + if ( + self.reasoning_effort is not None + and self.reasoning_effort not in SKILL_REASONING_EFFORTS + ): + raise SkillContractError( + f"unknown semantic reasoning effort {self.reasoning_effort!r}; " + f"expected one of {sorted(SKILL_REASONING_EFFORTS)}" + ) + if self.model_class is None and self.reasoning_effort is None: + raise SkillContractError("child model policy must constrain model class or effort") + + +@dataclass(frozen=True, slots=True) +class LogicalRoleSpec: + """Backend-neutral name and purpose for delegated child work.""" + + name: str + purpose: str + runtime_bound: bool = False + + def __post_init__(self) -> None: + _require_nonempty(self.name, "logical role name") + _require_nonempty(self.purpose, "logical role purpose") + if type(self.runtime_bound) is not bool: + raise SkillContractError("logical role runtime_bound must be a boolean") + if self.runtime_bound and self.name == DELEGATED_WORKER_ROLE: + raise SkillContractError("delegated worker role cannot be runtime-bound") diff --git a/src/autoskillit/core/types/skill/_type_skill_semantics.py b/src/autoskillit/core/types/skill/_type_skill_semantics.py index f3ab47b4a2..9832f1f07e 100644 --- a/src/autoskillit/core/types/skill/_type_skill_semantics.py +++ b/src/autoskillit/core/types/skill/_type_skill_semantics.py @@ -10,7 +10,17 @@ from types import MappingProxyType from typing import TYPE_CHECKING, Literal, final -from ..foundation._type_exceptions import ChildSpawnCardinalityError, SkillContractError +from ..foundation._type_exceptions import SkillContractError +from ._type_skill_plan_specs import ( + DELEGATED_WORKER_ROLE, + SKILL_MODEL_CLASS_REGISTRY, + SKILL_REASONING_EFFORTS, + ChildModelPolicySpec, + ChildSpawnSpec, + LogicalRoleSpec, + SkillModelClassDef, + _require_nonempty, +) if TYPE_CHECKING: from ..execution._type_backend import BackendCapabilities @@ -23,6 +33,7 @@ "ChildModelPolicySpec", "ChildSpawnSpec", "ConcurrencySpec", + "DELEGATED_WORKER_ROLE", "EvidenceSpec", "GitMetadataWriteSpec", "JoinSpec", @@ -47,29 +58,6 @@ ] -@dataclass(frozen=True, slots=True) -class SkillModelClassDef: - """Static definition of one backend-neutral logical model class.""" - - description: str - - -SKILL_MODEL_CLASS_REGISTRY: Mapping[str, SkillModelClassDef] = MappingProxyType( - { - "haiku": SkillModelClassDef( - description="Lightweight logical class for focused delegated work", - ), - "sonnet": SkillModelClassDef( - description="Balanced logical class for general delegated work", - ), - "opus": SkillModelClassDef( - description="Highest-capability logical class for demanding delegated work", - ), - } -) - -SKILL_REASONING_EFFORTS: frozenset[str] = frozenset({"medium", "high"}) - SKILL_SEMANTIC_SCHEMA_VERSION = 1 MANAGED_JOIN_ATTESTATION_SCHEMA_VERSION = 1 @@ -89,46 +77,11 @@ class SkillSemanticOperation(StrEnum): GIT_METADATA_WRITE = "git_metadata_write" -def _require_nonempty(value: str, field_name: str) -> None: - if not value.strip(): - raise SkillContractError(f"{field_name} must be non-empty") - - def _require_sha256(value: str, field_name: str) -> None: if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): raise SkillContractError(f"{field_name} must be a lowercase SHA-256 digest") -@dataclass(frozen=True, slots=True) -class ChildSpawnSpec: - """Spawn one or more children that perform a named logical role.""" - - role: str - count: int | None = None - for_each: str | None = None - - def __post_init__(self) -> None: - _require_nonempty(self.role, "child spawn role") - has_count = self.count is not None - has_for_each = self.for_each is not None - if has_count == has_for_each: - raise ChildSpawnCardinalityError( - "child spawn requires exactly one cardinality authority: " - "count: or for_each: " - ) - if self.count is not None: - if type(self.count) is not int or self.count <= 0: - raise ChildSpawnCardinalityError( - "child spawn count must be a positive integer; use " - "count: or for_each: " - ) - if has_for_each and (not isinstance(self.for_each, str) or not self.for_each.strip()): - raise ChildSpawnCardinalityError( - "child spawn for_each must be a non-empty runtime collection name; use " - "count: or for_each: " - ) - - @dataclass(frozen=True, slots=True) class ConcurrencySpec: """Whether the declared child work must overlap in time.""" @@ -340,45 +293,6 @@ def __post_init__(self) -> None: raise SkillContractError("independent evidence requires evidence collection") -@dataclass(frozen=True, slots=True) -class ChildModelPolicySpec: - """Semantic model-class and reasoning-effort policy for one logical role.""" - - role: str - model_class: str | None = None - reasoning_effort: str | None = None - - def __post_init__(self) -> None: - _require_nonempty(self.role, "child model policy role") - if self.model_class is not None and self.model_class not in SKILL_MODEL_CLASS_REGISTRY: - raise SkillContractError( - f"unknown semantic model class {self.model_class!r}; " - f"expected one of {sorted(SKILL_MODEL_CLASS_REGISTRY)}" - ) - if ( - self.reasoning_effort is not None - and self.reasoning_effort not in SKILL_REASONING_EFFORTS - ): - raise SkillContractError( - f"unknown semantic reasoning effort {self.reasoning_effort!r}; " - f"expected one of {sorted(SKILL_REASONING_EFFORTS)}" - ) - if self.model_class is None and self.reasoning_effort is None: - raise SkillContractError("child model policy must constrain model class or effort") - - -@dataclass(frozen=True, slots=True) -class LogicalRoleSpec: - """Backend-neutral name and purpose for delegated child work.""" - - name: str - purpose: str - - def __post_init__(self) -> None: - _require_nonempty(self.name, "logical role name") - _require_nonempty(self.purpose, "logical role purpose") - - @dataclass(frozen=True, slots=True) class SiblingSkillSpec: """Invoke another logical skill through the selected backend's native sigil.""" @@ -432,6 +346,24 @@ def __post_init__(self) -> None: raise SkillContractError( f"semantic plan references unknown logical role: {sorted(unknown)}" ) + runtime_bound_roles = self.runtime_bound_role_names + spawned_roles = {spawn.role for spawn in self.child_spawns} + unspawned_runtime_roles = runtime_bound_roles - spawned_roles + if unspawned_runtime_roles: + raise SkillContractError( + f"runtime-bound logical roles must be spawned: {sorted(unspawned_runtime_roles)}" + ) + policy_roles = {policy.role for policy in self.child_model_policies} + runtime_policy_roles = runtime_bound_roles & policy_roles + if runtime_policy_roles: + raise SkillContractError( + "runtime-bound logical roles cannot have model policies: " + f"{sorted(runtime_policy_roles)}" + ) + + @property + def runtime_bound_role_names(self) -> frozenset[str]: + return frozenset(role.name for role in self.logical_roles if role.runtime_bound) @property def operations(self) -> frozenset[SkillSemanticOperation]: @@ -486,7 +418,12 @@ def canonical_payload(self) -> Mapping[str, object]: for item in self.child_model_policies ), "logical_roles": tuple( - {"name": item.name, "purpose": item.purpose} for item in self.logical_roles + { + "name": item.name, + "purpose": item.purpose, + "runtime_bound": item.runtime_bound, + } + for item in self.logical_roles ), "sibling_skills": tuple({"name": item.name} for item in self.sibling_skills), "git_metadata_writes": tuple( @@ -584,6 +521,8 @@ def adapt_session_invariant( def _validate_supported_adaptation(result: SkillSemanticAdaptationResult) -> None: if any(not fragment.strip() for fragment in result.instruction_fragments): raise SkillContractError("semantic adaptation instructions must be non-empty") + if any(not isinstance(role, str) or not role.strip() for role in result.runtime_bound_roles): + raise SkillContractError("runtime-bound roles must be non-empty strings") if result.adaptation_context_digest: _require_sha256( result.adaptation_context_digest, @@ -616,11 +555,13 @@ class SkillSemanticAdaptationResult: unsupported_operation: SkillSemanticOperation | None = None diagnostic: str | None = None adaptation_context_digest: str = "" + runtime_bound_roles: frozenset[str] = frozenset() def __post_init__(self) -> None: object.__setattr__( self, "logical_role_mapping", MappingProxyType(dict(self.logical_role_mapping)) ) + object.__setattr__(self, "runtime_bound_roles", frozenset(self.runtime_bound_roles)) object.__setattr__( self, "sibling_skill_targets", MappingProxyType(dict(self.sibling_skill_targets)) ) @@ -634,6 +575,7 @@ def __post_init__(self) -> None: if self.unsupported_operation is not None and ( self.instruction_fragments or self.logical_role_mapping + or self.runtime_bound_roles or self.sibling_skill_targets or self.model_effort_policy ): @@ -646,6 +588,7 @@ def canonical_payload(self) -> Mapping[str, object]: { "instruction_fragments": self.instruction_fragments, "logical_role_mapping": dict(sorted(self.logical_role_mapping.items())), + "runtime_bound_roles": sorted(self.runtime_bound_roles), "sibling_skill_targets": dict(sorted(self.sibling_skill_targets.items())), "model_effort_policy": { key: value for key, value in sorted(self.model_effort_policy.items()) @@ -660,6 +603,10 @@ def canonical_payload(self) -> Mapping[str, object]: } ) + @property + def adapted_logical_roles(self) -> frozenset[str]: + return frozenset(self.logical_role_mapping) | self.runtime_bound_roles + @property def digest(self) -> str: return sha256( @@ -689,13 +636,18 @@ def validate_refusal_for( ) return operation + def _validate_logical_role_bindings(self, plan: SkillSemanticPlan) -> None: + logical_names = {role.name for role in plan.logical_roles if not role.runtime_bound} + if set(self.logical_role_mapping) != logical_names: + raise SkillContractError("semantic adaptation logical role mapping is incomplete") + if self.runtime_bound_roles != plan.runtime_bound_role_names: + raise SkillContractError("semantic adaptation runtime agent binding is incomplete") + def validate_for(self, plan: SkillSemanticPlan, *, backend: str) -> None: """Fail closed unless every declared semantic field has one observable adaptation.""" if self.validate_refusal_for(plan, backend=backend) is not None: raise SkillContractError(self.diagnostic or "unsupported skill semantics") - logical_names = {role.name for role in plan.logical_roles} - if set(self.logical_role_mapping) != logical_names: - raise SkillContractError("semantic adaptation logical role mapping is incomplete") + self._validate_logical_role_bindings(plan) native_roles = tuple(self.logical_role_mapping.values()) if len(native_roles) != len(set(native_roles)): raise SkillContractError("semantic adaptation maps multiple logical roles to one role") diff --git a/src/autoskillit/execution/backends/_explorer_dispatch.py b/src/autoskillit/execution/backends/_explorer_dispatch.py index 0a4968daa5..a7a6d74aa7 100644 --- a/src/autoskillit/execution/backends/_explorer_dispatch.py +++ b/src/autoskillit/execution/backends/_explorer_dispatch.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from autoskillit.core import ( + CLAUDE_PLUGIN_AGENT_NAMESPACE, PLUGINLESS_EXPLORER_ROLE, AgentDef, ExplorationDispatchConventions, @@ -189,14 +190,15 @@ def render( role_argument="subagent_type", description_argument="description", message_argument="prompt", - role_prefix="autoskillit:", + role_prefix=CLAUDE_PLUGIN_AGENT_NAMESPACE, provisioning_preamble=( "Before dispatching explorer subagents, call enable_exploration() to " "establish session-scoped exploration authority. The broker tools " "(submit_exploration_query, get_exploration_page, resume_exploration_context) " "become visible only after enable_exploration succeeds.\n\n" f"{_EXPLORATION_FALLBACK_SENTENCE} Dispatch it as " - f'Agent(subagent_type="autoskillit:{PLUGINLESS_EXPLORER_ROLE}", prompt=...).' + f'Agent(subagent_type="{CLAUDE_PLUGIN_AGENT_NAMESPACE}' + f'{PLUGINLESS_EXPLORER_ROLE}", prompt=...).' ), ) ) diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 46044466e9..d3719061c0 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -29,6 +29,7 @@ CLAUDE_MCP_CONNECT_TIMEOUT_ENV_VAR, CLAUDE_MCP_CONNECT_TIMEOUT_MS, CLAUDE_MCP_CONNECTION_NONBLOCKING, + CLAUDE_PLUGIN_AGENT_NAMESPACE, NON_VARIADIC_CLAUDE_FLAGS, SESSION_ADD_DIR_SUBDIR, VARIADIC_CLAUDE_FLAGS, @@ -591,16 +592,44 @@ def adapt_skill_semantics( "all capability-attested. Refuse the skill at admission." ), ) - role_mapping = {role.name: role.name for role in plan.logical_roles} + role_mapping = { + role.name: role.name for role in plan.logical_roles if not role.runtime_bound + } sibling_targets = { sibling.name: f"/autoskillit:{sibling.name}" for sibling in plan.sibling_skills } model_policy: dict[str, tuple[str, str | None]] = {} - fragments = [f"Logical role {role.name!r}: {role.purpose}." for role in plan.logical_roles] + fragments = [ + ( + f"Logical role {role.name!r} is bound at runtime to a bundled agent definition: " + f"{role.purpose}." + if role.runtime_bound + else f"Logical role {role.name!r}: {role.purpose}." + ) + for role in plan.logical_roles + ] for policy in plan.child_model_policies: model_id = self.translate_model(policy.model_class) if policy.model_class else "" model_policy[policy.role] = (model_id, policy.reasoning_effort) for spawn in plan.child_spawns: + if spawn.role in plan.runtime_bound_role_names: + if spawn.for_each is not None: + fragments.append( + f"Issue one Agent call for logical role {spawn.role!r} with " + f"subagent_type set to {CLAUDE_PLUGIN_AGENT_NAMESPACE!r} followed " + "by the bundled agent definition name supplied at runtime per " + f"runtime item in {spawn.for_each!r}." + ) + else: + assert spawn.count is not None + fragments.append( + f"Issue {spawn.count} Agent call" + f"{'s' if spawn.count != 1 else ''} for logical role " + f"{spawn.role!r} with subagent_type set to " + f"{CLAUDE_PLUGIN_AGENT_NAMESPACE!r} followed by the bundled agent " + "definition name supplied at runtime." + ) + continue spawn_policy = next( ( candidate @@ -658,6 +687,7 @@ def adapt_skill_semantics( logical_role_mapping=role_mapping, sibling_skill_targets=sibling_targets, model_effort_policy=model_policy, + runtime_bound_roles=plan.runtime_bound_role_names, ) result.validate_for(plan, backend=self.name) return result diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index abeacec8d7..5ddc0f3c7c 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -18,6 +18,7 @@ CODEX_MODEL_ALIASES, CODEX_SESSIONS_SUBDIR, CODEX_VALID_MODEL_IDS, + DELEGATED_WORKER_ROLE, RETIRED_CODEX_MODEL_BASE_IDS, RETIRED_CODEX_MODEL_PREFIXES, RETIRED_CODEX_MODEL_SUFFIXES, @@ -163,13 +164,41 @@ def _codex_logical_role_mapping(plan: SkillSemanticPlan) -> dict[str, str]: role.name.removeprefix("autoskillit:") if role.name.startswith("autoskillit:") else "worker" - if role.name == "delegated-worker" + if role.name == DELEGATED_WORKER_ROLE else role.name ) for role in plan.logical_roles + if not role.runtime_bound } +def _codex_semantic_admission_refusal( + plan: SkillSemanticPlan, + capabilities: BackendCapabilities, + backend_name: str, + adaptation_context: SemanticAdaptationContext | None, +) -> SkillSemanticAdaptationResult | None: + if required_join_is_unsupported(plan, capabilities, backend_name, adaptation_context): + return SkillSemanticAdaptationResult( + unsupported_operation=SkillSemanticOperation.REQUIRED_JOIN, + diagnostic=( + "Codex exposes wait-any/mailbox-activity semantics rather than fixed-set fan-in. " + "Skills declaring join.required=true cannot be honestly realized on this " + "backend and must be refused at admission." + ), + ) + if plan.runtime_bound_role_names: + return SkillSemanticAdaptationResult( + unsupported_operation=SkillSemanticOperation.CHILD_SPAWN, + diagnostic=( + "Codex provisions agents per session from statically declared roles; " + "a runtime-selected bundled agent cannot be provisioned. Refuse the " + "skill at admission." + ), + ) + return None + + @dataclass(frozen=True, slots=True) class CodexBackend(CodexOrdinaryHeadlessCommandMixin): source_codex_home: Path | None = None @@ -458,20 +487,14 @@ def adapt_skill_semantics( adaptation_context: SemanticAdaptationContext | None = None, ) -> SkillSemanticAdaptationResult: """Adapt portable skill requirements to Codex collaboration instructions.""" - if required_join_is_unsupported( + refusal = _codex_semantic_admission_refusal( plan, self.capabilities, self.name, adaptation_context, - ): - return SkillSemanticAdaptationResult( - unsupported_operation=SkillSemanticOperation.REQUIRED_JOIN, - diagnostic=( - "Codex exposes wait-any/mailbox-activity semantics rather than " - "fixed-set fan-in. Skills declaring join.required=true cannot be " - "honestly realized on this backend and must be refused at admission." - ), - ) + ) + if refusal is not None: + return refusal role_mapping = _codex_logical_role_mapping(plan) managed_join = bool(plan.join and plan.join.required) and ( adaptation_context is not None diff --git a/src/autoskillit/server/tools/tools_execution/_fixed_batch_request.py b/src/autoskillit/server/tools/tools_execution/_fixed_batch_request.py index 56f6189370..7d802e75f9 100644 --- a/src/autoskillit/server/tools/tools_execution/_fixed_batch_request.py +++ b/src/autoskillit/server/tools/tools_execution/_fixed_batch_request.py @@ -116,7 +116,9 @@ def _validate_membership( if len(dynamic_keys) != len(set(dynamic_keys)): raise SkillContractError("run_fixed_batch dynamic runtime keys must be unique") logical_roles = adaptation.logical_role_mapping - if logical_roles and any(role not in logical_roles for role in received_roles): + if adaptation.adapted_logical_roles and any( + role not in logical_roles for role in received_roles + ): raise SkillContractError( "run_fixed_batch assignments use roles absent from source adaptation" ) diff --git a/src/autoskillit/server/tools/tools_execution/_managed_leaf.py b/src/autoskillit/server/tools/tools_execution/_managed_leaf.py index 6e2f473e04..f8637e2cef 100644 --- a/src/autoskillit/server/tools/tools_execution/_managed_leaf.py +++ b/src/autoskillit/server/tools/tools_execution/_managed_leaf.py @@ -356,7 +356,7 @@ def bind_managed_leaf( ) if adaptation.digest != document_adaptation: raise SkillContractError("managed leaf adaptation is not the selected source adaptation") - if adaptation.logical_role_mapping and assignment.role not in adaptation.logical_role_mapping: + if adaptation.adapted_logical_roles and assignment.role not in adaptation.logical_role_mapping: raise SkillContractError( f"managed leaf role {assignment.role!r} is not declared by source" ) diff --git a/src/autoskillit/workspace/session_skills/_catalog.py b/src/autoskillit/workspace/session_skills/_catalog.py index 6c0f0ce96e..3b7184b1a9 100644 --- a/src/autoskillit/workspace/session_skills/_catalog.py +++ b/src/autoskillit/workspace/session_skills/_catalog.py @@ -191,7 +191,13 @@ def _required_native_child_roles( if adaptation.adaptation_context_digest: return () return tuple( - sorted({adaptation.logical_role_mapping[spawn.role] for spawn in plan.child_spawns}) + sorted( + { + adaptation.logical_role_mapping[spawn.role] + for spawn in plan.child_spawns + if spawn.role not in plan.runtime_bound_role_names + } + ) ) diff --git a/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py b/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py index 5bcbe9a48a..fccd211ccb 100644 --- a/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py +++ b/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py @@ -191,6 +191,7 @@ def _build_skill_semantic_plan( LogicalRoleSpec( name=str(item.get("name", "")), purpose=str(item.get("purpose", "")), + runtime_bound=item.get("runtime_bound", False), ) for item in _mapping_list(raw_requirements.get("logical_roles", []), "logical_roles") ) diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 1be1b652b6..13faec4edb 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -1017,7 +1017,7 @@ class AmbientEnvDisposition: "Unfiltered dict(os.environ) base for the global-Codex-home MCP-inventory validation" "probe subprocess, with CODEX_RESERVED_HOME_ENV_VARS overridden to the source home." ), - "execution/backends/claude.py:323": ( + "execution/backends/claude.py:324": ( "Excludes _INTERACTIVE_ENV_EXCLUSIONS (TERM/NO_COLOR headless-hardening keys) when" "building the interactive Claude Code base env." ), diff --git a/tests/arch/test_eval_agent_skill.py b/tests/arch/test_eval_agent_skill.py index b5f80bfb52..0eb77a6c9a 100644 --- a/tests/arch/test_eval_agent_skill.py +++ b/tests/arch/test_eval_agent_skill.py @@ -46,14 +46,21 @@ def test_eval_agent_has_critical_constraints(): def test_eval_agent_delegates_to_named_agent(): - """SKILL.md declares child delegation targeting the named autoskillit agent definition.""" + from autoskillit.execution.backends import ClaudeCodeBackend + from autoskillit.workspace.skill_capabilities import parse_skill_semantic_plan + source = _SKILL_FILE.read_text() parts = source.split("---", 2) fm = load_yaml(parts[1]) - semantic = fm.get("semantic_requirements") or {} - roles = {spawn.get("role") for spawn in semantic.get("child_spawns") or []} - assert "evaluated-agent" in roles, "child_spawns must declare the evaluated-agent role" - assert "autoskillit:{agent_name}" in source + plan, diagnostics = parse_skill_semantic_plan( + fm, path=_SKILL_FILE, content=source, uses_capabilities=frozenset() + ) + assert not diagnostics + assert plan is not None + assert plan.runtime_bound_role_names == frozenset({"evaluated-agent"}) + assert tuple(spawn.role for spawn in plan.child_spawns) == ("evaluated-agent",) + assert ClaudeCodeBackend().adapt_skill_semantics(plan).unsupported_operation is None + assert "autoskillit:{agent_name}" not in source def test_eval_agent_uses_write_tool(): diff --git a/tests/core/test_skill_semantic_plan.py b/tests/core/test_skill_semantic_plan.py index 6986c36186..a9d5dd0873 100644 --- a/tests/core/test_skill_semantic_plan.py +++ b/tests/core/test_skill_semantic_plan.py @@ -206,3 +206,171 @@ def test_managed_join_adaptation_context_is_immutable_and_digestible() -> None: managed_join_attestation=catalog_attestation, managed_codex_catalog=b"tampered", ) + + +def test_logical_role_is_not_runtime_bound_by_default() -> None: + from autoskillit.core import LogicalRoleSpec + + assert LogicalRoleSpec(name="x", purpose="p").runtime_bound is False + + +def test_runtime_bound_must_be_a_bool() -> None: + from autoskillit.core import LogicalRoleSpec, SkillContractError + + with pytest.raises(SkillContractError): + LogicalRoleSpec(name="x", purpose="p", runtime_bound="yes") # type: ignore[arg-type] + + +def test_delegated_worker_cannot_be_runtime_bound() -> None: + from autoskillit.core import DELEGATED_WORKER_ROLE, LogicalRoleSpec, SkillContractError + + with pytest.raises(SkillContractError): + LogicalRoleSpec(name=DELEGATED_WORKER_ROLE, purpose="p", runtime_bound=True) + + +def test_runtime_bound_role_must_be_spawned() -> None: + from autoskillit.core import LogicalRoleSpec, SkillContractError, SkillSemanticPlan + + with pytest.raises(SkillContractError): + SkillSemanticPlan( + schema_version=1, + logical_roles=( + LogicalRoleSpec(name="evaluated-agent", purpose="p", runtime_bound=True), + ), + ) + + +def test_runtime_bound_role_rejects_model_policy() -> None: + from autoskillit.core import ( + ChildModelPolicySpec, + ChildSpawnSpec, + LogicalRoleSpec, + SkillContractError, + SkillSemanticPlan, + ) + + with pytest.raises(SkillContractError): + SkillSemanticPlan( + schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), + child_model_policies=( + ChildModelPolicySpec(role="evaluated-agent", model_class="opus"), + ), + logical_roles=( + LogicalRoleSpec(name="evaluated-agent", purpose="p", runtime_bound=True), + ), + ) + + +def test_plan_exposes_runtime_bound_role_names() -> None: + from autoskillit.core import ChildSpawnSpec, LogicalRoleSpec, SkillSemanticPlan + + plan = SkillSemanticPlan( + schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), + logical_roles=(LogicalRoleSpec(name="evaluated-agent", purpose="p", runtime_bound=True),), + ) + + assert plan.runtime_bound_role_names == frozenset({"evaluated-agent"}) + + +def test_validate_for_requires_runtime_binding_coverage() -> None: + from autoskillit.core import ( + ChildSpawnSpec, + LogicalRoleSpec, + SkillContractError, + SkillSemanticAdaptationResult, + SkillSemanticPlan, + ) + + plan = SkillSemanticPlan( + schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), + logical_roles=(LogicalRoleSpec(name="evaluated-agent", purpose="p", runtime_bound=True),), + ) + + with pytest.raises(SkillContractError, match="runtime agent binding is incomplete"): + SkillSemanticAdaptationResult(instruction_fragments=("x",)).validate_for(plan, backend="b") + with pytest.raises(SkillContractError, match="logical role mapping is incomplete"): + SkillSemanticAdaptationResult( + instruction_fragments=("x",), + logical_role_mapping={"evaluated-agent": "native-worker"}, + ).validate_for(plan, backend="b") + + +def test_unsupported_adaptation_cannot_carry_runtime_bindings() -> None: + from autoskillit.core import ( + SkillContractError, + SkillSemanticAdaptationResult, + SkillSemanticOperation, + ) + + with pytest.raises(SkillContractError, match="cannot carry instructions"): + SkillSemanticAdaptationResult( + unsupported_operation=SkillSemanticOperation.CHILD_SPAWN, + diagnostic="unsupported", + runtime_bound_roles=frozenset({"evaluated-agent"}), + ) + + +def test_adapted_logical_roles_spans_both_bindings() -> None: + from autoskillit.core import SkillSemanticAdaptationResult + + result = SkillSemanticAdaptationResult( + logical_role_mapping={"a": "native-a"}, + runtime_bound_roles=frozenset({"b"}), + ) + + assert result.adapted_logical_roles == frozenset({"a", "b"}) + + +def test_canonical_payload_includes_runtime_bound_roles() -> None: + from autoskillit.core import SkillSemanticAdaptationResult + + result = SkillSemanticAdaptationResult(runtime_bound_roles=frozenset({"z", "a"})) + + assert result.canonical_payload["runtime_bound_roles"] == ["a", "z"] + + +def test_runtime_bound_changes_plan_semantic_identity() -> None: + from autoskillit.core import ChildSpawnSpec, LogicalRoleSpec, SkillSemanticPlan + + static_plan = SkillSemanticPlan( + schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), + logical_roles=(LogicalRoleSpec(name="evaluated-agent", purpose="p"),), + ) + runtime_plan = SkillSemanticPlan( + schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), + logical_roles=(LogicalRoleSpec(name="evaluated-agent", purpose="p", runtime_bound=True),), + ) + + assert static_plan.canonical_payload["logical_roles"] == ( + {"name": "evaluated-agent", "purpose": "p", "runtime_bound": False}, + ) + assert runtime_plan.canonical_payload["logical_roles"] == ( + {"name": "evaluated-agent", "purpose": "p", "runtime_bound": True}, + ) + assert static_plan.digest != runtime_plan.digest + + +@pytest.mark.parametrize("role", ["", 1]) +def test_runtime_bound_roles_are_nonempty_strings(role: object) -> None: + from autoskillit.core import SkillContractError, SkillSemanticAdaptationResult + + with pytest.raises(SkillContractError): + SkillSemanticAdaptationResult( + instruction_fragments=("x",), + runtime_bound_roles=frozenset({role}), # type: ignore[arg-type] + ) + + +def test_runtime_bound_roles_are_frozen() -> None: + from autoskillit.core import SkillSemanticAdaptationResult + + result = SkillSemanticAdaptationResult( + runtime_bound_roles=["worker", "auditor"] # type: ignore[arg-type] + ) + + assert result.runtime_bound_roles == frozenset({"worker", "auditor"}) diff --git a/tests/execution/backends/_conformance_assertions.py b/tests/execution/backends/_conformance_assertions.py index 85a9bef140..9d479f6eb2 100644 --- a/tests/execution/backends/_conformance_assertions.py +++ b/tests/execution/backends/_conformance_assertions.py @@ -343,6 +343,7 @@ def _expected_native_roles( semantic_plan: SkillSemanticPlan | None, semantic_adaptation: SkillSemanticAdaptationResult | None, runtime_cardinalities: Mapping[str, int] | None, + runtime_native_roles: Mapping[str, str] | None, ) -> tuple[str, ...]: assert (semantic_plan is None) == (semantic_adaptation is None), ( "semantic plan and adaptation must be supplied together" @@ -360,9 +361,17 @@ def _expected_native_roles( else: assert spawn.count is not None cardinality = spawn.count - expected_roles.extend( - semantic_adaptation.logical_role_mapping[spawn.role] for _ in range(cardinality) - ) + if spawn.role in semantic_adaptation.runtime_bound_roles: + native_role = ( + runtime_native_roles.get(spawn.role) if runtime_native_roles is not None else None + ) + assert isinstance(native_role, str) and native_role.strip(), ( + f"runtime-bound role {spawn.role!r} requires an independently supplied " + "native role name" + ) + else: + native_role = semantic_adaptation.logical_role_mapping[spawn.role] + expected_roles.extend(native_role for _ in range(cardinality)) return tuple(expected_roles) @@ -649,6 +658,7 @@ def assert_generated_child_delivery( semantic_plan: SkillSemanticPlan | None = None, semantic_adaptation: SkillSemanticAdaptationResult | None = None, runtime_cardinalities: Mapping[str, int] | None = None, + runtime_native_roles: Mapping[str, str] | None = None, child_terminal_sentinel: str | None = None, sibling_result_sentinel: str | None = None, parent_terminal_sentinel: str | None = None, @@ -657,7 +667,8 @@ def assert_generated_child_delivery( This is the sole oracle for both deterministic adapter traces and the installed Codex native-subagent probe. Raw backend events are normalized locally so the - semantic assertions remain backend-neutral. + semantic assertions remain backend-neutral. Runtime-bound native targets come from + ``runtime_native_roles``, independently of observed calls. """ observed = _collect_observed_calls(parent_events) @@ -668,6 +679,7 @@ def assert_generated_child_delivery( semantic_plan, semantic_adaptation, runtime_cardinalities, + runtime_native_roles, ) spawn_calls, actual_roles = _assert_native_role_policies( observed, diff --git a/tests/execution/backends/test_join_conformance_traces.py b/tests/execution/backends/test_join_conformance_traces.py index b2d0f1b31c..74542d578c 100644 --- a/tests/execution/backends/test_join_conformance_traces.py +++ b/tests/execution/backends/test_join_conformance_traces.py @@ -503,6 +503,7 @@ def test_codex_required_join_refused_at_admission() -> None: assert adaptation.canonical_payload == { "instruction_fragments": (), "logical_role_mapping": {}, + "runtime_bound_roles": [], "sibling_skill_targets": {}, "model_effort_policy": {}, "unsupported_operation": "required_join", diff --git a/tests/execution/backends/test_runtime_bound_role_adaptation.py b/tests/execution/backends/test_runtime_bound_role_adaptation.py new file mode 100644 index 0000000000..153c0280b5 --- /dev/null +++ b/tests/execution/backends/test_runtime_bound_role_adaptation.py @@ -0,0 +1,192 @@ +"""Runtime-selected logical roles stay unbound until Claude launches a child.""" + +from __future__ import annotations + +import json +import re +from dataclasses import replace + +import pytest + +from autoskillit.core import ( + ChildSpawnSpec, + JoinSpec, + LogicalRoleSpec, + SkillSemanticOperation, + SkillSemanticPlan, + pkg_root, +) +from autoskillit.execution.backends import ClaudeCodeBackend, CodexBackend + +_RUNTIME_ROLE = "evaluated-agent" +_STATIC_ROLE = "delegated-worker" +_RUNTIME_NATIVE_AGENT = "autoskillit:pr-review-auditor-baseline" +_DISCIPLINE_DIGEST = "runtime-bound-role-test-discipline" + + +def _runtime_bound_plan(*, runtime_only: bool = False) -> SkillSemanticPlan: + roles = ( + LogicalRoleSpec( + name=_RUNTIME_ROLE, + purpose="evaluate the selected agent definition", + runtime_bound=True, + ), + ) + spawns = (ChildSpawnSpec(role=_RUNTIME_ROLE, count=1),) + if not runtime_only: + roles += (LogicalRoleSpec(name=_STATIC_ROLE, purpose="collect evidence"),) + spawns += (ChildSpawnSpec(role=_STATIC_ROLE, count=1),) + return SkillSemanticPlan( + schema_version=1, + logical_roles=roles, + child_spawns=spawns, + join=JoinSpec(required=False), + ) + + +def test_claude_renders_runtime_bound_role_in_plugin_namespace() -> None: + from autoskillit.core.plugins import CLAUDE_PLUGIN_AGENT_NAMESPACE + + plan = _runtime_bound_plan() + adaptation = ClaudeCodeBackend().adapt_skill_semantics(plan) + + assert adaptation.unsupported_operation is None + assert adaptation.runtime_bound_roles == frozenset({_RUNTIME_ROLE}) + assert _RUNTIME_ROLE not in adaptation.logical_role_mapping + assert any( + "bound at runtime to a bundled agent definition" in fragment + for fragment in adaptation.instruction_fragments + ) + assert any( + f"subagent_type set to {CLAUDE_PLUGIN_AGENT_NAMESPACE!r}" in fragment + for fragment in adaptation.instruction_fragments + ) + assert not any( + re.search(r"subagent_type=['\"]evaluated-agent", fragment) + for fragment in adaptation.instruction_fragments + ) + + +def test_codex_refuses_runtime_bound_role_and_required_join_takes_precedence() -> None: + plan = _runtime_bound_plan() + adaptation = CodexBackend().adapt_skill_semantics(plan) + + assert adaptation.unsupported_operation is SkillSemanticOperation.CHILD_SPAWN + assert adaptation.diagnostic is not None + assert "runtime-selected bundled agent" in adaptation.diagnostic + assert adaptation.instruction_fragments == () + + required_join = CodexBackend().adapt_skill_semantics( + replace(plan, join=JoinSpec(required=True)) + ) + assert required_join.unsupported_operation is SkillSemanticOperation.REQUIRED_JOIN + assert required_join.instruction_fragments == () + + +def test_codex_mapping_excludes_runtime_bound_roles() -> None: + from autoskillit.execution.backends.codex import _codex_logical_role_mapping + + assert _codex_logical_role_mapping(_runtime_bound_plan()) == {_STATIC_ROLE: "worker"} + + +def test_claude_plugin_agent_namespace_matches_manifest() -> None: + from autoskillit.core.plugins import CLAUDE_PLUGIN_AGENT_NAMESPACE + + manifest = json.loads( + (pkg_root() / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + ) + + assert CLAUDE_PLUGIN_AGENT_NAMESPACE == manifest["name"] + ":" + + +def test_exploration_renderer_shares_plugin_agent_namespace() -> None: + from autoskillit.core.plugins import CLAUDE_PLUGIN_AGENT_NAMESPACE + from autoskillit.execution.backends._explorer_dispatch import ( + CLAUDE_EXPLORATION_DISPATCH_RENDERER, + ) + + assert ( + CLAUDE_EXPLORATION_DISPATCH_RENDERER.conventions.role_prefix + == CLAUDE_PLUGIN_AGENT_NAMESPACE + ) + + +def _claude_trace(native_roles: tuple[str, ...]) -> list[dict]: + calls = [ + { + "type": "tool_use", + "id": f"child-{index}", + "name": "Agent", + "input": {"subagent_type": native_role}, + } + for index, native_role in enumerate(native_roles) + ] + results = [ + { + "type": "tool_result", + "tool_use_id": f"child-{index}", + "content": "child-delivery-complete", + } + for index, _native_role in enumerate(native_roles) + ] + if not calls: + return [] + return [ + {"type": "assistant", "message": {"content": calls}}, + {"type": "user", "message": {"content": results}}, + ] + + +def test_runtime_bound_conformance_uses_supplied_native_name_and_declared_count() -> None: + from tests.execution.backends._conformance_assertions import ( + assert_generated_child_delivery, + ) + + plan = _runtime_bound_plan(runtime_only=True) + adaptation = ClaudeCodeBackend().adapt_skill_semantics(plan) + + assert_generated_child_delivery( + _claude_trace((_RUNTIME_NATIVE_AGENT,)), + [], + parent_id="parent", + agent_role="unused-with-semantic-plan", + output_discipline_digest=_DISCIPLINE_DIGEST, + backend="claude", + semantic_plan=plan, + semantic_adaptation=adaptation, + runtime_native_roles={_RUNTIME_ROLE: _RUNTIME_NATIVE_AGENT}, + child_terminal_sentinel="child-delivery-complete", + ) + + +@pytest.mark.parametrize( + ("native_roles", "failure"), + [ + ((), "expected 1 native child calls"), + (("autoskillit:plan-foundation-auditor",), "native role mapping mismatch"), + ], + ids=("missing-child", "wrong-native-name"), +) +def test_runtime_bound_conformance_rejects_missing_or_wrong_child( + native_roles: tuple[str, ...], + failure: str, +) -> None: + from tests.execution.backends._conformance_assertions import ( + assert_generated_child_delivery, + ) + + plan = _runtime_bound_plan(runtime_only=True) + adaptation = ClaudeCodeBackend().adapt_skill_semantics(plan) + + with pytest.raises(AssertionError, match=failure): + assert_generated_child_delivery( + _claude_trace(native_roles), + [], + parent_id="parent", + agent_role="unused-with-semantic-plan", + output_discipline_digest=_DISCIPLINE_DIGEST, + backend="claude", + semantic_plan=plan, + semantic_adaptation=adaptation, + runtime_native_roles={_RUNTIME_ROLE: _RUNTIME_NATIVE_AGENT}, + ) diff --git a/tests/execution/test_skill_session_contract_store.py b/tests/execution/test_skill_session_contract_store.py index df3ec84016..467514c5ae 100644 --- a/tests/execution/test_skill_session_contract_store.py +++ b/tests/execution/test_skill_session_contract_store.py @@ -22,7 +22,7 @@ def test_exploration_vector_contract_versions_invalidate_stale_artifacts() -> None: from autoskillit.core import SKILL_SESSION_CONTRACT_SCHEMA_VERSION - assert SKILL_PROJECTION_VERSION == 10 + assert SKILL_PROJECTION_VERSION == 11 assert SKILL_SESSION_CONTRACT_SCHEMA_VERSION == 5 @@ -476,13 +476,13 @@ def test_stale_projection_version_rejected_before_enum_construction(tmp_path: Pa store.finalize(correlation_key, "stale-projection") -def test_store_load_rejects_an_otherwise_valid_finalized_v8_contract(tmp_path: Path) -> None: - """A finalized pre-v9 snapshot cannot resume through the real load path.""" +def test_store_load_rejects_an_otherwise_valid_finalized_v10_contract(tmp_path: Path) -> None: + """A snapshot predating runtime agent bindings cannot resume.""" from autoskillit.execution.session import DefaultSkillSessionContractStore from autoskillit.execution.session._skill_session_contract_codec import _digest_json text = "projected\n" - session_id = "finalized-v8" + session_id = "finalized-v10" store = DefaultSkillSessionContractStore(root=tmp_path / "contracts") correlation_key = store.create_provisional( contract=_contract(tmp_path, text), @@ -492,13 +492,13 @@ def test_store_load_rejects_an_otherwise_valid_finalized_v8_contract(tmp_path: P entry = store._session_path(session_id) manifest = store._read_manifest(entry) contract_data = manifest["contract"] - contract_data["projection_version"] = 8 + contract_data["projection_version"] = 10 manifest["contract_digest"] = _digest_json(contract_data) store._write_manifest(entry, manifest) with pytest.raises( ValueError, - match=f"unsupported projection_version 8; expected {SKILL_PROJECTION_VERSION}", + match=f"unsupported projection_version 10; expected {SKILL_PROJECTION_VERSION}", ): store.load(session_id) diff --git a/tests/fakes.py b/tests/fakes.py index 19bbef5e3a..59fe9260b6 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -95,12 +95,13 @@ def adapt_test_skill_semantics( adaptation_context: SemanticAdaptationContext | None = None, ) -> SkillSemanticAdaptationResult: """Return a deterministic protocol-complete adaptation for backend test doubles.""" - logical_roles = {role.name: role.name for role in plan.logical_roles} + logical_roles = {role.name: role.name for role in plan.logical_roles if not role.runtime_bound} result = SkillSemanticAdaptationResult( instruction_fragments=("Use deterministic test-native skill semantics.",) if plan.operations else (), logical_role_mapping=logical_roles, + runtime_bound_roles=plan.runtime_bound_role_names, sibling_skill_targets={ sibling.name: f"${sibling.name}" for sibling in plan.sibling_skills }, diff --git a/tests/server/test_managed_fixed_batch.py b/tests/server/test_managed_fixed_batch.py index ff26af24e6..3ab4442f90 100644 --- a/tests/server/test_managed_fixed_batch.py +++ b/tests/server/test_managed_fixed_batch.py @@ -18,6 +18,7 @@ from autoskillit.hooks._join_ledger import active_batch, aggregate_batch, can_release_stop from autoskillit.hooks._session_binding import LoadedSkillEntry, LoadedSkillOrigin from autoskillit.pipeline import DefaultBackgroundSupervisor +from autoskillit.server.tools.tools_execution._fixed_batch_request import _validate_membership from autoskillit.server.tools.tools_execution._managed_fixed_batch import ( DefaultManagedFixedBatchSupervisor, ManagedFixedBatchLaunchBinding, @@ -96,6 +97,47 @@ def _binding(tmp_path, launch_leaf): ) +def _membership_source(role: str) -> LoadedSkillEntry: + return LoadedSkillEntry( + skill_name="fixed-batch-skill", + ts="2026-08-28T00:00:00Z", + join_required=True, + child_spawn_cardinality={role: 1}, + semantic_digest="semantic-source", + adaptation_digest="source-adaptation", + projected_digest="projected-source", + canonical_digest="canonical-source", + source_artifact_digest="source-artifact", + source_artifact_incarnation_id="incarnation-1", + binding_valid=True, + binding_error=None, + origin=LoadedSkillOrigin.AUTOSKILLIT, + ) + + +def test_fixed_batch_membership_rejects_runtime_only_role() -> None: + role = "evaluated-agent" + assignments = (ManagedLeafAssignmentInput(role, "evaluation", "Evaluate the result."),) + adaptation = SkillSemanticAdaptationResult( + logical_role_mapping={}, + runtime_bound_roles=frozenset({role}), + ) + + with pytest.raises(SkillContractError, match="roles absent from source adaptation"): + _validate_membership(assignments, _membership_source(role), adaptation) + + +def test_fixed_batch_membership_keeps_roleless_adaptation_permissive() -> None: + role = "legacy-role" + assignments = (ManagedLeafAssignmentInput(role, "legacy", "Run the legacy assignment."),) + + _validate_membership( + assignments, + _membership_source(role), + SkillSemanticAdaptationResult(), + ) + + @pytest.mark.anyio async def test_supervisor_opens_once_replays_and_releases_each_owned_permit(tmp_path) -> None: capacity = DefaultManagedWorkerCapacity(max_concurrent=1) diff --git a/tests/server/test_managed_leaf.py b/tests/server/test_managed_leaf.py index c554a109e4..efea5fb03f 100644 --- a/tests/server/test_managed_leaf.py +++ b/tests/server/test_managed_leaf.py @@ -39,6 +39,7 @@ ) from autoskillit.server.tools.tools_execution._managed_fixed_batch import ManagedLaunchBinding from autoskillit.server.tools.tools_execution._managed_leaf import ( + ManagedLeafAssignmentIdentity, ManagedLeafAssignmentInput, ManagedLeafBinding, ManagedLeafProjection, @@ -57,6 +58,91 @@ _SYNTHETIC_MODEL_ID = "gpt-synthetic" +def _managed_leaf_binding_inputs( + role: str, + adaptation: SkillSemanticAdaptationResult, + *, + declared_role: str, +) -> tuple[ManagedLeafAssignmentIdentity, LoadedSkillEntry, AgentSkillDocument]: + assignment = plan_managed_leaf_identities( + "request-1", + (ManagedLeafAssignmentInput(role, "assignment", "Inspect the requested work."),), + ).assignments[0] + selected_source = LoadedSkillEntry( + skill_name="membership-skill", + ts="2026-08-28T00:00:00Z", + join_required=True, + child_spawn_cardinality={declared_role: 1}, + semantic_digest="semantic-source", + adaptation_digest=adaptation.digest, + projected_digest="projected-source", + canonical_digest="canonical-source", + source_artifact_digest="source-artifact", + source_artifact_incarnation_id="incarnation-1", + binding_valid=True, + binding_error=None, + origin=LoadedSkillOrigin.AUTOSKILLIT, + ) + source_document = AgentSkillDocument( + content="Source contract.\n", + projected_digest="projected-source", + canonical_digest="canonical-source", + source_identity=SkillSourceIdentity(SkillSource.BUNDLED, "membership-skill"), + semantic_digest="semantic-source", + adaptation_digest=adaptation.digest, + ) + return assignment, selected_source, source_document + + +@pytest.mark.parametrize( + "role", + ["evaluated-agent", "unlisted-role"], +) +def test_managed_leaf_rejects_runtime_only_and_undeclared_roles(role: str) -> None: + adaptation = SkillSemanticAdaptationResult( + logical_role_mapping={}, + runtime_bound_roles=frozenset({"evaluated-agent"}), + ) + assignment, selected_source, source_document = _managed_leaf_binding_inputs( + role, + adaptation, + declared_role="evaluated-agent", + ) + + with pytest.raises(SkillContractError, match="is not declared by source"): + bind_managed_leaf( + assignment=assignment, + selected_source=selected_source, + source_document=source_document, + adaptation=adaptation, + default_model=_SYNTHETIC_MODEL_ID, + write_behavior=WriteBehaviorSpec(), + read_only=True, + ) + + +def test_managed_leaf_keeps_roleless_adaptation_permissive() -> None: + role = "legacy-role" + adaptation = SkillSemanticAdaptationResult() + assignment, selected_source, source_document = _managed_leaf_binding_inputs( + role, + adaptation, + declared_role=role, + ) + + binding = bind_managed_leaf( + assignment=assignment, + selected_source=selected_source, + source_document=source_document, + adaptation=adaptation, + default_model=_SYNTHETIC_MODEL_ID, + write_behavior=WriteBehaviorSpec(), + read_only=True, + ) + + assert binding.model == _SYNTHETIC_MODEL_ID + + def test_managed_leaf_planner_and_projection_bind_only_leaf_authority() -> None: assignments = ( ManagedLeafAssignmentInput( diff --git a/tests/workspace/test_skill_semantics.py b/tests/workspace/test_skill_semantics.py index 331068d40f..f2c248f6c6 100644 --- a/tests/workspace/test_skill_semantics.py +++ b/tests/workspace/test_skill_semantics.py @@ -2,6 +2,7 @@ from __future__ import annotations +from importlib import import_module from pathlib import Path from types import SimpleNamespace @@ -43,6 +44,21 @@ - purpose: create the requested commit """ +_RUNTIME_BOUND_SEMANTICS = """semantic_version: 1 +semantic_requirements: + logical_roles: + - name: evaluated-agent + purpose: select a bundled evaluator at runtime + runtime_bound: true + - name: delegated-worker + purpose: perform one portable review + child_spawns: + - role: evaluated-agent + count: 1 + - role: delegated-worker + count: 1 +""" + _REPO_ROOT = Path(__file__).resolve().parents[2] _DECLARED_SKILL_ROOTS = ( pkg_root() / "skills", @@ -135,6 +151,44 @@ def _write_skill(path: Path, *, declarations: str = _VALID_SEMANTICS, body: str ) +def _parse_semantic_plan(path: Path): + from autoskillit.workspace.skill_capabilities import parse_skill_semantic_plan + + parsed = read_skill_frontmatter(path) + assert parsed.data is not None + return parse_skill_semantic_plan( + parsed.data, + path=path, + content=parsed.content, + uses_capabilities=frozenset(parsed.data.get("uses_capabilities", ())), + ) + + +def test_runtime_bound_role_parses(tmp_path: Path) -> None: + skill_md = tmp_path / "runtime-bound" / "SKILL.md" + _write_skill(skill_md, declarations=_RUNTIME_BOUND_SEMANTICS) + + plan, diagnostics = _parse_semantic_plan(skill_md) + + assert not diagnostics + assert plan is not None + assert plan.logical_roles[0].runtime_bound is True + + +def test_non_boolean_runtime_bound_is_plan_invalid(tmp_path: Path) -> None: + skill_md = tmp_path / "invalid-runtime-bound" / "SKILL.md" + declarations = _RUNTIME_BOUND_SEMANTICS.replace( + "runtime_bound: true", "runtime_bound: sometimes" + ) + _write_skill(skill_md, declarations=declarations) + + plan, diagnostics = _parse_semantic_plan(skill_md) + + assert plan is None + assert len(diagnostics) == 1 + assert diagnostics[0][0].value == "semantic_plan_invalid" + + @pytest.mark.parametrize( ("relative_path", "origin"), [ @@ -452,6 +506,37 @@ def test_session_catalog_filters_unsupported_semantics_with_exact_metadata( } +def test_runtime_bound_spawns_have_no_static_native_target(tmp_path: Path) -> None: + from autoskillit.core import SkillExecutionRole, SkillSemanticOperation + from autoskillit.workspace import ( + EffectiveSkillCatalog, + SkillCatalogEntry, + compile_session_skill_catalog, + ) + + backends = import_module("autoskillit.execution.backends") + skill_path = tmp_path / "runtime-bound" / "SKILL.md" + _write_skill(skill_path, declarations=_RUNTIME_BOUND_SEMANTICS) + entry = SkillCatalogEntry.from_skill_info( + _skill_info_from_frontmatter("runtime-bound", SkillSource.PROJECT_LOCAL, skill_path) + ) + catalog = EffectiveSkillCatalog( + skills=(entry,), + execution_role=SkillExecutionRole.SESSION, + ) + + claude = compile_session_skill_catalog(catalog, backends.ClaudeCodeBackend()) + assert tuple(skill.name for skill in claude.catalog.skills) == ("runtime-bound",) + assert claude.required_native_roles["runtime-bound"] == ("delegated-worker",) + assert "evaluated-agent" not in claude.required_native_roles["runtime-bound"] + + codex = compile_session_skill_catalog(catalog, backends.CodexBackend()) + assert not codex.catalog.skills + assert len(codex.unavailable) == 1 + assert codex.unavailable[0].skill == "runtime-bound" + assert codex.unavailable[0].operation is SkillSemanticOperation.CHILD_SPAWN + + def test_session_catalog_propagates_unexpected_adapter_contract_error( tmp_path: Path, ) -> None: From 2b02fd6e1cc1c26f48ceedcbcc83a1538aa37dd3 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 18:35:40 -0700 Subject: [PATCH 02/11] test: isolate hook and launch state from shared ancestors --- tests/cli/test_managed_launch_composition.py | 1 + tests/hooks/test_session_binding_channel.py | 1 + tests/infra/test_skill_load_guard.py | 14 +++++++++++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/cli/test_managed_launch_composition.py b/tests/cli/test_managed_launch_composition.py index a3ff9a83b6..84cfc0b04d 100644 --- a/tests/cli/test_managed_launch_composition.py +++ b/tests/cli/test_managed_launch_composition.py @@ -69,6 +69,7 @@ def managed_launch_case( use_bundled_catalog(monkeypatch, raw_catalog) project = tmp_path / "project" project.mkdir() + (project / ".autoskillit").mkdir() backend = CodexBackend(source_codex_home=source_home) context = prepare_managed_join_context( backend=backend, diff --git a/tests/hooks/test_session_binding_channel.py b/tests/hooks/test_session_binding_channel.py index cb1783a0e8..4dd59ee5df 100644 --- a/tests/hooks/test_session_binding_channel.py +++ b/tests/hooks/test_session_binding_channel.py @@ -329,6 +329,7 @@ def test_binding_path_ignores_a_relative_payload_cwd( process_cwd = tmp_path / "process-cwd" process_cwd.mkdir() + (process_cwd / ".autoskillit").mkdir() monkeypatch.chdir(process_cwd) assert resolve_binding_path("relative/worktree", "session-1").parent == ( diff --git a/tests/infra/test_skill_load_guard.py b/tests/infra/test_skill_load_guard.py index 0d8d2d16e3..ae8b9e493e 100644 --- a/tests/infra/test_skill_load_guard.py +++ b/tests/infra/test_skill_load_guard.py @@ -26,6 +26,7 @@ def _run_guard( applicable_guards: str | None = None, agent_backend: str | None = None, project_root: Path | None = None, + create_project_marker: bool = True, ) -> str: """Run skill_load_guard.main(), return stdout.""" from autoskillit.hooks.guards.skill_load_guard import main @@ -70,6 +71,8 @@ def _run_guard( base_env.update(env_updates) isolated_project_root = project_root if project_root is not None else tmp_dir + if create_project_marker: + (isolated_project_root / ".autoskillit").mkdir(parents=True, exist_ok=True) base_env["AUTOSKILLIT_STATE_ROOT"] = str(isolated_project_root.resolve()) with ( @@ -291,9 +294,17 @@ def test_flag_found_via_ancestor_walk_when_cwd_is_subdirectory(tmp_path): assert not out.strip() -def test_denies_when_no_autoskillit_dir_in_ancestors(tmp_path): +def test_denies_when_no_autoskillit_dir_in_ancestors(tmp_path, monkeypatch): """T2-14: Deny when no .autoskillit/ found in any ancestor (fallback to CWD).""" bare_dir = tmp_path / "bare" / "dir" + real_is_dir = Path.is_dir + + def isolated_is_dir(path): + if path.name == ".autoskillit" and not path.is_relative_to(bare_dir): + return False + return real_is_dir(path) + + monkeypatch.setattr(Path, "is_dir", isolated_is_dir) out = _run_guard( _make_event("Read"), tmp_dir=bare_dir, @@ -301,6 +312,7 @@ def test_denies_when_no_autoskillit_dir_in_ancestors(tmp_path): headless=True, session_type="skill", applicable_guards="skill_load_guard", + create_project_marker=False, ) response = json.loads(out) assert response["hookSpecificOutput"]["permissionDecision"] == "deny" From da46c425bbcbfd6dd9786e347f3ec5517a8159d6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 18:48:37 -0700 Subject: [PATCH 03/11] test: register the child semantic specification module --- tests/_test_filter.py | 3 +++ tests/arch/test_subpackage_structure.py | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index a0d3217bf3..3f7c359ba3 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -512,6 +512,9 @@ class SourceMapRejection(enum.StrEnum): "workspace", } ), + "_type_skill_plan_specs": frozenset( + {"cli", "core", "execution", "fleet", "migration", "pipeline", "server", "workspace"} + ), "_type_skill_semantics": frozenset( {"cli", "core", "execution", "fleet", "migration", "pipeline", "server", "workspace"} ), diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 08a72afa52..0a0ed06aca 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -30,7 +30,9 @@ } ), "github": frozenset({"_type_github_review", "_type_github_review_anchor"}), - "skill": frozenset({"_type_skill_semantics", "_type_session_invariant_admission"}), + "skill": frozenset( + {"_type_skill_plan_specs", "_type_skill_semantics", "_type_session_invariant_admission"} + ), "audit": frozenset( { "_type_audit_admission", From 5704eae985737fcad209eb8f8e4c426842af2796 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 19:00:40 -0700 Subject: [PATCH 04/11] test: include child specifications in the cascade inventory --- tests/test_test_filter_core_cascade.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index 08406e56d0..9714f4e5ba 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -132,6 +132,7 @@ def test_all_entries_present(self) -> None: "_type_helpers", "_type_protocols_workspace", "_type_protocols_backend", + "_type_skill_plan_specs", "_type_skill_semantics", "_install_detect", "_release_identity", From 12297d29ae50028f7944a86e16bcc5fe06e9991e Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:00:23 -0700 Subject: [PATCH 05/11] fix: resolve Claude child roles against native agent names --- src/autoskillit/core/__init__.pyi | 4 + src/autoskillit/core/plugins/__init__.py | 4 + .../core/plugins/agent_definition.py | 14 +- .../_type_constants_skill_contract.py | 10 ++ .../core/types/foundation/_type_enums.py | 1 + .../core/types/foundation/_type_exceptions.py | 5 + .../core/types/launch/_type_skill_contract.py | 2 +- .../protocols/_type_protocols_backend.py | 7 +- .../types/skill/_type_skill_plan_specs.py | 15 ++- .../core/types/skill/_type_skill_semantics.py | 2 + .../execution/backends/__init__.py | 4 + .../execution/backends/_native_roles.py | 51 +++++++ src/autoskillit/execution/backends/claude.py | 36 +++-- src/autoskillit/execution/backends/codex.py | 18 +-- src/autoskillit/migration/adapters_skill.py | 23 ++++ .../analyze-pipeline-health/SKILL.md | 4 +- .../skills_extended/investigate/SKILL.md | 4 +- .../skills_extended/review-approach/SKILL.md | 4 +- .../_installed/_projection_assets.py | 4 +- .../_projected_artifact/_publication.py | 5 +- .../session_skills/_materialization.py | 8 +- .../skill_capabilities/_semantic_plan.py | 14 ++ tests/_ambient_env_surface.py | 2 +- .../test_backend_protocol_completeness.py | 11 ++ tests/contracts/_projection_helpers.py | 24 ++++ .../legacy_namespaced_role.md | 15 +++ .../test_agent_tool_surface_alignment.py | 55 ++++++++ .../test_analyze_pipeline_health_contracts.py | 2 +- tests/contracts/test_backend_protocol.py | 31 +---- .../test_native_spawn_target_resolution.py | 95 +++++++++++++ .../test_skill_contract_remediations.py | 3 + .../test_skill_semantic_authenticity.py | 35 +++-- tests/core/test_agent_definition.py | 42 ++++++ tests/core/test_backend_protocols.py | 4 +- tests/core/test_skill_semantic_plan.py | 37 +++++ .../backends/test_claude_code_backend.py | 88 +++++++++++- .../test_codex_managed_route_live_gate.py | 2 +- .../backends/test_join_conformance_traces.py | 8 +- tests/execution/backends/test_native_roles.py | 61 +++++++++ .../test_skill_semantic_trace_conformance.py | 21 ++- tests/execution/conftest.py | 2 +- .../test_skill_session_contract_store.py | 12 +- tests/migration/test_engine_skill.py | 88 ++++++++++++ ...t_review_approach_web_evidence_contract.py | 4 +- .../test_explorer_adoption_inventory.py | 2 +- tests/workspace/_helpers.py | 15 ++- .../test_claude_native_role_admission.py | 127 ++++++++++++++++++ .../test_claude_projected_spawn_targets.py | 63 +++++++++ tests/workspace/test_corridor_composition.py | 3 +- .../test_session_skills_codex_layout.py | 17 +-- .../workspace/test_session_skills_provider.py | 8 +- .../test_session_skills_stale_path.py | 8 +- tests/workspace/test_skill_semantics.py | 20 ++- 53 files changed, 1013 insertions(+), 131 deletions(-) create mode 100644 src/autoskillit/execution/backends/_native_roles.py create mode 100644 tests/contracts/fixtures/skill_contract_corpus/legacy_namespaced_role.md create mode 100644 tests/contracts/test_native_spawn_target_resolution.py create mode 100644 tests/execution/backends/test_native_roles.py create mode 100644 tests/workspace/test_claude_native_role_admission.py create mode 100644 tests/workspace/test_claude_projected_spawn_targets.py diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index a26cad2a63..81a5a13e27 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -308,6 +308,7 @@ from .plugins._retiring_cache import repair_corrupt_retiring_cache as repair_cor from .plugins.agent_definition import ( AGENT_DEFINITION_DIGEST_DOMAIN as AGENT_DEFINITION_DIGEST_DOMAIN, ) +from .plugins.agent_definition import AGENT_DIRECTORY_GUIDE_FILES as AGENT_DIRECTORY_GUIDE_FILES from .plugins.agent_definition import AGENT_PROVISIONING_BASELINE as AGENT_PROVISIONING_BASELINE from .plugins.agent_definition import ( AGENT_PROVISIONING_SKILL_DERIVED as AGENT_PROVISIONING_SKILL_DERIVED, @@ -329,6 +330,7 @@ from .plugins.agent_definition import agent_definition_digest as agent_definitio from .plugins.agent_definition import ( canonical_reader_tools_to_bare as canonical_reader_tools_to_bare, ) +from .plugins.agent_definition import is_agent_definition_file as is_agent_definition_file from .plugins.agent_definition import load_agent_definition as load_agent_definition from .plugins.agent_definition import load_agent_definitions as load_agent_definitions from .plugins.agent_definition import ( @@ -452,6 +454,7 @@ from .types import AGENT_BACKEND_CLAUDE_CODE as AGENT_BACKEND_CLAUDE_CODE from .types import AGENT_BACKEND_CODEX as AGENT_BACKEND_CODEX from .types import AGENT_BACKEND_DYNACONF_ENV_VAR as AGENT_BACKEND_DYNACONF_ENV_VAR from .types import AGENT_BACKEND_ENV_VAR as AGENT_BACKEND_ENV_VAR +from .types import AGENT_NAME_PATTERN as AGENT_NAME_PATTERN from .types import AGENT_PACK_REGISTRY as AGENT_PACK_REGISTRY from .types import ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS as ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS from .types import ALL_SESSION_SHAPES as ALL_SESSION_SHAPES @@ -1057,6 +1060,7 @@ from .types import LineDriver as LineDriver from .types import LineDriverError as LineDriverError from .types import LoadReport as LoadReport from .types import LoadResult as LoadResult +from .types import LogicalRoleNameError as LogicalRoleNameError from .types import LogicalRoleSpec as LogicalRoleSpec from .types import MaintenanceInstallArgv as MaintenanceInstallArgv from .types import ManagedCodexRoute as ManagedCodexRoute diff --git a/src/autoskillit/core/plugins/__init__.py b/src/autoskillit/core/plugins/__init__.py index 9948803642..6f3bc0ff75 100644 --- a/src/autoskillit/core/plugins/__init__.py +++ b/src/autoskillit/core/plugins/__init__.py @@ -96,6 +96,7 @@ ) from autoskillit.core.plugins.agent_definition import ( AGENT_DEFINITION_DIGEST_DOMAIN, + AGENT_DIRECTORY_GUIDE_FILES, AGENT_PROVISIONING_BASELINE, AGENT_PROVISIONING_SKILL_DERIVED, BUNDLED_EXPLORER_ROLES, @@ -109,6 +110,7 @@ CodexAgentProjectionDef, agent_definition_digest, canonical_reader_tools_to_bare, + is_agent_definition_file, load_agent_definition, load_agent_definitions, load_bundled_agent_definitions, @@ -117,6 +119,7 @@ __all__ = [ "AGENT_DEFINITION_DIGEST_DOMAIN", + "AGENT_DIRECTORY_GUIDE_FILES", "AGENT_PROVISIONING_BASELINE", "AGENT_PROVISIONING_SKILL_DERIVED", "ActiveKitchensReadResult", @@ -184,6 +187,7 @@ "installed_plugin_artifact_root", "installed_plugin_cache_dir", "installed_plugin_semantic_key", + "is_agent_definition_file", "is_marketplace_plugin_registered", "is_reclaimable_artifact_path", "kitchen_entry_alive", diff --git a/src/autoskillit/core/plugins/agent_definition.py b/src/autoskillit/core/plugins/agent_definition.py index 3128a48b55..254b400afb 100644 --- a/src/autoskillit/core/plugins/agent_definition.py +++ b/src/autoskillit/core/plugins/agent_definition.py @@ -12,6 +12,7 @@ from ..io import load_yaml from ..io.paths import pkg_root from ..types import ( + AGENT_NAME_PATTERN, CODEX_EFFORT_MAPPING, CODEX_MODEL_ALIASES, CODEX_VALID_MODEL_IDS, @@ -27,6 +28,7 @@ __all__ = [ "AGENT_DEFINITION_DIGEST_DOMAIN", + "AGENT_DIRECTORY_GUIDE_FILES", "AGENT_PROVISIONING_BASELINE", "AGENT_PROVISIONING_SKILL_DERIVED", "BUNDLED_EXPLORER_ROLES", @@ -40,6 +42,7 @@ "CodexAgentProjectionDef", "agent_definition_digest", "canonical_reader_tools_to_bare", + "is_agent_definition_file", "load_agent_definition", "load_agent_definitions", "load_bundled_agent_definitions", @@ -48,6 +51,7 @@ AGENT_DEFINITION_DIGEST_DOMAIN = "autoskillit.agent-definition.v1" +AGENT_DIRECTORY_GUIDE_FILES: Final = frozenset({"AGENTS.md", "CLAUDE.md"}) AGENT_PROVISIONING_BASELINE: Final = "baseline" AGENT_PROVISIONING_SKILL_DERIVED: Final = "skill-derived" CODEX_DISABLED_WEB_SEARCH_POLICY: Literal["disabled"] = "disabled" @@ -58,7 +62,6 @@ BUNDLED_EXPLORER_ROLES: frozenset[str] = frozenset( {SEMANTIC_CODE_NAVIGATOR_ROLE, REPOSITORY_IMPACT_PROFILER_ROLE} ) -_AGENT_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$") _DIRECT_TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") _CODEX_CLI_VERSION_RE = re.compile(r"(?:codex-cli )?(?P[0-9]+\.[0-9]+\.[0-9]+)") _READ_ONLY_AGENT_TOOLS = frozenset({"Read", "Grep", "Glob", "LSP"}) @@ -199,7 +202,7 @@ class AgentDef: provisioning: Literal["skill-derived", "baseline"] = AGENT_PROVISIONING_SKILL_DERIVED def __post_init__(self) -> None: - if not _AGENT_NAME_RE.fullmatch(self.name): + if not AGENT_NAME_PATTERN.fullmatch(self.name): raise AgentDefinitionError(f"invalid agent name: {self.name!r}") if not self.description.strip(): raise AgentDefinitionError("agent description must be non-empty") @@ -396,12 +399,17 @@ def load_agent_definition(path: Path) -> AgentDef: ) +def is_agent_definition_file(path: Path) -> bool: + """Return whether *path* is a Markdown agent definition rather than a guide.""" + return path.is_file() and path.suffix == ".md" and path.name not in AGENT_DIRECTORY_GUIDE_FILES + + def load_agent_definitions(agents_dir: Path) -> tuple[AgentDef, ...]: """Load the complete agent catalog and reject duplicate registered names.""" definitions = tuple( load_agent_definition(path) for path in sorted(agents_dir.glob("*.md")) - if path.name not in {"AGENTS.md", "CLAUDE.md"} + if is_agent_definition_file(path) ) names = tuple(definition.name for definition in definitions) duplicates = sorted({name for name in names if names.count(name) > 1}) diff --git a/src/autoskillit/core/types/constants/_type_constants_skill_contract.py b/src/autoskillit/core/types/constants/_type_constants_skill_contract.py index 7a91259649..9e6ef134c0 100644 --- a/src/autoskillit/core/types/constants/_type_constants_skill_contract.py +++ b/src/autoskillit/core/types/constants/_type_constants_skill_contract.py @@ -171,6 +171,16 @@ class SkillContractRemediationDef(NamedTuple): "definition is effective" ), ), + SkillContractRemediationDef( + kind=SkillInvalidityKind.SEMANTIC_ROLE_NAME_NONCANONICAL, + introduced_in="0.10.1256", + action=RemediationAction.DETERMINISTIC, + hint=( + "logical role names are bare agent names matching `^[a-z][a-z0-9-]*$`; drop any " + "`autoskillit:` namespace, because each backend adds its own native namespace. " + "Migration strips it." + ), + ), ) SKILL_CONTRACT_REMEDIATIONS: Mapping[SkillInvalidityKind, SkillContractRemediationDef] = ( MappingProxyType( diff --git a/src/autoskillit/core/types/foundation/_type_enums.py b/src/autoskillit/core/types/foundation/_type_enums.py index a7f006ddd0..ac927048cc 100644 --- a/src/autoskillit/core/types/foundation/_type_enums.py +++ b/src/autoskillit/core/types/foundation/_type_enums.py @@ -202,6 +202,7 @@ class SkillInvalidityKind(StrEnum): SEMANTIC_MISSING_VERSION = "semantic_missing_version" SEMANTIC_VERSION_MISMATCH = "semantic_version_mismatch" SEMANTIC_CHILD_CARDINALITY_INVALID = "semantic_child_cardinality_invalid" + SEMANTIC_ROLE_NAME_NONCANONICAL = "semantic_role_name_noncanonical" SEMANTIC_PLAN_INVALID = "semantic_plan_invalid" diff --git a/src/autoskillit/core/types/foundation/_type_exceptions.py b/src/autoskillit/core/types/foundation/_type_exceptions.py index 46d420457f..02cf5e3190 100644 --- a/src/autoskillit/core/types/foundation/_type_exceptions.py +++ b/src/autoskillit/core/types/foundation/_type_exceptions.py @@ -14,6 +14,7 @@ "ExplorationBindingFailed", "InfrastructureFaultError", "LineDriverError", + "LogicalRoleNameError", "PluginArtifactContentionError", "PluginArtifactPublicationError", "PluginArtifactUnavailableError", @@ -197,6 +198,10 @@ class SkillContractError(ValueError): """A skill machine contract is malformed or exceeds its execution role.""" +class LogicalRoleNameError(SkillContractError): + """A logical role name is not a bare canonical agent name.""" + + class ChildSpawnCardinalityError(SkillContractError): """A child spawn does not declare exactly one valid cardinality authority.""" diff --git a/src/autoskillit/core/types/launch/_type_skill_contract.py b/src/autoskillit/core/types/launch/_type_skill_contract.py index 384aa26e29..cd0dcef2ce 100644 --- a/src/autoskillit/core/types/launch/_type_skill_contract.py +++ b/src/autoskillit/core/types/launch/_type_skill_contract.py @@ -57,7 +57,7 @@ ) MANAGED_SKILL_FILENAME = "SKILL.md" # Static resources are now compiled into projected SKILL.md bytes. -SKILL_PROJECTION_VERSION = 11 +SKILL_PROJECTION_VERSION = 12 SKILL_SESSION_CONTRACT_SCHEMA_VERSION = 5 PARENT_SANDBOX_MODES: frozenset[str] = frozenset({"read-only", "workspace-write"}) _CANONICAL_IDENTIFIER_RE = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z") diff --git a/src/autoskillit/core/types/protocols/_type_protocols_backend.py b/src/autoskillit/core/types/protocols/_type_protocols_backend.py index 01dbc1ef56..1110566498 100644 --- a/src/autoskillit/core/types/protocols/_type_protocols_backend.py +++ b/src/autoskillit/core/types/protocols/_type_protocols_backend.py @@ -427,7 +427,12 @@ 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, - ) -> frozenset[str] | None: ... + ) -> frozenset[str]: + """Finalize the session and return the native agent names it can resolve; + + admission prunes skills whose native spawn targets fall outside this set. + """ + ... def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/core/types/skill/_type_skill_plan_specs.py b/src/autoskillit/core/types/skill/_type_skill_plan_specs.py index eda5de11ac..f177afcc26 100644 --- a/src/autoskillit/core/types/skill/_type_skill_plan_specs.py +++ b/src/autoskillit/core/types/skill/_type_skill_plan_specs.py @@ -2,14 +2,20 @@ from __future__ import annotations +import re from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final -from ..foundation._type_exceptions import ChildSpawnCardinalityError, SkillContractError +from ..foundation._type_exceptions import ( + ChildSpawnCardinalityError, + LogicalRoleNameError, + SkillContractError, +) __all__ = [ + "AGENT_NAME_PATTERN", "SKILL_MODEL_CLASS_REGISTRY", "SKILL_REASONING_EFFORTS", "ChildModelPolicySpec", @@ -43,6 +49,7 @@ class SkillModelClassDef: SKILL_REASONING_EFFORTS: frozenset[str] = frozenset({"medium", "high"}) DELEGATED_WORKER_ROLE: Final = "delegated-worker" +AGENT_NAME_PATTERN: Final = re.compile(r"^[a-z][a-z0-9-]*$") def _require_nonempty(value: str, field_name: str) -> None: @@ -120,5 +127,11 @@ def __post_init__(self) -> None: _require_nonempty(self.purpose, "logical role purpose") if type(self.runtime_bound) is not bool: raise SkillContractError("logical role runtime_bound must be a boolean") + if AGENT_NAME_PATTERN.fullmatch(self.name) is None: + raise LogicalRoleNameError( + f"logical role name {self.name!r} must match {AGENT_NAME_PATTERN.pattern}; " + f"name the bare agent definition or {DELEGATED_WORKER_ROLE!r} and let the " + "backend add its native namespace" + ) if self.runtime_bound and self.name == DELEGATED_WORKER_ROLE: raise SkillContractError("delegated worker role cannot be runtime-bound") diff --git a/src/autoskillit/core/types/skill/_type_skill_semantics.py b/src/autoskillit/core/types/skill/_type_skill_semantics.py index 9832f1f07e..0a3e2de30f 100644 --- a/src/autoskillit/core/types/skill/_type_skill_semantics.py +++ b/src/autoskillit/core/types/skill/_type_skill_semantics.py @@ -12,6 +12,7 @@ from ..foundation._type_exceptions import SkillContractError from ._type_skill_plan_specs import ( + AGENT_NAME_PATTERN, DELEGATED_WORKER_ROLE, SKILL_MODEL_CLASS_REGISTRY, SKILL_REASONING_EFFORTS, @@ -27,6 +28,7 @@ from ..protocols._type_protocols_backend import CodingAgentBackend __all__ = [ + "AGENT_NAME_PATTERN", "SKILL_MODEL_CLASS_REGISTRY", "SKILL_REASONING_EFFORTS", "SKILL_SEMANTIC_SCHEMA_VERSION", diff --git a/src/autoskillit/execution/backends/__init__.py b/src/autoskillit/execution/backends/__init__.py index ea3b02942a..adc1bd4c7d 100644 --- a/src/autoskillit/execution/backends/__init__.py +++ b/src/autoskillit/execution/backends/__init__.py @@ -54,11 +54,13 @@ ) from ._composite_locator import CompositeSessionLocator from .claude import ( + CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES, ClaudeCodeBackend, ClaudeEnvPolicy, ClaudeResultParser, ClaudeSessionLocator, ClaudeStreamParser, + claude_resolvable_agent_names, ) from .codex import ( CODEX_SKILL_DISCOVERY_CONTRACT, @@ -121,11 +123,13 @@ def all_backends() -> list[CodingAgentBackend]: "generate_codex_hooks_config", "iter_codex_hook_commands", "sync_hooks_to_codex_config", + "CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES", "ClaudeCodeBackend", "ClaudeEnvPolicy", "ClaudeResultParser", "ClaudeSessionLocator", "ClaudeStreamParser", + "claude_resolvable_agent_names", "CodexBackend", "CodexCatalogAcquisitionError", "CodexEnvPolicy", diff --git a/src/autoskillit/execution/backends/_native_roles.py b/src/autoskillit/execution/backends/_native_roles.py new file mode 100644 index 0000000000..b5e40bd04f --- /dev/null +++ b/src/autoskillit/execution/backends/_native_roles.py @@ -0,0 +1,51 @@ +"""Map declared logical roles to backend-native agent names.""" + +from __future__ import annotations + +from autoskillit.core import ( + CLAUDE_PLUGIN_AGENT_NAMESPACE, + DELEGATED_WORKER_ROLE, + SkillSemanticPlan, + load_bundled_agent_definitions, +) + +__all__ = [ + "CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES", + "claude_resolvable_agent_names", + "map_declared_logical_roles", +] + +# Common CLI/headless sessions share these; claude-code-guide is excluded from sdk-cli. +CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES = ( + "general-purpose", + "Explore", + "Plan", + "claude", + "statusline-setup", +) + + +def claude_resolvable_agent_names() -> frozenset[str]: + """Return built-in and plugin-provided agent names resolvable in Claude sessions.""" + return frozenset(CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES) | { + f"{CLAUDE_PLUGIN_AGENT_NAMESPACE}{definition.name}" + for definition in load_bundled_agent_definitions() + } + + +def map_declared_logical_roles( + plan: SkillSemanticPlan, + *, + delegated_worker_agent: str, + agent_namespace: str, +) -> dict[str, str]: + """Map each statically declared logical role to its native agent name.""" + return { + role.name: ( + delegated_worker_agent + if role.name == DELEGATED_WORKER_ROLE + else f"{agent_namespace}{role.name}" + ) + for role in plan.logical_roles + if not role.runtime_bound + } diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index d3719061c0..bf6345661e 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -104,6 +104,11 @@ from autoskillit.execution.backends._explorer_dispatch import ( CLAUDE_EXPLORATION_DISPATCH_RENDERER, ) +from autoskillit.execution.backends._native_roles import ( + CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES, + claude_resolvable_agent_names, + map_declared_logical_roles, +) log = logging.getLogger(__name__) # noqa: TID251 — stdlib fallback: used before configure_logging(); structlog proxy would emit to stderr via import-time WriteLoggerFactory _EXPLORER_BINDING_REJECTION_MESSAGE = "Claude Code does not support explorer binding projection" @@ -122,13 +127,23 @@ ) +def _claude_logical_role_mapping(plan: SkillSemanticPlan) -> dict[str, str]: + return map_declared_logical_roles( + plan, + delegated_worker_agent="general-purpose", + agent_namespace=CLAUDE_PLUGIN_AGENT_NAMESPACE, + ) + + __all__ = [ "CLAUDE_ADD_DIR_SKILLS_ROUTE", + "CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES", "ClaudeCodeBackend", "ClaudeEnvPolicy", "ClaudeResultParser", "ClaudeSessionLocator", "ClaudeStreamParser", + "claude_resolvable_agent_names", "detect_repository_agent_teams_setting", "find_malformed_agent_teams_settings", ] @@ -196,11 +211,12 @@ 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, - ) -> frozenset[str] | None: + ) -> frozenset[str]: del agent_defs, execution_role if explorer_binding_env: raise ValueError(_EXPLORER_BINDING_REJECTION_MESSAGE) - return None + # Claude publishes plugin agents/ statically, without per-session use of agent_defs. + return claude_resolvable_agent_names() def refresh_explorer_binding_env( self, @@ -592,9 +608,7 @@ def adapt_skill_semantics( "all capability-attested. Refuse the skill at admission." ), ) - role_mapping = { - role.name: role.name for role in plan.logical_roles if not role.runtime_bound - } + role_mapping = _claude_logical_role_mapping(plan) sibling_targets = { sibling.name: f"/autoskillit:{sibling.name}" for sibling in plan.sibling_skills } @@ -604,13 +618,16 @@ def adapt_skill_semantics( f"Logical role {role.name!r} is bound at runtime to a bundled agent definition: " f"{role.purpose}." if role.runtime_bound - else f"Logical role {role.name!r}: {role.purpose}." + else ( + f"Logical role {role.name!r} maps to Claude Code agent " + f"{role_mapping[role.name]!r}: {role.purpose}." + ) ) for role in plan.logical_roles ] for policy in plan.child_model_policies: model_id = self.translate_model(policy.model_class) if policy.model_class else "" - model_policy[policy.role] = (model_id, policy.reasoning_effort) + model_policy[role_mapping[policy.role]] = (model_id, policy.reasoning_effort) for spawn in plan.child_spawns: if spawn.role in plan.runtime_bound_role_names: if spawn.for_each is not None: @@ -630,6 +647,7 @@ def adapt_skill_semantics( "definition name supplied at runtime." ) continue + native_role = role_mapping[spawn.role] spawn_policy = next( ( candidate @@ -650,13 +668,13 @@ def adapt_skill_semantics( ) if spawn.for_each is not None: fragments.append( - f"Issue one Agent(subagent_type={spawn.role!r}{model_arg}) call per " + f"Issue one Agent(subagent_type={native_role!r}{model_arg}) call per " f"runtime item in {spawn.for_each!r}{effort_text}." ) else: assert spawn.count is not None fragments.append( - f"Issue {spawn.count} Agent(subagent_type={spawn.role!r}{model_arg}) " + f"Issue {spawn.count} Agent(subagent_type={native_role!r}{model_arg}) " f"call{'s' if spawn.count != 1 else ''}{effort_text}." ) if plan.concurrency is not None and plan.concurrency.required: diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 5ddc0f3c7c..5ffef73ae3 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -18,7 +18,6 @@ CODEX_MODEL_ALIASES, CODEX_SESSIONS_SUBDIR, CODEX_VALID_MODEL_IDS, - DELEGATED_WORKER_ROLE, RETIRED_CODEX_MODEL_BASE_IDS, RETIRED_CODEX_MODEL_PREFIXES, RETIRED_CODEX_MODEL_SUFFIXES, @@ -106,6 +105,7 @@ from autoskillit.execution.backends._explorer_dispatch import ( CODEX_EXPLORATION_DISPATCH_RENDERER, ) +from autoskillit.execution.backends._native_roles import map_declared_logical_roles _CODEX_SQLITE_HOME_ENV_VAR = "CODEX_SQLITE_HOME" @@ -159,17 +159,11 @@ def _append_symlink_shape_error(errors: list[str], path: Path, diagnostic: str) def _codex_logical_role_mapping(plan: SkillSemanticPlan) -> dict[str, str]: - return { - role.name: ( - role.name.removeprefix("autoskillit:") - if role.name.startswith("autoskillit:") - else "worker" - if role.name == DELEGATED_WORKER_ROLE - else role.name - ) - for role in plan.logical_roles - if not role.runtime_bound - } + return map_declared_logical_roles( + plan, + delegated_worker_agent="worker", + agent_namespace="", + ) def _codex_semantic_admission_refusal( diff --git a/src/autoskillit/migration/adapters_skill.py b/src/autoskillit/migration/adapters_skill.py index 11958dd44b..ea7efc3aa9 100644 --- a/src/autoskillit/migration/adapters_skill.py +++ b/src/autoskillit/migration/adapters_skill.py @@ -6,7 +6,9 @@ from typing import TYPE_CHECKING, Any from autoskillit.core import ( + AGENT_NAME_PATTERN, ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS, + CLAUDE_PLUGIN_AGENT_NAMESPACE, SKILL_CONTRACT_REMEDIATIONS, SKILL_SEMANTIC_SCHEMA_VERSION, RemediationAction, @@ -125,6 +127,25 @@ def _repair_retired_capability_frontmatter( return None +def _canonicalize_logical_role_names(data: dict[str, Any]) -> str | None: + """Remove the Claude plugin prefix from backend-neutral role references.""" + requirements = data.get("semantic_requirements") + if not isinstance(requirements, dict): + return "semantic_requirements must be a mapping" + + for collection, field in ( + ("logical_roles", "name"), + ("child_spawns", "role"), + ("child_model_policies", "role"), + ): + for declaration in requirements.get(collection, ()): + name = declaration[field].removeprefix(CLAUDE_PLUGIN_AGENT_NAMESPACE) + declaration[field] = name + if collection == "logical_roles" and AGENT_NAME_PATTERN.fullmatch(name) is None: + return f"cannot canonicalize logical role name {name!r}" + return None + + def _apply_deterministic_remediation( kind: SkillInvalidityKind, data: dict[str, Any], @@ -141,6 +162,8 @@ def _apply_deterministic_remediation( return _repair_retired_capability_frontmatter(data, declared_caps) if kind is SkillInvalidityKind.SEMANTIC_CHILD_CARDINALITY_INVALID: return _normalize_legacy_child_spawn_cardinality(data) + if kind is SkillInvalidityKind.SEMANTIC_ROLE_NAME_NONCANONICAL: + return _canonicalize_logical_role_names(data) if kind is SkillInvalidityKind.WRITE_BOUNDARY_UNDECLARED: from autoskillit.hooks._write_scope import WRITE_SCOPE_INHERIT diff --git a/src/autoskillit/skills_extended/analyze-pipeline-health/SKILL.md b/src/autoskillit/skills_extended/analyze-pipeline-health/SKILL.md index 55cc37a9eb..0edd436e04 100644 --- a/src/autoskillit/skills_extended/analyze-pipeline-health/SKILL.md +++ b/src/autoskillit/skills_extended/analyze-pipeline-health/SKILL.md @@ -17,10 +17,10 @@ hooks: semantic_version: 1 semantic_requirements: logical_roles: - - name: autoskillit:session-log-reader + - name: session-log-reader purpose: perform the named independent responsibility and return bounded evidence child_spawns: - - role: autoskillit:session-log-reader + - role: session-log-reader for_each: reader_packets concurrency: required: true diff --git a/src/autoskillit/skills_extended/investigate/SKILL.md b/src/autoskillit/skills_extended/investigate/SKILL.md index e7aaaae7dc..2695074276 100644 --- a/src/autoskillit/skills_extended/investigate/SKILL.md +++ b/src/autoskillit/skills_extended/investigate/SKILL.md @@ -18,12 +18,12 @@ semantic_requirements: logical_roles: - name: delegated-worker purpose: perform the named independent responsibility and return bounded evidence - - name: autoskillit:web-evidence-researcher + - name: web-evidence-researcher purpose: research one selected external evidence topic child_spawns: - role: delegated-worker for_each: selected_reasoning_responsibilities - - role: autoskillit:web-evidence-researcher + - role: web-evidence-researcher for_each: selected_web_research_topics concurrency: required: true diff --git a/src/autoskillit/skills_extended/review-approach/SKILL.md b/src/autoskillit/skills_extended/review-approach/SKILL.md index 6b06678e05..9c4a9a742c 100644 --- a/src/autoskillit/skills_extended/review-approach/SKILL.md +++ b/src/autoskillit/skills_extended/review-approach/SKILL.md @@ -15,10 +15,10 @@ hooks: semantic_version: 1 semantic_requirements: logical_roles: - - name: autoskillit:web-evidence-researcher + - name: web-evidence-researcher purpose: collect bounded external web evidence for one runtime research topic child_spawns: - - role: autoskillit:web-evidence-researcher + - role: web-evidence-researcher for_each: research_topics concurrency: required: true diff --git a/src/autoskillit/workspace/_installed/_projection_assets.py b/src/autoskillit/workspace/_installed/_projection_assets.py index bc7ace2fa3..ba971b1887 100644 --- a/src/autoskillit/workspace/_installed/_projection_assets.py +++ b/src/autoskillit/workspace/_installed/_projection_assets.py @@ -13,7 +13,7 @@ from pathlib import Path from types import MappingProxyType -from autoskillit.core import is_python_bytecode_path +from autoskillit.core import is_agent_definition_file, is_python_bytecode_path _CANONICAL_SKILL_DIRS = frozenset({"skills", "skills_extended"}) _PUBLIC_PLUGIN_ASSET_NAMES = frozenset( @@ -51,6 +51,8 @@ def is_projected_asset(entry: Path, *, top_level: bool) -> bool: return False if name in _CANONICAL_SKILL_DIRS: return False + if not top_level and entry.parent.name == "agents": + return is_agent_definition_file(entry) return not (top_level and name not in _PUBLIC_PLUGIN_ASSET_NAMES) diff --git a/src/autoskillit/workspace/_projected_artifact/_publication.py b/src/autoskillit/workspace/_projected_artifact/_publication.py index c6087d96e8..1ad46bb968 100644 --- a/src/autoskillit/workspace/_projected_artifact/_publication.py +++ b/src/autoskillit/workspace/_projected_artifact/_publication.py @@ -26,6 +26,7 @@ SkillSemanticAdaptationResult, atomic_write, destination_location, + is_agent_definition_file, load_agent_definition, project_agent_tool_name, read_claude_plugin_tool_prefix, @@ -326,10 +327,8 @@ def _render_agent_definitions(plugin_root: Path) -> None: return resolve_prefix = functools.cache(lambda: _derive_plugin_tool_prefix(plugin_root)) for entry in entries: - if not entry.name.endswith(".md") or entry.is_dir: - continue path = entry.path - if path.name in {"AGENTS.md", "CLAUDE.md"}: + if entry.is_dir or not is_agent_definition_file(path): continue try: _render_agent_definition(path, resolve_prefix) diff --git a/src/autoskillit/workspace/session_skills/_materialization.py b/src/autoskillit/workspace/session_skills/_materialization.py index c399ca6bdd..0f4f67b9a0 100644 --- a/src/autoskillit/workspace/session_skills/_materialization.py +++ b/src/autoskillit/workspace/session_skills/_materialization.py @@ -170,7 +170,7 @@ def materialize_profile_skills( backend: CodingAgentBackend, projection_context: SkillProjectionContextAuthority, *, - finalized_native_roles: frozenset[str] | None, + finalized_native_roles: frozenset[str], ) -> CompiledSessionSkillCatalog: infos = _profile_skill_infos(profile_skills_source) admission_compilation = compile_session_skill_catalog( @@ -178,9 +178,7 @@ def materialize_profile_skills( backend, adaptation_context=projection_context.adaptation_context, ) - compilation = admission_compilation - if finalized_native_roles is not None: - compilation = admission_compilation.restrict_to_native_roles(finalized_native_roles) + compilation = admission_compilation.restrict_to_native_roles(finalized_native_roles) return _materialize_profile_skill_infos( catalog_dir, compilation, @@ -324,7 +322,7 @@ def _configure_generated_home( projection_context: SkillProjectionContextAuthority, backend: CodingAgentBackend, setup_kwargs: _SessionSetupKwargs, -) -> tuple[frozenset[str] | None, ManagedHomeProjection | None]: +) -> tuple[frozenset[str], ManagedHomeProjection | None]: roles = backend.setup_session_dir(generated_home, **setup_kwargs) projection = _configure_managed_session_route(generated_home, projection_context, backend) return roles, projection diff --git a/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py b/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py index fccd211ccb..f89ce550ce 100644 --- a/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py +++ b/src/autoskillit/workspace/skill_capabilities/_semantic_plan.py @@ -21,6 +21,7 @@ EvidenceSpec, GitMetadataWriteSpec, JoinSpec, + LogicalRoleNameError, LogicalRoleSpec, SiblingSkillSpec, SkillContractError, @@ -315,6 +316,19 @@ def parse_skill_semantic_plan( ) ) return None, tuple(diagnostics) + except LogicalRoleNameError as exc: + diagnostics.append( + ( + SkillInvalidityKind.SEMANTIC_ROLE_NAME_NONCANONICAL, + _semantic_error( + path, + schema_version=schema_version, + offending="semantic_requirements.logical_roles name", + replacement=str(exc), + ), + ) + ) + return None, tuple(diagnostics) except (SkillContractError, TypeError, ValueError) as exc: diagnostics.append( ( diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 13faec4edb..53485cebad 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -1017,7 +1017,7 @@ class AmbientEnvDisposition: "Unfiltered dict(os.environ) base for the global-Codex-home MCP-inventory validation" "probe subprocess, with CODEX_RESERVED_HOME_ENV_VARS overridden to the source home." ), - "execution/backends/claude.py:324": ( + "execution/backends/claude.py:340": ( "Excludes _INTERACTIVE_ENV_EXCLUSIONS (TERM/NO_COLOR headless-hardening keys) when" "building the interactive Claude Code base env." ), diff --git a/tests/arch/test_backend_protocol_completeness.py b/tests/arch/test_backend_protocol_completeness.py index ef3ed7542c..b8d5c50cde 100644 --- a/tests/arch/test_backend_protocol_completeness.py +++ b/tests/arch/test_backend_protocol_completeness.py @@ -262,6 +262,17 @@ def test_coding_agent_backend_protocol_includes_setup_session_dir(): ) +def test_every_backend_finalizes_a_native_agent_universe() -> None: + from typing import get_type_hints + + from autoskillit.execution.backends import BACKEND_REGISTRY + + for name, cls in BACKEND_REGISTRY.items(): + assert get_type_hints(cls.setup_session_dir)["return"] == frozenset[str], ( + f"{name} setup_session_dir must return a finalized native agent universe" + ) + + def test_coding_agent_backend_protocol_includes_model_config_overrides(): from autoskillit.core.types.protocols._type_protocols_backend import CodingAgentBackend diff --git a/tests/contracts/_projection_helpers.py b/tests/contracts/_projection_helpers.py index 6d52e071d4..b5f499bad8 100644 --- a/tests/contracts/_projection_helpers.py +++ b/tests/contracts/_projection_helpers.py @@ -17,6 +17,7 @@ __all__ = [ "STALE_VERSION", + "native_spawn_target_universes", "non_exploration_catalog", "plant_stale_snapshot", "projected_plugin_authority", @@ -26,6 +27,29 @@ STALE_VERSION = "0.0.1-stale" +def native_spawn_target_universes() -> dict[str, frozenset[str]]: + """Build expected native spawn targets from manifests, loaders, and built-in facts.""" + from importlib import import_module + + from autoskillit.core import ( + AGENT_BACKEND_CLAUDE_CODE, + AGENT_BACKEND_CODEX, + load_bundled_agent_definitions, + pkg_root, + ) + + backends = import_module("autoskillit.execution.backends") + plugin_name = json.loads( + (pkg_root() / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + )["name"] + bundled = frozenset(definition.name for definition in load_bundled_agent_definitions()) + return { + AGENT_BACKEND_CLAUDE_CODE: frozenset(backends.CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES) + | {f"{plugin_name}:{name}" for name in bundled}, + AGENT_BACKEND_CODEX: frozenset(backends.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | bundled, + } + + def non_exploration_catalog( catalog: EffectiveSkillCatalog, ) -> EffectiveSkillCatalog: diff --git a/tests/contracts/fixtures/skill_contract_corpus/legacy_namespaced_role.md b/tests/contracts/fixtures/skill_contract_corpus/legacy_namespaced_role.md new file mode 100644 index 0000000000..0481b703a7 --- /dev/null +++ b/tests/contracts/fixtures/skill_contract_corpus/legacy_namespaced_role.md @@ -0,0 +1,15 @@ +--- +name: legacy-namespaced-role +description: A previously valid skill with a namespaced logical role. +semantic_version: 1 +semantic_requirements: + logical_roles: + - name: "autoskillit:plan-foundation-auditor" + purpose: Audit one plan slice. + child_spawns: + - role: "autoskillit:plan-foundation-auditor" + count: 1 +--- +# legacy-namespaced-role + +Delegates one plan slice to the registered plan foundation auditor. diff --git a/tests/contracts/test_agent_tool_surface_alignment.py b/tests/contracts/test_agent_tool_surface_alignment.py index f4a25697c5..b36470c07e 100644 --- a/tests/contracts/test_agent_tool_surface_alignment.py +++ b/tests/contracts/test_agent_tool_surface_alignment.py @@ -96,6 +96,61 @@ def test_projected_artifact_agent_tools_carry_plugin_namespace(tmp_path: Path) - assert short in EXPLORATION_TOOLS +def test_published_plugin_agents_are_exactly_the_loaded_definitions(tmp_path: Path) -> None: + from autoskillit.core import is_agent_definition_file + from autoskillit.workspace import SkillProjectionContext, materialize_sanitized_plugin_root + from autoskillit.workspace.skills import ( + DefaultSkillResolver, + EffectiveSkillCatalog, + SkillCatalogEntry, + ) + + source_root = pkg_root() + source_agents_dir = source_root / "agents" + source_definitions = tuple( + path + for path in source_agents_dir.glob("*.md") + if path.is_file() and not path.is_symlink() and path.name not in {"AGENTS.md", "CLAUDE.md"} + ) + expected_filenames = {path.name for path in source_definitions} + expected_names = {path.stem for path in source_definitions} + source_infos = tuple( + skill for skill in DefaultSkillResolver().list_all() if skill.source is SkillSource.BUNDLED + ) + catalog = EffectiveSkillCatalog( + skills=tuple(SkillCatalogEntry.from_skill_info(skill) for skill in source_infos), + execution_role=SkillExecutionRole.SESSION, + ) + destination = tmp_path / "plugins" / "autoskillit" + destination.parent.mkdir(parents=True) + materialize_sanitized_plugin_root( + source_root, + destination, + catalog, + SkillProjectionContext(cwd=tmp_path, catalog=catalog), + ) + + published_agents_dir = destination / "agents" + assert published_agents_dir.is_dir() + published_files = { + path.relative_to(published_agents_dir).as_posix() + for path in published_agents_dir.rglob("*") + if path.is_file() and not path.is_symlink() + } + assert published_files == expected_filenames + + published_names = { + definition.name for definition in load_agent_definitions(published_agents_dir) + } + bundled_names = {definition.name for definition in load_bundled_agent_definitions()} + assert published_names == expected_names + assert bundled_names == expected_names + assert all( + is_agent_definition_file(published_agents_dir / filename) + for filename in expected_filenames + ) + + def test_session_log_reader_has_one_inspection_tool_and_terminal_codex_policy() -> None: definition = next( item for item in load_bundled_agent_definitions() if item.name == "session-log-reader" diff --git a/tests/contracts/test_analyze_pipeline_health_contracts.py b/tests/contracts/test_analyze_pipeline_health_contracts.py index 117b0f7201..c9e617b111 100644 --- a/tests/contracts/test_analyze_pipeline_health_contracts.py +++ b/tests/contracts/test_analyze_pipeline_health_contracts.py @@ -70,7 +70,7 @@ def test_analyze_pipeline_health_routes_bounded_reader_packets_without_paths(): skill_path = pkg_root() / "skills_extended" / "analyze-pipeline-health" / "SKILL.md" content = skill_path.read_text() - assert "autoskillit:session-log-reader" in content + assert "session-log-reader" in content assert "ordered session IDs" in content assert "requested anomaly classes" in content assert "Verdict: answered | partial | blocked" in content diff --git a/tests/contracts/test_backend_protocol.py b/tests/contracts/test_backend_protocol.py index 8589a4a25d..7319d375ec 100644 --- a/tests/contracts/test_backend_protocol.py +++ b/tests/contracts/test_backend_protocol.py @@ -125,8 +125,9 @@ def test_registered_backends_adapt_every_skill_semantic_operation() -> None: assert result.sibling_skill_targets["investigate"].endswith("investigate"), ( backend_name ) - assert result.model_effort_policy["reviewer"][0], backend_name - assert result.model_effort_policy["reviewer"][1] == "high", backend_name + native_reviewer = result.logical_role_mapping["reviewer"] + assert result.model_effort_policy[native_reviewer][0], backend_name + assert result.model_effort_policy[native_reviewer][1] == "high", backend_name def test_codex_protocol_admits_required_join_with_a_managed_context() -> None: @@ -143,32 +144,6 @@ def test_codex_protocol_admits_required_join_with_a_managed_context() -> None: assert "server-owned managed fixed-batch route" in "\n".join(result.instruction_fragments) -def test_codex_adaptation_maps_namespaced_role_to_registered_agent() -> None: - from autoskillit.core import ( - ChildModelPolicySpec, - ChildSpawnSpec, - LogicalRoleSpec, - SkillSemanticPlan, - ) - from autoskillit.execution.backends import CodexBackend - - logical_role = "autoskillit:pr-review-auditor-reachability" - plan = SkillSemanticPlan( - schema_version=1, - child_spawns=(ChildSpawnSpec(role=logical_role, count=1),), - child_model_policies=(ChildModelPolicySpec(role=logical_role, model_class="sonnet"),), - logical_roles=(LogicalRoleSpec(name=logical_role, purpose="prove reachability"),), - ) - - adaptation = CodexBackend().adapt_skill_semantics(plan) - - native_role = "pr-review-auditor-reachability" - assert adaptation.logical_role_mapping[logical_role] == native_role - assert native_role in "\n".join(adaptation.instruction_fragments) - assert logical_role not in adaptation.model_effort_policy - assert adaptation.model_effort_policy[native_role][0] - - # -- isinstance conformance: ClaudeCodeBackend ------------------------------ diff --git a/tests/contracts/test_native_spawn_target_resolution.py b/tests/contracts/test_native_spawn_target_resolution.py new file mode 100644 index 0000000000..9774fb4796 --- /dev/null +++ b/tests/contracts/test_native_spawn_target_resolution.py @@ -0,0 +1,95 @@ +"""Rendered child-spawn targets resolve in each backend's native agent namespace.""" + +from __future__ import annotations + +import json +import re + +import pytest + +from autoskillit.core import AGENT_BACKEND_CLAUDE_CODE, AGENT_BACKEND_CODEX, pkg_root +from tests.contracts._projection_helpers import native_spawn_target_universes + +pytestmark = [pytest.mark.layer("contracts"), pytest.mark.small] + +_RENDERED_TARGET = { + AGENT_BACKEND_CLAUDE_CODE: re.compile(r"\bsubagent_type='([^']+)'"), + AGENT_BACKEND_CODEX: re.compile(r"\bagent_type='([^']+)'"), +} + + +def test_universe_table_covers_every_registered_backend() -> None: + from autoskillit.execution.backends import BACKEND_REGISTRY + + universes = native_spawn_target_universes() + + assert set(universes) == set(BACKEND_REGISTRY) == set(_RENDERED_TARGET) + + +def test_every_rendered_spawn_target_resolves_on_every_backend() -> None: + from autoskillit.execution.backends import BACKEND_REGISTRY + from autoskillit.workspace import DefaultSkillResolver + + universes = native_spawn_target_universes() + plugin_name = json.loads( + (pkg_root() / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") + )["name"] + plans = tuple( + (skill.name, skill.semantic_plan) + for skill in DefaultSkillResolver().list_all() + if skill.semantic_plan is not None + ) + assert plans + + violations: list[str] = [] + claude_targets: set[str] = set() + for backend_name, backend_factory in BACKEND_REGISTRY.items(): + backend = backend_factory() + pattern = _RENDERED_TARGET[backend_name] + for skill_name, plan in plans: + assert plan is not None + adaptation = backend.adapt_skill_semantics(plan) + if adaptation.unsupported_operation is not None: + continue + + rendered = { + target + for fragment in adaptation.instruction_fragments + for target in pattern.findall(fragment) + } + if backend_name == AGENT_BACKEND_CLAUDE_CODE: + claude_targets.update(rendered) + + unresolved = sorted(rendered - universes[backend_name]) + if unresolved: + violations.append( + f"{skill_name}/{backend_name}: rendered targets outside universe {unresolved}" + ) + + for spawn in plan.child_spawns: + if spawn.role in plan.runtime_bound_role_names: + continue + mapped = adaptation.logical_role_mapping.get(spawn.role) + if mapped not in rendered: + violations.append( + f"{skill_name}/{backend_name}: static role {spawn.role!r} maps to " + f"{mapped!r}, absent from rendered targets {sorted(rendered)}" + ) + + expected_policy_keys = { + adaptation.logical_role_mapping.get(policy.role) + for policy in plan.child_model_policies + } + actual_policy_keys = set(adaptation.model_effort_policy) + if actual_policy_keys != expected_policy_keys: + violations.append( + f"{skill_name}/{backend_name}: model policy keys {sorted(actual_policy_keys)} " + "do not match mapped child-policy roles " + f"{sorted(expected_policy_keys, key=repr)}" + ) + + assert not violations, "native spawn target contract violations:\n" + "\n".join( + sorted(violations) + ) + assert "general-purpose" in claude_targets + assert any(target.startswith(f"{plugin_name}:") for target in claude_targets) diff --git a/tests/contracts/test_skill_contract_remediations.py b/tests/contracts/test_skill_contract_remediations.py index 1df1b55533..cd2c72fd6c 100644 --- a/tests/contracts/test_skill_contract_remediations.py +++ b/tests/contracts/test_skill_contract_remediations.py @@ -110,6 +110,7 @@ def test_migration_adapter_covers_every_deterministic_remediation() -> None: SkillInvalidityKind.UNDECLARED_CAPABILITY, SkillInvalidityKind.SEMANTIC_CHILD_CARDINALITY_INVALID, SkillInvalidityKind.SEMANTIC_MISSING_VERSION, + SkillInvalidityKind.SEMANTIC_ROLE_NAME_NONCANONICAL, SkillInvalidityKind.SEMANTIC_UNDECLARED_TOKENS, SkillInvalidityKind.WRITE_BOUNDARY_UNDECLARED, } @@ -182,6 +183,7 @@ def test_capability_registry_authenticity_findings_are_typed(tmp_path: Path) -> "missing_semantic_version.md", "legacy_spawner.md", "legacy_child_spawn_cardinality.md", + "legacy_namespaced_role.md", "resource_contract_invalid.md", "undeclared_write_scope.md", ) @@ -191,6 +193,7 @@ def test_capability_registry_authenticity_findings_are_typed(tmp_path: Path) -> "missing_semantic_version.md": "research-helper", "legacy_spawner.md": "legacy-spawner", "legacy_child_spawn_cardinality.md": "legacy-child-spawn-cardinality", + "legacy_namespaced_role.md": "legacy-namespaced-role", "resource_contract_invalid.md": "resource-contract-invalid", "undeclared_write_scope.md": "undeclared-write-scope", } diff --git a/tests/contracts/test_skill_semantic_authenticity.py b/tests/contracts/test_skill_semantic_authenticity.py index acbb32c42d..fb0e26bc78 100644 --- a/tests/contracts/test_skill_semantic_authenticity.py +++ b/tests/contracts/test_skill_semantic_authenticity.py @@ -164,35 +164,32 @@ def test_managed_codex_admits_every_bundled_join_required_plan() -> None: ) -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, - _codex_logical_role_mapping, +def test_bundled_logical_roles_name_real_agents() -> None: + from autoskillit.core import ( + DELEGATED_WORKER_ROLE, + SkillSource, + load_bundled_agent_definitions, ) from autoskillit.workspace import DefaultSkillResolver - allowed = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | { - definition.name for definition in load_bundled_agent_definitions() - } + bundled_sources = {SkillSource.BUNDLED, SkillSource.BUNDLED_EXTENDED} + bundled_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.source in bundled_sources if skill.semantic_plan is not None ) violations: list[str] = [] - backend = CodexBackend() for skill_name, plan in plans: assert plan is not None - role_mapping = _codex_logical_role_mapping(plan) - adaptation = backend.adapt_skill_semantics(plan) - if adaptation.unsupported_operation is not None: - assert plan.join is not None and plan.join.required - targets = {role_mapping[spawn.role] for spawn in plan.child_spawns} - missing = sorted(targets - allowed) - if missing: - violations.append(f"{skill_name}: {missing}") + for role in plan.logical_roles: + if role.runtime_bound: + continue + if role.name != DELEGATED_WORKER_ROLE and role.name not in bundled_agent_names: + violations.append(f"{skill_name}: {role.name!r}") assert plans - assert not violations, "unregistered bundled Codex child roles:\n" + "\n".join(violations) + assert not violations, "bundled logical roles without real agent definitions:\n" + "\n".join( + sorted(violations) + ) diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index 5ac32ff866..d825d76d77 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -817,3 +817,45 @@ def test_definition_digest_is_domain_separated_and_content_bound() -> None: assert agent_definition_digest(changed_web_search) != agent_definition_digest( changed_live_web_search ) + + +@pytest.mark.parametrize("filename", ["AGENTS.md", "CLAUDE.md"]) +@pytest.mark.parametrize("has_frontmatter", [True, False]) +def test_agent_directory_guides_are_excluded_from_definition_loading( + tmp_path: Path, filename: str, has_frontmatter: bool +) -> None: + from autoskillit.core.plugins import ( + AGENT_DIRECTORY_GUIDE_FILES, + is_agent_definition_file, + load_agent_definitions, + ) + + guide_path = tmp_path / filename + guide_contents = ( + "---\n" + "name: reserved-guide\n" + "description: A valid agent definition in a reserved guide file.\n" + "tools: [Read]\n" + "---\n\n" + "Return bounded evidence.\n" + if has_frontmatter + else "# Directory guide\n\nThis file contains plain documentation.\n" + ) + guide_path.write_text(guide_contents, encoding="utf-8") + ordinary_path = tmp_path / "ordinary.md" + ordinary_path.write_text( + "---\n" + "name: ordinary-agent\n" + "description: An ordinary agent definition.\n" + "tools: [Read]\n" + "---\n\n" + "Return bounded evidence.\n", + encoding="utf-8", + ) + + definitions = load_agent_definitions(tmp_path) + + assert [definition.name for definition in definitions] == ["ordinary-agent"] + assert AGENT_DIRECTORY_GUIDE_FILES == frozenset({"AGENTS.md", "CLAUDE.md"}) + assert not is_agent_definition_file(guide_path) + assert is_agent_definition_file(ordinary_path) diff --git a/tests/core/test_backend_protocols.py b/tests/core/test_backend_protocols.py index 2a16153be9..96d997b612 100644 --- a/tests/core/test_backend_protocols.py +++ b/tests/core/test_backend_protocols.py @@ -104,7 +104,7 @@ def test_coding_agent_backend_has_setup_session_dir_method(): "agent_defs": tuple[AgentDef, ...] | None, "explorer_binding_env": Mapping[str, Mapping[str, str]] | None, "execution_role": SkillExecutionRole, - "return": frozenset[str] | None, + "return": frozenset[str], } @@ -479,7 +479,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, - ) -> frozenset[str] | None: ... + ) -> frozenset[str]: ... def refresh_explorer_binding_env( self, diff --git a/tests/core/test_skill_semantic_plan.py b/tests/core/test_skill_semantic_plan.py index a9d5dd0873..62d5616c58 100644 --- a/tests/core/test_skill_semantic_plan.py +++ b/tests/core/test_skill_semantic_plan.py @@ -374,3 +374,40 @@ def test_runtime_bound_roles_are_frozen() -> None: ) assert result.runtime_bound_roles == frozenset({"worker", "auditor"}) + + +def test_namespaced_logical_role_name_is_unconstructible() -> None: + from autoskillit.core import LogicalRoleNameError, LogicalRoleSpec + + with pytest.raises(LogicalRoleNameError): + LogicalRoleSpec(name="autoskillit:plan-foundation-auditor", purpose="p") + + +@pytest.mark.parametrize( + ("name", "valid"), + [ + ("Plan-Auditor", False), + ("plan_auditor", False), + ("-x", False), + ("delegated-worker", True), + ("plan-foundation-auditor", True), + ], +) +def test_logical_role_name_grammar(name: str, valid: bool) -> None: + from autoskillit.core import LogicalRoleNameError, LogicalRoleSpec, SkillContractError + + assert issubclass(LogicalRoleNameError, SkillContractError) + if valid: + assert LogicalRoleSpec(name=name, purpose="p").name == name + else: + with pytest.raises(LogicalRoleNameError): + LogicalRoleSpec(name=name, purpose="p") + + +def test_agent_definitions_and_logical_roles_share_one_grammar() -> None: + from autoskillit.core import AGENT_NAME_PATTERN + from autoskillit.core.plugins.agent_definition import ( + AGENT_NAME_PATTERN as agent_definition_pattern, + ) + + assert agent_definition_pattern is AGENT_NAME_PATTERN diff --git a/tests/execution/backends/test_claude_code_backend.py b/tests/execution/backends/test_claude_code_backend.py index c8b4df6dee..8837b6e462 100644 --- a/tests/execution/backends/test_claude_code_backend.py +++ b/tests/execution/backends/test_claude_code_backend.py @@ -9,12 +9,16 @@ from autoskillit.core import ( AGENT_BACKEND_CLAUDE_CODE, BackendConventions, + ChildModelPolicySpec, + ChildSpawnSpec, CmdSpec, CodingAgentBackend, EnvPolicy, + LogicalRoleSpec, OutputFormat, ResultParser, SessionLocator, + SkillSemanticPlan, StreamParser, load_bundled_agent_definitions, ) @@ -101,12 +105,92 @@ def test_conventions_returns_backend_conventions(self) -> None: assert isinstance(result, BackendConventions) assert result.skills_subdir == Path(".claude/skills") - def test_setup_session_dir_returns_none(self, tmp_path: Path) -> None: + def test_claude_maps_logical_roles_to_registered_agent_types(self) -> None: + plan = SkillSemanticPlan( + schema_version=1, + logical_roles=( + LogicalRoleSpec(name="delegated-worker", purpose="review changes"), + LogicalRoleSpec(name="plan-foundation-auditor", purpose="audit a plan"), + LogicalRoleSpec(name="web-evidence-researcher", purpose="research topics"), + ), + child_spawns=( + ChildSpawnSpec(role="delegated-worker", count=2), + ChildSpawnSpec(role="plan-foundation-auditor", count=1), + ChildSpawnSpec(role="web-evidence-researcher", for_each="topics"), + ), + child_model_policies=( + ChildModelPolicySpec(role="delegated-worker", model_class="sonnet"), + ChildModelPolicySpec( + role="plan-foundation-auditor", + model_class="sonnet", + reasoning_effort="high", + ), + ), + ) + + adaptation = ClaudeCodeBackend().adapt_skill_semantics(plan) + fragments = adaptation.instruction_fragments + + assert adaptation.unsupported_operation is None + assert adaptation.logical_role_mapping == { + "delegated-worker": "general-purpose", + "plan-foundation-auditor": "autoskillit:plan-foundation-auditor", + "web-evidence-researcher": "autoskillit:web-evidence-researcher", + } + assert set(adaptation.model_effort_policy) == { + "general-purpose", + "autoskillit:plan-foundation-auditor", + } + assert any( + "Issue 2 Agent(subagent_type='general-purpose', model='sonnet') calls." in fragment + for fragment in fragments + ) + assert any( + "subagent_type='autoskillit:plan-foundation-auditor'" in fragment + for fragment in fragments + ) + assert any( + "Agent(subagent_type='autoskillit:web-evidence-researcher') call per runtime item " + "in 'topics'" in fragment + for fragment in fragments + ) + assert not any( + needle in fragment + for fragment in fragments + for needle in ( + "subagent_type='delegated-worker'", + "subagent_type='plan-foundation-auditor'", + ) + ) + + def test_setup_session_dir_returns_resolvable_agent_universe(self, tmp_path: Path) -> None: + from autoskillit.core.plugins import CLAUDE_PLUGIN_AGENT_NAMESPACE + from autoskillit.execution.backends import ( + CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES, + claude_resolvable_agent_names, + ) + result = ClaudeCodeBackend().setup_session_dir( tmp_path, agent_defs=(load_bundled_agent_definitions()[0],), ) - assert result is None + expected = frozenset(CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES) | { + f"{CLAUDE_PLUGIN_AGENT_NAMESPACE}{definition.name}" + for definition in load_bundled_agent_definitions() + } + + assert result == expected == claude_resolvable_agent_names() + assert isinstance(result, frozenset) + assert {"autoskillit:plan-foundation-auditor", "general-purpose", "Plan"} <= result + assert ( + not { + "delegated-worker", + "plan-foundation-auditor", + "autoskillit:AGENTS", + "autoskillit:CLAUDE", + } + & result + ) def test_setup_session_dir_does_not_raise_or_write(self, tmp_path: Path) -> None: before = list(tmp_path.iterdir()) diff --git a/tests/execution/backends/test_codex_managed_route_live_gate.py b/tests/execution/backends/test_codex_managed_route_live_gate.py index 8c8bea3c89..c4b235fe67 100644 --- a/tests/execution/backends/test_codex_managed_route_live_gate.py +++ b/tests/execution/backends/test_codex_managed_route_live_gate.py @@ -491,7 +491,7 @@ def preserve_parent_transcripts() -> None: prompt = f""" Call open_kitchen with no arguments, then run_fixed_batch exactly once with skill_name "{skill_name}", idempotency_key "live-{run_id}", and one assignment: -role "autoskillit:session-log-reader", label "live-worker", runtime_key "live-packet", +role "session-log-reader", label "live-worker", runtime_key "live-packet", task_prompt "Use only this supplied evidence packet: the native gate reached its worker. Return LIVE_BATCH_CHILD followed by ---pipeline-health-result---. This is the complete leaf assignment; no filesystem access is needed." diff --git a/tests/execution/backends/test_join_conformance_traces.py b/tests/execution/backends/test_join_conformance_traces.py index 74542d578c..ed196ac4fc 100644 --- a/tests/execution/backends/test_join_conformance_traces.py +++ b/tests/execution/backends/test_join_conformance_traces.py @@ -57,10 +57,10 @@ # Reusable 4-child trace fixtures # --------------------------------------------------------------------------- -_REVIEWER = "autoskillit:reviewer-a" -_FACT_CHECKER = "autoskillit:fact-checker-b" -_SUMMARIZER = "autoskillit:summarizer-c" -_CRITIC = "autoskillit:critic-d" +_REVIEWER = "reviewer-a" +_FACT_CHECKER = "fact-checker-b" +_SUMMARIZER = "summarizer-c" +_CRITIC = "critic-d" def _four_child_plan() -> SkillSemanticPlan: diff --git a/tests/execution/backends/test_native_roles.py b/tests/execution/backends/test_native_roles.py new file mode 100644 index 0000000000..70d690d92f --- /dev/null +++ b/tests/execution/backends/test_native_roles.py @@ -0,0 +1,61 @@ +"""Normalization of declared logical roles to backend-native agent names.""" + +from __future__ import annotations + +import pytest + +from autoskillit.core import LogicalRoleSpec, SkillSemanticPlan + +pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] + + +@pytest.mark.parametrize( + ("delegated_worker_agent", "agent_namespace", "expected"), + [ + ( + "general-purpose", + "autoskillit:", + { + "delegated-worker": "general-purpose", + "plan-foundation-auditor": "autoskillit:plan-foundation-auditor", + }, + ), + ( + "worker", + "", + { + "delegated-worker": "worker", + "plan-foundation-auditor": "plan-foundation-auditor", + }, + ), + ], + ids=("claude", "codex"), +) +def test_map_declared_logical_roles( + delegated_worker_agent: str, + agent_namespace: str, + expected: dict[str, str], +) -> None: + from autoskillit.execution.backends._native_roles import map_declared_logical_roles + + plan = SkillSemanticPlan( + schema_version=1, + logical_roles=( + LogicalRoleSpec(name="delegated-worker", purpose="perform general work"), + LogicalRoleSpec(name="plan-foundation-auditor", purpose="audit a plan"), + LogicalRoleSpec( + name="evaluated-agent", + purpose="use the selected bundled agent", + runtime_bound=True, + ), + ), + ) + + assert ( + map_declared_logical_roles( + plan, + delegated_worker_agent=delegated_worker_agent, + agent_namespace=agent_namespace, + ) + == expected + ) diff --git a/tests/execution/backends/test_skill_semantic_trace_conformance.py b/tests/execution/backends/test_skill_semantic_trace_conformance.py index db557e195e..186c4593e2 100644 --- a/tests/execution/backends/test_skill_semantic_trace_conformance.py +++ b/tests/execution/backends/test_skill_semantic_trace_conformance.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import re import tomllib from dataclasses import replace from pathlib import Path @@ -45,7 +46,7 @@ ) _DISCIPLINE_DIGEST = "sha256:portable-output-discipline" -_REVIEW_ROLE = "autoskillit:pr-review-auditor-baseline" +_REVIEW_ROLE = "pr-review-auditor-baseline" _WORKER_ROLE = "delegated-worker" @@ -205,11 +206,19 @@ def _codex_trace( return parent_events, child_events +def _rendered_claude_targets(adaptation: SkillSemanticAdaptationResult) -> list[str]: + target_pattern = re.compile(r"subagent_type='([^']+)'") + return [ + target + for fragment in adaptation.instruction_fragments + for target in target_pattern.findall(fragment) + ] + + def _claude_trace( adaptation: SkillSemanticAdaptationResult, ) -> tuple[list[dict], list[dict]]: - reviewer = adaptation.logical_role_mapping[_REVIEW_ROLE] - worker = adaptation.logical_role_mapping[_WORKER_ROLE] + reviewer, worker = _rendered_claude_targets(adaptation) model, _effort = adaptation.model_effort_policy[reviewer] sibling = adaptation.sibling_skill_targets["smoke-task"] return ( @@ -443,7 +452,7 @@ def test_compose_pr_managed_codex_trace_uses_the_fixed_batch_route() -> None: def test_dynamic_child_spawn_adapters_preserve_runtime_cardinality() -> None: - role = "autoskillit:web-evidence-researcher" + role = "web-evidence-researcher" plan = SkillSemanticPlan( schema_version=1, logical_roles=(LogicalRoleSpec(name=role, purpose="research one topic"),), @@ -472,7 +481,7 @@ def test_review_approach_projects_the_real_named_web_role() -> None: assert not info.invalidities assert info.semantic_plan is not None plan = info.semantic_plan - role = "autoskillit:web-evidence-researcher" + role = "web-evidence-researcher" assert tuple(item.name for item in plan.logical_roles) == (role,) assert plan.child_spawns == (ChildSpawnSpec(role=role, for_each="research_topics"),) assert not plan.child_model_policies @@ -505,7 +514,7 @@ def test_analyze_pipeline_health_projects_the_real_terminal_reader() -> None: assert not info.invalidities assert info.semantic_plan is not None plan = info.semantic_plan - role = "autoskillit:session-log-reader" + role = "session-log-reader" assert tuple(item.name for item in plan.logical_roles) == (role,) assert plan.child_spawns == (ChildSpawnSpec(role=role, for_each="reader_packets"),) assert not plan.child_model_policies diff --git a/tests/execution/conftest.py b/tests/execution/conftest.py index 0b61f9abcc..d286cc74e4 100644 --- a/tests/execution/conftest.py +++ b/tests/execution/conftest.py @@ -100,7 +100,7 @@ def _mock_backend(**kw: Any) -> Mock: backend.list_plugins.return_value = [] backend.validate_skill_content.return_value = [] backend.validate_session_layout.return_value = [] - backend.setup_session_dir.return_value = None + backend.setup_session_dir.return_value = frozenset({"general-purpose"}) return backend diff --git a/tests/execution/test_skill_session_contract_store.py b/tests/execution/test_skill_session_contract_store.py index 467514c5ae..7f665b1e5e 100644 --- a/tests/execution/test_skill_session_contract_store.py +++ b/tests/execution/test_skill_session_contract_store.py @@ -22,7 +22,7 @@ def test_exploration_vector_contract_versions_invalidate_stale_artifacts() -> None: from autoskillit.core import SKILL_SESSION_CONTRACT_SCHEMA_VERSION - assert SKILL_PROJECTION_VERSION == 11 + assert SKILL_PROJECTION_VERSION == 12 assert SKILL_SESSION_CONTRACT_SCHEMA_VERSION == 5 @@ -476,13 +476,13 @@ def test_stale_projection_version_rejected_before_enum_construction(tmp_path: Pa store.finalize(correlation_key, "stale-projection") -def test_store_load_rejects_an_otherwise_valid_finalized_v10_contract(tmp_path: Path) -> None: - """A snapshot predating runtime agent bindings cannot resume.""" +def test_store_load_rejects_an_otherwise_valid_finalized_v11_contract(tmp_path: Path) -> None: + """A snapshot predating native role normalization cannot resume.""" from autoskillit.execution.session import DefaultSkillSessionContractStore from autoskillit.execution.session._skill_session_contract_codec import _digest_json text = "projected\n" - session_id = "finalized-v10" + session_id = "finalized-v11" store = DefaultSkillSessionContractStore(root=tmp_path / "contracts") correlation_key = store.create_provisional( contract=_contract(tmp_path, text), @@ -492,13 +492,13 @@ def test_store_load_rejects_an_otherwise_valid_finalized_v10_contract(tmp_path: entry = store._session_path(session_id) manifest = store._read_manifest(entry) contract_data = manifest["contract"] - contract_data["projection_version"] = 10 + contract_data["projection_version"] = 11 manifest["contract_digest"] = _digest_json(contract_data) store._write_manifest(entry, manifest) with pytest.raises( ValueError, - match=f"unsupported projection_version 10; expected {SKILL_PROJECTION_VERSION}", + match=f"unsupported projection_version 11; expected {SKILL_PROJECTION_VERSION}", ): store.load(session_id) diff --git a/tests/migration/test_engine_skill.py b/tests/migration/test_engine_skill.py index 226c09cdc0..b76eb1c838 100644 --- a/tests/migration/test_engine_skill.py +++ b/tests/migration/test_engine_skill.py @@ -2,7 +2,9 @@ from __future__ import annotations +import importlib from pathlib import Path +from unittest.mock import AsyncMock import pytest @@ -10,6 +12,7 @@ from autoskillit.migration.adapters_skill import SkillMigrationAdapter from autoskillit.migration.engine import ( DeterministicMigrationAdapter, + MigrationEngine, MigrationFile, ) from autoskillit.migration.service import default_migration_engine @@ -21,6 +24,29 @@ ) +def _skill_with_logical_role(role: str) -> str: + return ( + "---\n" + "name: role-migration\n" + "description: exercise logical role migration\n" + "write_paths: inherit\n" + "semantic_version: 1\n" + "semantic_requirements:\n" + " logical_roles:\n" + f' - name: "{role}"\n' + " purpose: review one independent concern\n" + " child_spawns:\n" + f' - role: "{role}"\n' + " count: 1\n" + " child_model_policies:\n" + f' - role: "{role}"\n' + " model_class: opus\n" + " reasoning_effort: high\n" + "---\n" + "Body.\n" + ) + + def test_skill_validation_checks_deterministic_contract(tmp_path: Path) -> None: skill_dir = tmp_path / ".claude" / "skills" / "missing-declaration" skill_dir.mkdir(parents=True) @@ -169,3 +195,65 @@ async def test_migrate_no_op_when_nothing_deterministic(self, tmp_path: Path) -> assert result.success assert result.migrated_content is None + + @pytest.mark.anyio + async def test_migration_strips_plugin_namespace_from_logical_roles( + self, tmp_path: Path + ) -> None: + skill_name = "role-migration" + skill_path = tmp_path / ".claude" / "skills" / skill_name / "SKILL.md" + skill_path.parent.mkdir(parents=True) + skill_path.write_text( + _skill_with_logical_role("autoskillit:web-evidence-researcher"), + encoding="utf-8", + ) + file = MigrationFile( + name=skill_name, + path=skill_path, + file_type="skill", + current_version=None, + ) + + result = await MigrationEngine([SkillMigrationAdapter()]).migrate_file( + file, + run_headless=AsyncMock(), + temp_dir=tmp_path / "temp", + ) + + assert result.success, result.error + workspace = importlib.import_module("autoskillit.workspace") + parsed = workspace.read_skill_frontmatter(skill_path) + assert parsed.data is not None + requirements = parsed.data["semantic_requirements"] + assert requirements["logical_roles"][0]["name"] == "web-evidence-researcher" + assert requirements["child_spawns"][0]["role"] == "web-evidence-researcher" + assert requirements["child_model_policies"][0]["role"] == "web-evidence-researcher" + + info = workspace.default_skill_resolver().resolve_local_candidate(skill_name, tmp_path) + assert info is not None + assert not info.invalidities + + @pytest.mark.anyio + async def test_migration_reports_uncanonicalizable_role_name(self, tmp_path: Path) -> None: + skill_name = "role-migration" + skill_path = tmp_path / ".claude" / "skills" / skill_name / "SKILL.md" + skill_path.parent.mkdir(parents=True) + original = _skill_with_logical_role("autoskillit:Bad_Name").encode("utf-8") + skill_path.write_bytes(original) + file = MigrationFile( + name=skill_name, + path=skill_path, + file_type="skill", + current_version=None, + ) + + result = await MigrationEngine([SkillMigrationAdapter()]).migrate_file( + file, + run_headless=AsyncMock(), + temp_dir=tmp_path / "temp", + ) + + assert result.success is False + assert result.error == "cannot canonicalize logical role name 'Bad_Name'" + assert skill_path.read_bytes() == original + assert not skill_path.with_suffix(".yaml.bak").exists() diff --git a/tests/skills/test_review_approach_web_evidence_contract.py b/tests/skills/test_review_approach_web_evidence_contract.py index 7d7b9a591f..6663e1ba88 100644 --- a/tests/skills/test_review_approach_web_evidence_contract.py +++ b/tests/skills/test_review_approach_web_evidence_contract.py @@ -41,8 +41,8 @@ def test_every_topic_has_one_terminal_ledger_entry(skill_text: str) -> None: def test_named_web_role_receives_one_bounded_packet_per_topic(skill_text: str) -> None: frontmatter = skill_text.split("---", maxsplit=2)[1] - assert "name: autoskillit:web-evidence-researcher" in frontmatter - assert "role: autoskillit:web-evidence-researcher" in frontmatter + assert "name: web-evidence-researcher" in frontmatter + assert "role: web-evidence-researcher" in frontmatter assert "for_each: research_topics" in frontmatter assert "child_model_policies" not in frontmatter assert "why that topic matters to the reviewed plan" in skill_text diff --git a/tests/skills_extended/test_explorer_adoption_inventory.py b/tests/skills_extended/test_explorer_adoption_inventory.py index 5c6ed222ad..96320b513b 100644 --- a/tests/skills_extended/test_explorer_adoption_inventory.py +++ b/tests/skills_extended/test_explorer_adoption_inventory.py @@ -1578,7 +1578,7 @@ def test_investigate_projects_adaptive_semantic_collections_and_guarded_candidat assert [(spawn.role, spawn.count, spawn.for_each) for spawn in spawns] == [ ("delegated-worker", None, "selected_reasoning_responsibilities"), ( - "autoskillit:web-evidence-researcher", + "web-evidence-researcher", None, "selected_web_research_topics", ), diff --git a/tests/workspace/_helpers.py b/tests/workspace/_helpers.py index a568f4096e..4ddda9a3a6 100644 --- a/tests/workspace/_helpers.py +++ b/tests/workspace/_helpers.py @@ -111,6 +111,7 @@ class _ReleaseFailure(Exception): def _make_codex_backend() -> MagicMock: + from autoskillit.core import load_bundled_agent_definitions from autoskillit.execution.backends import CodexBackend b = MagicMock() @@ -118,13 +119,25 @@ def _make_codex_backend() -> MagicMock: b.capabilities = _CODEX_CAPABILITIES b.conventions = CodexBackend().conventions b.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) - b.setup_session_dir.return_value = None + b.setup_session_dir.return_value = frozenset( + {"default", "explorer", "worker"} + | {definition.name for definition in load_bundled_agent_definitions()} + ) b.validate_session_layout.return_value = [] b.adapt_skill_semantics.side_effect = CodexBackend().adapt_skill_semantics b.exploration_dispatch_renderer = CodexBackend().exploration_dispatch_renderer return b +def _identity_test_agent_names() -> frozenset[str]: + from autoskillit.core import load_bundled_agent_definitions + + return frozenset( + {"delegated-worker", "worker"} + | {definition.name for definition in load_bundled_agent_definitions()} + ) + + def _stub_backend( name: str, *, diff --git a/tests/workspace/test_claude_native_role_admission.py b/tests/workspace/test_claude_native_role_admission.py new file mode 100644 index 0000000000..9388823ec0 --- /dev/null +++ b/tests/workspace/test_claude_native_role_admission.py @@ -0,0 +1,127 @@ +"""Claude session admission uses the native names its session can resolve.""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path + +import pytest + +from autoskillit.core import SkillContractError, SkillExecutionRole, SkillSource +from autoskillit.workspace import DefaultSkillResolver, SkillProjectionContext +from tests.workspace._helpers import _managed, _write_project_skill_override + +pytestmark = [pytest.mark.layer("workspace"), pytest.mark.medium] + +_REPOSITORY_ROOT = Path(__file__).resolve().parents[2] + + +def _write_child_spawn_skill(project_root: Path, name: str, role: str) -> Path: + content = ( + "---\n" + f"name: {name}\n" + "description: Delegate one child task.\n" + "uses_capabilities: []\n" + "execution_role: session\n" + "semantic_version: 1\n" + "semantic_requirements:\n" + " logical_roles:\n" + f" - name: {role}\n" + " purpose: perform one child task\n" + " child_spawns:\n" + f" - role: {role}\n" + " count: 1\n" + "write_paths: inherit\n" + "---\n" + "Delegate one child task using the declared semantic role.\n" + ) + return _write_project_skill_override(project_root, name, content) + + +def test_claude_invocation_with_unresolvable_target_fails_closed( + make_session_skill_manager, + tmp_path: Path, +) -> None: + project_root = tmp_path / "project" + _write_child_spawn_skill(project_root, "unresolvable-target", "nonexistent-agent") + invocation = DefaultSkillResolver().resolve_invocation( + "unresolvable-target", + project_root, + SkillExecutionRole.SESSION, + ) + backend = import_module("autoskillit.execution.backends").ClaudeCodeBackend() + manager = make_session_skill_manager() + projection_context = SkillProjectionContext( + cwd=project_root, + invocation=invocation, + backend=backend, + ) + + with pytest.raises( + SkillContractError, + match=r"native child-spawn targets are unavailable: \['autoskillit:nonexistent-agent'\]", + ): + manager.materialize_invocation( + "unresolvable-target-admission", + invocation, + projection_context, + ) + + +def test_claude_catalog_prunes_unresolvable_skill( + make_session_skill_manager, +) -> None: + backend = import_module("autoskillit.execution.backends").ClaudeCodeBackend() + manager = make_session_skill_manager() + _write_child_spawn_skill(manager.ephemeral_root, "unresolvable-target", "nonexistent-agent") + _write_child_spawn_skill(manager.ephemeral_root, "portable-review", "delegated-worker") + + with _managed( + manager, + "claude-catalog-admission", + backend=backend, + names=frozenset({"unresolvable-target", "portable-review"}), + ) as managed: + catalog_root = Path(managed.skills_dir.path) / "skills" + unavailable = { + item["skill"]: item["operation"] + for item in managed.unavailability_payload["unavailable"] + } + + assert unavailable == {"unresolvable-target": "child_spawn"} + assert not (catalog_root / "unresolvable-target").exists() + projected_skill = catalog_root / "portable-review" / "SKILL.md" + assert projected_skill.is_file() + assert "subagent_type='general-purpose'" in projected_skill.read_text(encoding="utf-8") + + +def test_claude_invocation_admits_runtime_bound_eval_agent( + make_session_skill_manager, + tmp_path: Path, +) -> None: + backend = import_module("autoskillit.execution.backends").ClaudeCodeBackend() + invocation = DefaultSkillResolver().resolve_invocation( + "eval-agent", + _REPOSITORY_ROOT, + SkillExecutionRole.SESSION, + ) + assert invocation.root.source is SkillSource.PROJECT_LOCAL + manager = make_session_skill_manager() + session_id = "claude-eval-agent-admission" + result = manager.materialize_invocation( + session_id, + invocation, + SkillProjectionContext( + cwd=_REPOSITORY_ROOT, + invocation=invocation, + backend=backend, + ), + ) + + projected_skill = ( + Path(result.path) / backend.conventions.skills_subdir / "eval-agent" / "SKILL.md" + ) + content = projected_skill.read_text(encoding="utf-8") + assert "subagent_type set to 'autoskillit:'" in content + assert "subagent_type='autoskillit:evaluated-agent'" not in content + assert manager.cleanup_session(session_id) is True diff --git a/tests/workspace/test_claude_projected_spawn_targets.py b/tests/workspace/test_claude_projected_spawn_targets.py new file mode 100644 index 0000000000..0934f4faa4 --- /dev/null +++ b/tests/workspace/test_claude_projected_spawn_targets.py @@ -0,0 +1,63 @@ +"""Every child-spawn target in Claude-projected skills resolves to a native agent.""" + +from __future__ import annotations + +import re +from importlib import import_module + +import pytest + +from autoskillit.core import ( + AGENT_BACKEND_CLAUDE_CODE, + RepositoryProfileId, + SkillExecutionRole, + SkillSource, +) +from tests.contracts._projection_helpers import native_spawn_target_universes + +pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] + +_SPAWN_TARGET = re.compile(r"subagent_type=(['\"])([^'\"]+)\1") + + +def test_claude_projected_skill_targets_resolve(tmp_path) -> None: + from autoskillit.workspace import ( + DefaultSkillResolver, + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillProjectionContext, + compile_session_skill_catalog, + materialize_agent_skill_tree, + ) + + backend = import_module("autoskillit.execution.backends").ClaudeCodeBackend() + source_infos = tuple( + skill + for skill in DefaultSkillResolver().list_all() + if skill.source in {SkillSource.BUNDLED, SkillSource.BUNDLED_EXTENDED} + and skill.execution_role is SkillExecutionRole.SESSION + ) + catalog = EffectiveSkillCatalog( + skills=tuple(SkillCatalogEntry.from_skill_info(skill) for skill in source_infos), + execution_role=SkillExecutionRole.SESSION, + ) + context = SkillProjectionContext( + cwd=tmp_path, + catalog=catalog, + backend=backend, + resolved_exploration_profile=RepositoryProfileId.LANGUAGE_NEUTRAL, + provisioning_disposition=True, + ) + + compilation = compile_session_skill_catalog(catalog, backend) + documents = materialize_agent_skill_tree(tmp_path / "skills", compilation.catalog, context) + rendered_targets = { + match.group(2) + for document in documents.values() + for match in _SPAWN_TARGET.finditer(document.content) + } + allowed = native_spawn_target_universes()[AGENT_BACKEND_CLAUDE_CODE] + + assert rendered_targets <= allowed, sorted(rendered_targets - allowed) + assert "general-purpose" in rendered_targets + assert any(target.startswith("autoskillit:") for target in rendered_targets) diff --git a/tests/workspace/test_corridor_composition.py b/tests/workspace/test_corridor_composition.py index 84abcc7127..8a13afa929 100644 --- a/tests/workspace/test_corridor_composition.py +++ b/tests/workspace/test_corridor_composition.py @@ -14,6 +14,7 @@ SkillSource, ) from tests.fakes import adapt_test_skill_semantics +from tests.workspace._helpers import _identity_test_agent_names pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] @@ -38,7 +39,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(return_value=None) + backend.setup_session_dir = MagicMock(return_value=_identity_test_agent_names()) backend.ensure_pre_launch = MagicMock(return_value=PreLaunchReadiness((), {})) return backend diff --git a/tests/workspace/test_session_skills_codex_layout.py b/tests/workspace/test_session_skills_codex_layout.py index 0cd9753474..8909efc0e8 100644 --- a/tests/workspace/test_session_skills_codex_layout.py +++ b/tests/workspace/test_session_skills_codex_layout.py @@ -761,10 +761,10 @@ def test_invocation_only_roles_are_forwarded_and_reachability_checked( "semantic_version: 1\n" "semantic_requirements:\n" " logical_roles:\n" - " - name: autoskillit:session-log-reader\n" + " - name: session-log-reader\n" " purpose: inspect one session log\n" " child_spawns:\n" - " - role: autoskillit:session-log-reader\n" + " - role: session-log-reader\n" " count: 1\n" "write_paths: inherit\n" "---\n" @@ -895,7 +895,7 @@ def test_codex_orchestrator_profile_collision_raises_during_catalog_merge( profile_skills, backend, context, - finalized_native_roles=None, + finalized_native_roles=frozenset(), ) assert {entry.name for entry in catalog_root.iterdir()} == {"sous-chef"} @@ -909,10 +909,11 @@ def test_codex_managed_orchestrator_rejects_discovery_collision( route = codex_env.backend.conventions.managed_skill_discovery assert route is not None - def setup_session_dir(session_dir: Path, **_kwargs: object) -> None: + def setup_session_dir(session_dir: Path, **_kwargs: object) -> frozenset[str]: discovery_root = route.discovery_root(session_dir) assert discovery_root is not None discovery_root.mkdir(parents=True) + return frozenset({"default", "explorer", "worker", "pluginless-explorer"}) codex_env.backend.setup_session_dir.side_effect = setup_session_dir with pytest.raises(SkillContractError, match="discovery entry point path already exists"): @@ -1251,7 +1252,7 @@ def test_profile_skills_are_projected_from_the_declared_source(tmp_path: Path) - source_skills, backend, context, - finalized_native_roles=None, + finalized_native_roles=frozenset({"helper"}), ) target = catalog_root / "my-skill" @@ -1461,10 +1462,10 @@ def test_profile_native_role_is_provisioned_before_setup_and_remains_projected( "semantic_version: 1\n" "semantic_requirements:\n" " logical_roles:\n" - " - name: autoskillit:session-log-reader\n" + " - name: session-log-reader\n" " purpose: inspect one session log\n" " child_spawns:\n" - " - role: autoskillit:session-log-reader\n" + " - role: session-log-reader\n" " count: 1\n" ), ) @@ -1800,7 +1801,7 @@ def test_missing_declared_profile_skills_dir_returns_an_empty_compilation(tmp_pa source_skills, backend, context, - finalized_native_roles=None, + finalized_native_roles=frozenset(), ) assert compilation.catalog.skills == () diff --git a/tests/workspace/test_session_skills_provider.py b/tests/workspace/test_session_skills_provider.py index 976af6ec99..64988c45d4 100644 --- a/tests/workspace/test_session_skills_provider.py +++ b/tests/workspace/test_session_skills_provider.py @@ -39,7 +39,11 @@ ) from autoskillit.workspace.skills import _skill_info_from_frontmatter from tests.fakes import adapt_test_skill_semantics -from tests.workspace._helpers import _CODEX_CAPABILITIES, _write_project_skill_override +from tests.workspace._helpers import ( + _CODEX_CAPABILITIES, + _identity_test_agent_names, + _write_project_skill_override, +) pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] @@ -91,7 +95,7 @@ def _codex_backend() -> MagicMock: profile_skills_source=None, ) backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) - backend.setup_session_dir.return_value = None + backend.setup_session_dir.return_value = _identity_test_agent_names() 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 65d2e4fbc3..595f06132c 100644 --- a/tests/workspace/test_session_skills_stale_path.py +++ b/tests/workspace/test_session_skills_stale_path.py @@ -17,7 +17,11 @@ pkg_root, ) from tests.fakes import adapt_test_skill_semantics -from tests.workspace._helpers import _CODEX_CAPABILITIES, _write_project_skill_override +from tests.workspace._helpers import ( + _CODEX_CAPABILITIES, + _identity_test_agent_names, + _write_project_skill_override, +) pytestmark = [pytest.mark.layer("workspace"), pytest.mark.small] @@ -31,7 +35,7 @@ def _codex_backend() -> MagicMock: profile_skills_source=None, ) backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) - backend.setup_session_dir.return_value = None + backend.setup_session_dir.return_value = _identity_test_agent_names() backend.validate_session_layout.return_value = [] backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics return backend diff --git a/tests/workspace/test_skill_semantics.py b/tests/workspace/test_skill_semantics.py index f2c248f6c6..97b0a50785 100644 --- a/tests/workspace/test_skill_semantics.py +++ b/tests/workspace/test_skill_semantics.py @@ -8,7 +8,12 @@ import pytest -from autoskillit.core import CODEX_MODEL_ALIASES, SkillSemanticOperation, SkillSource +from autoskillit.core import ( + CODEX_MODEL_ALIASES, + SkillInvalidityKind, + SkillSemanticOperation, + SkillSource, +) from autoskillit.core.paths import pkg_root from autoskillit.workspace.skills import ( _skill_info_from_frontmatter, @@ -189,6 +194,19 @@ def test_non_boolean_runtime_bound_is_plan_invalid(tmp_path: Path) -> None: assert diagnostics[0][0].value == "semantic_plan_invalid" +def test_namespaced_role_reports_noncanonical_invalidity(tmp_path: Path) -> None: + skill_md = tmp_path / "namespaced-role" / "SKILL.md" + declarations = _VALID_SEMANTICS.replace("reviewer", "autoskillit:session-log-reader") + _write_skill(skill_md, declarations=declarations) + + plan, diagnostics = _parse_semantic_plan(skill_md) + + assert plan is None + assert len(diagnostics) == 1 + assert diagnostics[0][0] is not SkillInvalidityKind.SEMANTIC_PLAN_INVALID + assert diagnostics[0][0] is SkillInvalidityKind.SEMANTIC_ROLE_NAME_NONCANONICAL + + @pytest.mark.parametrize( ("relative_path", "origin"), [ From 8324dc02ca264334f55b49e39a50e4954dc3dcf1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 20:26:44 -0700 Subject: [PATCH 06/11] fix: complete native-role fixtures and backend package layout --- .../session_locator.py} | 0 src/autoskillit/execution/backends/claude.py | 4 ++-- src/autoskillit/execution/child_outcomes.py | 2 +- tests/cli/test_resume_registry_identity.py | 2 +- tests/contracts/_projection_helpers.py | 4 +++- tests/execution/backends/test_backend_registry.py | 2 ++ tests/execution/backends/test_claude_session_locator.py | 2 +- tests/execution/backends/test_native_roles.py | 3 ++- tests/server/test_tools_report_bug.py | 8 ++++---- tests/workspace/test_claude_native_role_admission.py | 2 +- tests/workspace/test_skill_semantics.py | 2 +- 11 files changed, 18 insertions(+), 13 deletions(-) rename src/autoskillit/execution/backends/{_claude_session_locator.py => _claude/session_locator.py} (100%) diff --git a/src/autoskillit/execution/backends/_claude_session_locator.py b/src/autoskillit/execution/backends/_claude/session_locator.py similarity index 100% rename from src/autoskillit/execution/backends/_claude_session_locator.py rename to src/autoskillit/execution/backends/_claude/session_locator.py diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index bf6345661e..66a676c7a8 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -3,7 +3,7 @@ Owns the Claude Code env policy (`ClaudeEnvPolicy`), the host-attestation helper, and the `ClaudeCodeBackend` implementation. Public surface includes the session locator and result parsers re-exported from the -sibling modules `_claude_session_locator` and `_claude_parse`. +subpackage module `_claude/session_locator` and sibling `_claude_parse`. """ from __future__ import annotations @@ -88,6 +88,7 @@ neutralize_repository_agent_teams_settings, ) from autoskillit.execution.backends._claude.session_commands import ClaudeSessionCommandMixin +from autoskillit.execution.backends._claude.session_locator import ClaudeSessionLocator from autoskillit.execution.backends._claude_parse import ( ClaudeResultParser, ClaudeStreamParser, @@ -99,7 +100,6 @@ _PROVIDER_EXTRAS_BASE_DENYLIST, _apply_output_format, ) -from autoskillit.execution.backends._claude_session_locator import ClaudeSessionLocator from autoskillit.execution.backends._cmd_builder import CmdBuilder from autoskillit.execution.backends._explorer_dispatch import ( CLAUDE_EXPLORATION_DISPATCH_RENDERER, diff --git a/src/autoskillit/execution/child_outcomes.py b/src/autoskillit/execution/child_outcomes.py index d38406238f..8d59854594 100644 --- a/src/autoskillit/execution/child_outcomes.py +++ b/src/autoskillit/execution/child_outcomes.py @@ -396,7 +396,7 @@ def collect_codex_observed_children( def _resolve_claude_parent_transcript(parent_session_id: str) -> Path | None: - from autoskillit.execution.backends._claude_session_locator import ClaudeSessionLocator + from autoskillit.execution.backends._claude.session_locator import ClaudeSessionLocator return ClaudeSessionLocator().locate_session(parent_session_id) diff --git a/tests/cli/test_resume_registry_identity.py b/tests/cli/test_resume_registry_identity.py index 7cc45d1959..36c675769f 100644 --- a/tests/cli/test_resume_registry_identity.py +++ b/tests/cli/test_resume_registry_identity.py @@ -9,7 +9,7 @@ import autoskillit.cli.session._session_order as _patch_session__session_order import autoskillit.cli.ui._menu as _patch_ui__menu -import autoskillit.execution.backends._claude_session_locator as _locator_module +import autoskillit.execution.backends._claude.session_locator as _locator_module from autoskillit import cli from autoskillit.cli.session._session_constants import SESSION_TYPE_COOK from autoskillit.cli.session._session_launch_intent import pick_session diff --git a/tests/contracts/_projection_helpers.py b/tests/contracts/_projection_helpers.py index b5f499bad8..36c00a3916 100644 --- a/tests/contracts/_projection_helpers.py +++ b/tests/contracts/_projection_helpers.py @@ -39,6 +39,7 @@ def native_spawn_target_universes() -> dict[str, frozenset[str]]: ) backends = import_module("autoskillit.execution.backends") + codex_backend = import_module("autoskillit.execution.backends.codex") plugin_name = json.loads( (pkg_root() / ".claude-plugin" / "plugin.json").read_text(encoding="utf-8") )["name"] @@ -46,7 +47,8 @@ def native_spawn_target_universes() -> dict[str, frozenset[str]]: return { AGENT_BACKEND_CLAUDE_CODE: frozenset(backends.CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES) | {f"{plugin_name}:{name}" for name in bundled}, - AGENT_BACKEND_CODEX: frozenset(backends.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | bundled, + AGENT_BACKEND_CODEX: frozenset(codex_backend.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) + | bundled, } diff --git a/tests/execution/backends/test_backend_registry.py b/tests/execution/backends/test_backend_registry.py index eec73f06d0..2c38ff5bac 100644 --- a/tests/execution/backends/test_backend_registry.py +++ b/tests/execution/backends/test_backend_registry.py @@ -65,11 +65,13 @@ def test_all_exports_complete(self) -> None: "CODEX_RECIPE_DELIVERY_CALLING_CONTRACT", "CODEX_RECIPE_DELIVERY_CALLING_CONTRACT_DIGEST", "CompositeSessionLocator", + "CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES", "ClaudeCodeBackend", "ClaudeEnvPolicy", "ClaudeResultParser", "ClaudeSessionLocator", "ClaudeStreamParser", + "claude_resolvable_agent_names", "CodexBackend", "CodexCatalogAcquisitionError", "CodexAttestationResult", diff --git a/tests/execution/backends/test_claude_session_locator.py b/tests/execution/backends/test_claude_session_locator.py index ab3078f7f4..32fcb5b1e0 100644 --- a/tests/execution/backends/test_claude_session_locator.py +++ b/tests/execution/backends/test_claude_session_locator.py @@ -5,7 +5,7 @@ import pytest -import autoskillit.execution.backends._claude_session_locator as _locator_module +import autoskillit.execution.backends._claude.session_locator as _locator_module from autoskillit.core import ( SessionLocator, SessionSummary, diff --git a/tests/execution/backends/test_native_roles.py b/tests/execution/backends/test_native_roles.py index 70d690d92f..52a19fabaa 100644 --- a/tests/execution/backends/test_native_roles.py +++ b/tests/execution/backends/test_native_roles.py @@ -4,7 +4,7 @@ import pytest -from autoskillit.core import LogicalRoleSpec, SkillSemanticPlan +from autoskillit.core import ChildSpawnSpec, LogicalRoleSpec, SkillSemanticPlan pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] @@ -40,6 +40,7 @@ def test_map_declared_logical_roles( plan = SkillSemanticPlan( schema_version=1, + child_spawns=(ChildSpawnSpec(role="evaluated-agent", count=1),), logical_roles=( LogicalRoleSpec(name="delegated-worker", purpose="perform general work"), LogicalRoleSpec(name="plan-foundation-auditor", purpose="audit a plan"), diff --git a/tests/server/test_tools_report_bug.py b/tests/server/test_tools_report_bug.py index dab63d3972..446008c145 100644 --- a/tests/server/test_tools_report_bug.py +++ b/tests/server/test_tools_report_bug.py @@ -331,17 +331,17 @@ async def test_report_bug_delivers_winning_override_identity_and_projection( "semantic_version: 1\n" "semantic_requirements:\n" " logical_roles:\n" - " - name: report-investigator\n" + " - name: delegated-worker\n" " purpose: investigate the report independently\n" " child_spawns:\n" - " - role: report-investigator\n" + " - role: delegated-worker\n" " count: 1\n" " child_model_policies:\n" - " - role: report-investigator\n" + " - role: delegated-worker\n" " model_class: sonnet\n" "---\n" "winning report override body\n" - "Spawn a child assigned logical role `report-investigator` under its declared " + "Spawn a child assigned logical role `delegated-worker` under its declared " "sonnet model-class policy.\n" ) source_before = override.read_bytes() diff --git a/tests/workspace/test_claude_native_role_admission.py b/tests/workspace/test_claude_native_role_admission.py index 9388823ec0..5dc553d59a 100644 --- a/tests/workspace/test_claude_native_role_admission.py +++ b/tests/workspace/test_claude_native_role_admission.py @@ -82,7 +82,7 @@ def test_claude_catalog_prunes_unresolvable_skill( backend=backend, names=frozenset({"unresolvable-target", "portable-review"}), ) as managed: - catalog_root = Path(managed.skills_dir.path) / "skills" + catalog_root = Path(managed.skills_dir.path) / backend.conventions.skills_subdir unavailable = { item["skill"]: item["operation"] for item in managed.unavailability_payload["unavailable"] diff --git a/tests/workspace/test_skill_semantics.py b/tests/workspace/test_skill_semantics.py index 97b0a50785..7c6df047d7 100644 --- a/tests/workspace/test_skill_semantics.py +++ b/tests/workspace/test_skill_semantics.py @@ -545,7 +545,7 @@ def test_runtime_bound_spawns_have_no_static_native_target(tmp_path: Path) -> No claude = compile_session_skill_catalog(catalog, backends.ClaudeCodeBackend()) assert tuple(skill.name for skill in claude.catalog.skills) == ("runtime-bound",) - assert claude.required_native_roles["runtime-bound"] == ("delegated-worker",) + assert claude.required_native_roles["runtime-bound"] == ("general-purpose",) assert "evaluated-agent" not in claude.required_native_roles["runtime-bound"] codex = compile_session_skill_catalog(catalog, backends.CodexBackend()) From 166e2b17845205fdee3c54a8f2c839689541840d Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:15:11 -0700 Subject: [PATCH 07/11] fix(review): share Claude delegated worker agent name --- src/autoskillit/execution/backends/_native_roles.py | 5 ++++- src/autoskillit/execution/backends/claude.py | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/execution/backends/_native_roles.py b/src/autoskillit/execution/backends/_native_roles.py index b5e40bd04f..54183dc12e 100644 --- a/src/autoskillit/execution/backends/_native_roles.py +++ b/src/autoskillit/execution/backends/_native_roles.py @@ -10,14 +10,17 @@ ) __all__ = [ + "CLAUDE_DELEGATED_WORKER_AGENT", "CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES", "claude_resolvable_agent_names", "map_declared_logical_roles", ] +CLAUDE_DELEGATED_WORKER_AGENT = "general-purpose" + # Common CLI/headless sessions share these; claude-code-guide is excluded from sdk-cli. CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES = ( - "general-purpose", + CLAUDE_DELEGATED_WORKER_AGENT, "Explore", "Plan", "claude", diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 66a676c7a8..7b1b63be2a 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -105,6 +105,7 @@ CLAUDE_EXPLORATION_DISPATCH_RENDERER, ) from autoskillit.execution.backends._native_roles import ( + CLAUDE_DELEGATED_WORKER_AGENT, CLAUDE_SPAWNABLE_BUILT_IN_AGENT_NAMES, claude_resolvable_agent_names, map_declared_logical_roles, @@ -130,7 +131,7 @@ def _claude_logical_role_mapping(plan: SkillSemanticPlan) -> dict[str, str]: return map_declared_logical_roles( plan, - delegated_worker_agent="general-purpose", + delegated_worker_agent=CLAUDE_DELEGATED_WORKER_AGENT, agent_namespace=CLAUDE_PLUGIN_AGENT_NAMESPACE, ) From a477ca68399c3cb77ec2f5c8a2950cdc479b4392 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:17:36 -0700 Subject: [PATCH 08/11] fix(review): reject malformed migration role declarations --- src/autoskillit/migration/adapters_skill.py | 12 +++++- tests/migration/test_engine_skill.py | 45 ++++++++++++++++++++- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/autoskillit/migration/adapters_skill.py b/src/autoskillit/migration/adapters_skill.py index ea7efc3aa9..425e86f329 100644 --- a/src/autoskillit/migration/adapters_skill.py +++ b/src/autoskillit/migration/adapters_skill.py @@ -138,8 +138,16 @@ def _canonicalize_logical_role_names(data: dict[str, Any]) -> str | None: ("child_spawns", "role"), ("child_model_policies", "role"), ): - for declaration in requirements.get(collection, ()): - name = declaration[field].removeprefix(CLAUDE_PLUGIN_AGENT_NAMESPACE) + declarations = requirements.get(collection, []) + if not isinstance(declarations, list): + return f"semantic_requirements.{collection} must be a list" + for declaration in declarations: + if not isinstance(declaration, dict): + return f"semantic_requirements.{collection} entries must be mappings" + raw_name = declaration.get(field) + if not isinstance(raw_name, str): + return f"semantic_requirements.{collection}.{field} must be a string" + name = raw_name.removeprefix(CLAUDE_PLUGIN_AGENT_NAMESPACE) declaration[field] = name if collection == "logical_roles" and AGENT_NAME_PATTERN.fullmatch(name) is None: return f"cannot canonicalize logical role name {name!r}" diff --git a/tests/migration/test_engine_skill.py b/tests/migration/test_engine_skill.py index b76eb1c838..e2ba19c5bf 100644 --- a/tests/migration/test_engine_skill.py +++ b/tests/migration/test_engine_skill.py @@ -8,7 +8,7 @@ import pytest -from autoskillit.core import ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS +from autoskillit.core import ALL_PROJECT_LOCAL_SKILL_SEARCH_DIRS, dump_yaml_str from autoskillit.migration.adapters_skill import SkillMigrationAdapter from autoskillit.migration.engine import ( DeterministicMigrationAdapter, @@ -257,3 +257,46 @@ async def test_migration_reports_uncanonicalizable_role_name(self, tmp_path: Pat assert result.error == "cannot canonicalize logical role name 'Bad_Name'" assert skill_path.read_bytes() == original assert not skill_path.with_suffix(".yaml.bak").exists() + + @pytest.mark.anyio + @pytest.mark.parametrize( + ("collection", "declarations", "error"), + [ + ("child_spawns", None, "semantic_requirements.child_spawns must be a list"), + ( + "child_spawns", + ["invalid"], + "semantic_requirements.child_spawns entries must be mappings", + ), + ("child_spawns", [{}], "semantic_requirements.child_spawns.role must be a string"), + ( + "child_spawns", + [{"role": None}], + "semantic_requirements.child_spawns.role must be a string", + ), + ], + ) + async def test_migration_reports_invalid_role_declarations( + self, tmp_path: Path, collection: str, declarations: object, error: str + ) -> None: + workspace = importlib.import_module("autoskillit.workspace") + skill_path = tmp_path / ".claude" / "skills" / "role-migration" / "SKILL.md" + skill_path.parent.mkdir(parents=True) + skill_path.write_text(_skill_with_logical_role("autoskillit:web-evidence-researcher")) + parsed = workspace.read_skill_frontmatter(skill_path) + assert parsed.data is not None + parsed.data["semantic_requirements"][collection] = declarations + original = f"---\n{dump_yaml_str(parsed.data)}---\n{parsed.body}" + skill_path.write_text(original) + file = MigrationFile( + name="role-migration", path=skill_path, file_type="skill", current_version=None + ) + + result = await MigrationEngine([SkillMigrationAdapter()]).migrate_file( + file, run_headless=AsyncMock(), temp_dir=tmp_path / "temp" + ) + + assert result.success is False + assert result.error == error + assert skill_path.read_text() == original + assert not skill_path.with_suffix(".yaml.bak").exists() From 20e31143f1b135d6ab5be71e4f0fc4d4809475b8 Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:19:16 -0700 Subject: [PATCH 09/11] fix(review): validate canonical child role references --- src/autoskillit/migration/adapters_skill.py | 4 ++-- tests/migration/test_engine_skill.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/migration/adapters_skill.py b/src/autoskillit/migration/adapters_skill.py index 425e86f329..cdbd233511 100644 --- a/src/autoskillit/migration/adapters_skill.py +++ b/src/autoskillit/migration/adapters_skill.py @@ -148,9 +148,9 @@ def _canonicalize_logical_role_names(data: dict[str, Any]) -> str | None: if not isinstance(raw_name, str): return f"semantic_requirements.{collection}.{field} must be a string" name = raw_name.removeprefix(CLAUDE_PLUGIN_AGENT_NAMESPACE) - declaration[field] = name - if collection == "logical_roles" and AGENT_NAME_PATTERN.fullmatch(name) is None: + if AGENT_NAME_PATTERN.fullmatch(name) is None: return f"cannot canonicalize logical role name {name!r}" + declaration[field] = name return None diff --git a/tests/migration/test_engine_skill.py b/tests/migration/test_engine_skill.py index e2ba19c5bf..de9cdef2b3 100644 --- a/tests/migration/test_engine_skill.py +++ b/tests/migration/test_engine_skill.py @@ -274,6 +274,16 @@ async def test_migration_reports_uncanonicalizable_role_name(self, tmp_path: Pat [{"role": None}], "semantic_requirements.child_spawns.role must be a string", ), + ( + "child_spawns", + [{"role": "autoskillit:Bad_Name", "count": 1}], + "cannot canonicalize logical role name 'Bad_Name'", + ), + ( + "child_model_policies", + [{"role": "autoskillit:Bad_Name", "model_class": "opus"}], + "cannot canonicalize logical role name 'Bad_Name'", + ), ], ) async def test_migration_reports_invalid_role_declarations( From 2f9213bdbc20973f45fccb1953ba9c80a271631c Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:20:18 -0700 Subject: [PATCH 10/11] fix(review): decouple admission test from list formatting --- tests/workspace/test_claude_native_role_admission.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/workspace/test_claude_native_role_admission.py b/tests/workspace/test_claude_native_role_admission.py index 5dc553d59a..729a306a3d 100644 --- a/tests/workspace/test_claude_native_role_admission.py +++ b/tests/workspace/test_claude_native_role_admission.py @@ -59,7 +59,7 @@ def test_claude_invocation_with_unresolvable_target_fails_closed( with pytest.raises( SkillContractError, - match=r"native child-spawn targets are unavailable: \['autoskillit:nonexistent-agent'\]", + match=r"native child-spawn targets are unavailable.*autoskillit:nonexistent-agent", ): manager.materialize_invocation( "unresolvable-target-admission", From 5d1353849eb219b3d9fbf3881466c26d6126240a Mon Sep 17 00:00:00 2001 From: Trecek Date: Sun, 4 Oct 2026 23:32:19 -0700 Subject: [PATCH 11/11] fix(review): refresh forwarding inventory after worker import --- tests/_ambient_env_surface.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_ambient_env_surface.py b/tests/_ambient_env_surface.py index 53485cebad..57c07c3d72 100644 --- a/tests/_ambient_env_surface.py +++ b/tests/_ambient_env_surface.py @@ -1017,7 +1017,7 @@ class AmbientEnvDisposition: "Unfiltered dict(os.environ) base for the global-Codex-home MCP-inventory validation" "probe subprocess, with CODEX_RESERVED_HOME_ENV_VARS overridden to the source home." ), - "execution/backends/claude.py:340": ( + "execution/backends/claude.py:341": ( "Excludes _INTERACTIVE_ENV_EXCLUSIONS (TERM/NO_COLOR headless-hardening keys) when" "building the interactive Claude Code base env." ),