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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/autoskillit/config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
_MAX_CONCURRENT_DISPATCHES as _MAX_CONCURRENT_DISPATCHES,
)
from autoskillit.config.ingredient_defaults import (
BACKEND_CAPABILITY_INGREDIENTS,
SERVER_AUTHORITATIVE_INGREDIENTS,
apply_config_authoritative_overrides,
build_config_authoritative_layer,
Expand Down Expand Up @@ -57,6 +56,7 @@
validate_layer_keys,
write_config_layer,
)
from autoskillit.core import BACKEND_CAPABILITY_INGREDIENTS

__all__ = [
"AgentBackendConfig",
Expand Down
6 changes: 0 additions & 6 deletions src/autoskillit/config/ingredient_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,6 @@ def iter_display_categories(
"backend_supports_git_write",
}

BACKEND_CAPABILITY_INGREDIENTS: frozenset[str] = frozenset(
{
"backend_supports_git_write",
}
)


def resolve_ingredient_defaults(project_dir: Path) -> dict[str, str]:
"""Resolve auto-detect ingredient values from the project environment."""
Expand Down
2 changes: 2 additions & 0 deletions src/autoskillit/core/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ from .types import AUTOSKILLIT_INSTALLED_VERSION as AUTOSKILLIT_INSTALLED_VERSIO
from .types import AUTOSKILLIT_PRIVATE_ENV_VARS as AUTOSKILLIT_PRIVATE_ENV_VARS
from .types import AUTOSKILLIT_SKILL_PREFIX as AUTOSKILLIT_SKILL_PREFIX
from .types import AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES as AUTOSKILLIT_WRITE_GUARD_TOOL_NAMES
from .types import BACKEND_CAPABILITY_INGREDIENTS as BACKEND_CAPABILITY_INGREDIENTS
from .types import CAMPAIGN_ID_ENV_VAR as CAMPAIGN_ID_ENV_VAR
from .types import CAPABILITY_GATE_CALLABLES as CAPABILITY_GATE_CALLABLES
from .types import CAPTURE_VALID_VALUE_TYPES as CAPTURE_VALID_VALUE_TYPES
from .types import CATEGORY_TAGS as CATEGORY_TAGS
from .types import CLAUDE_CODE_CAPABILITIES as CLAUDE_CODE_CAPABILITIES
Expand Down
26 changes: 26 additions & 0 deletions src/autoskillit/core/types/_type_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"RUN_PYTHON_SENTINEL_KEYS",
"SCOPE_DIRECTION_SOURCE_TYPES",
"WORKTREE_SKILLS",
"BACKEND_CAPABILITY_INGREDIENTS",
"CAPABILITY_GATE_CALLABLES",
"SkillFamilyDef",
"GITHUB_API_SKILL_FAMILIES",
"CODEX_SESSIONS_SUBDIR",
Expand Down Expand Up @@ -83,6 +85,30 @@
}
)

# Ingredient keys that are derived from a live runtime backend's capabilities
# (not from LLM-supplied values). Used to gate recipe admission control — a
# recipe is refused at load time when an ingredient in this set resolves to a
# falsy value while a surviving step depends on it. Moved to IL-0 so it is
# importable by the recipe layer (IL-2) without violating the IL-006 contract
# that forbids recipe → config imports.
BACKEND_CAPABILITY_INGREDIENTS: frozenset[str] = frozenset(
{
"backend_supports_git_write",
}
)

# Bare (un-dotted) callable names whose run_python steps are capability gates.
# Each entry corresponds to a callable in smoke_utils that reads a
# BACKEND_CAPABILITY_INGREDIENTS key and returns a verdict dict. When a
# surviving step's with_args["callable"] final component matches an entry here
# and the corresponding capability ingredient resolves falsy, the recipe is
# marked dispatch-infeasible at load time.
CAPABILITY_GATE_CALLABLES: frozenset[str] = frozenset(
{
"gate_backend_write",
}
)


class SkillFamilyDef(NamedTuple):
"""Skill family definition — groups sibling skills that must share API patterns."""
Expand Down
4 changes: 4 additions & 0 deletions src/autoskillit/hooks/formatters/_fmt_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"stop_step_semantics", # delivered via open_kitchen response Channel B; not redisplayed
"deferred_guards", # internal deferral metadata; not displayed to agent
"post_prune_step_names", # internal preflight field; not displayed to agent
"dispatch_feasible", # internal admission control signal; surfaced via refusal envelopes
"infeasible_steps", # internal admission control detail; surfaced via refusal envelopes
}
)

Expand Down Expand Up @@ -185,6 +187,8 @@ def _fmt_load_recipe(data: LoadRecipeResult, pipeline: bool) -> str:
"hook_warning", # edge-case diagnostic; not rendered in standard path
"deferred_guards", # internal deferral metadata; not displayed to agent
"post_prune_step_names", # internal preflight field; not displayed to agent
"dispatch_feasible", # internal admission control signal; surfaced via refusal envelopes
"infeasible_steps", # internal admission control detail; surfaced via refusal envelopes
}
)

Expand Down
20 changes: 20 additions & 0 deletions src/autoskillit/recipe/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from typing import Any, cast

from autoskillit.core import (
BACKEND_CAPABILITY_INGREDIENTS,
CAPABILITY_GATE_CALLABLES,
ProcessStaleError,
RecipeNotFoundError,
RecipeSource,
Expand Down Expand Up @@ -45,6 +47,7 @@
from autoskillit.recipe._recipe_composition import (
_assert_content_integrity,
_build_active_recipe,
_compute_capability_feasibility,
_prune_skipped_steps,
_resolve_hidden_inputs_in_content,
_resolve_skip_guards_in_content,
Expand Down Expand Up @@ -318,6 +321,8 @@ def load_and_validate(
active_recipe = None
_skip_resolutions: dict[str, bool | None] = {}
_pre_prune_steps: dict[str, Any] = {}
_dispatch_feasible = True
_infeasible_steps: list[str] = []

# Determine recipes_dir from source
if match.source == RecipeSource.BUILTIN:
Expand Down Expand Up @@ -393,6 +398,18 @@ def load_and_validate(
raw = ""
t0 = _t("prune_skipped_steps", t0, name)

# Stage: capability feasibility — detect DOA pipelines where a
# surviving capability-gated run_python step has no recovery route
# for a falsy capability ingredient. This refuses the pipeline
# before any side-effect step is executed.
_dispatch_feasible, _infeasible_steps = _compute_capability_feasibility(
active_recipe,
ingredient_overrides or {},
capability_ingredient_keys=BACKEND_CAPABILITY_INGREDIENTS,
capability_gate_callables=CAPABILITY_GATE_CALLABLES,
)
t0 = _t("capability_feasibility", t0, name)

# Stage: semantic rules (builds ValidationContext from post-prune recipe)
if lister is None:
from autoskillit.workspace import DefaultSkillResolver # noqa: PLC0415
Expand Down Expand Up @@ -582,6 +599,9 @@ def load_and_validate(
result["deferred_guards"] = _deferred_guard_list
if active_recipe is not None:
result["post_prune_step_names"] = list(active_recipe.steps.keys())
result["dispatch_feasible"] = _dispatch_feasible
if _infeasible_steps:
result["infeasible_steps"] = _infeasible_steps

# Write to cache (only when recipe was found and fully processed)
if match is not None:
Expand Down
1 change: 1 addition & 0 deletions src/autoskillit/recipe/_api_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def copy_result(self, result: Any) -> dict[str, Any]:
"requires_packs",
"requires_features",
"deferred_guards",
"infeasible_steps",
):
if list_key in r:
r[list_key] = list(r[list_key])
Expand Down
54 changes: 54 additions & 0 deletions src/autoskillit/recipe/_recipe_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,60 @@ def _is_ingredient_truthy(value: str) -> bool:
return bool(value) and value.lower() not in FALSY_STRINGS


def _compute_capability_feasibility(
post_prune_recipe: Any,
ingredient_overrides: dict[str, str],
*,
capability_ingredient_keys: frozenset[str],
capability_gate_callables: frozenset[str],
) -> tuple[bool, list[str]]:
"""Detect dead-on-arrival pipelines caused by capability-gated run_python steps.

A pipeline is DOA when a surviving post-prune step:
1. Has tool="run_python" with step.with_args["callable"] whose final dotted
component matches an entry in capability_gate_callables.
2. The step reads an ingredient (via with_args keys that overlap
capability_ingredient_keys) that resolves to a falsy value under the
current ingredient_overrides.
3. The step's on_result / on_failure routes the falsy case to a terminal
failure target ("escalate") rather than recovery.

Returns (is_feasible, infeasible_step_names).
"""
if not post_prune_recipe or not getattr(post_prune_recipe, "steps", None):
return True, []

infeasible: list[str] = []
steps = post_prune_recipe.steps
for step_name, step in steps.items():
if step is None:
continue
if getattr(step, "tool", None) != "run_python":
continue
with_args = getattr(step, "with_args", None) or {}
callable_dotted = with_args.get("callable", "")
if not callable_dotted or "." not in callable_dotted:
continue
bare_name = callable_dotted.rsplit(".", 1)[-1]
if bare_name not in capability_gate_callables:
continue
gate_input_keys = {k for k in with_args.keys() if k in capability_ingredient_keys}
if not gate_input_keys:
continue
all_falsy = all(
not _is_ingredient_truthy(str(ingredient_overrides.get(k, "")))
for k in gate_input_keys
)
if not all_falsy:
continue
on_failure = getattr(step, "on_failure", None) or "escalate"
on_exhausted = getattr(step, "on_exhausted", None) or "escalate"
if on_failure == "escalate" or on_exhausted == "escalate":
infeasible.append(step_name)

return (not infeasible), infeasible


def _drop_sub_recipe_step(recipe: Any, step_name: str) -> Any:
"""Return a new Recipe with the named sub_recipe placeholder step removed."""
new_steps = {k: v for k, v in recipe.steps.items() if k != step_name}
Expand Down
6 changes: 5 additions & 1 deletion src/autoskillit/recipe/_recipe_ingredients.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ class LoadRecipeResult(TypedDict, total=False):
recipe_version: str | None
deferred_guards: list[DeferredGuard]
post_prune_step_names: list[str]
dispatch_feasible: bool
infeasible_steps: list[str]


class OpenKitchenResult(TypedDict, total=False):
Expand All @@ -135,7 +137,7 @@ class OpenKitchenResult(TypedDict, total=False):
Extends LoadRecipeResult with three post-return keys injected by the handler.
"""

# Inherited from LoadRecipeResult (17 keys)
# Inherited from LoadRecipeResult (19 keys)
content: str
errors: list[str]
diagram: str | None
Expand All @@ -153,6 +155,8 @@ class OpenKitchenResult(TypedDict, total=False):
recipe_version: str | None
deferred_guards: list[DeferredGuard]
post_prune_step_names: list[str]
dispatch_feasible: bool
infeasible_steps: list[str]
# Post-return keys injected by open_kitchen handler (4 keys)
success: bool
kitchen: str
Expand Down
47 changes: 47 additions & 0 deletions src/autoskillit/server/tools/tools_kitchen.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,36 @@ def _recipe_validation_error_response(name: str, result: dict[str, Any]) -> str:
)


async def _dispatch_infeasible_response(
result: dict[str, Any],
backend: Any,
gate: Any,
ctx: Context,
) -> str:
"""Refuse a dead-on-arrival pipeline before the gate is enabled.

Used by the open_kitchen (both normal and deferred-recall) paths when
load_and_validate reports dispatch_feasible=False.
"""
gate.disable()
await ctx.disable_components(tags={"kitchen"})
_infeasible = result.get("infeasible_steps", [])
_backend_name = backend.name if backend is not None else "unknown"
return json.dumps(
{
"success": False,
"kitchen": "dispatch_infeasible",
"infeasible_steps": _infeasible,
"ingredients_table": result.get("ingredients_table"),
"user_visible_message": (
f"Cannot dispatch recipe: backend {_backend_name!r} "
f"causes steps {_infeasible} to route to terminal failure. "
f"Use a backend with git_metadata_writable=True (e.g. claude-code)."
),
}
)


class QuotaGuardHookPayload(TypedDict):
cache_max_age: int
cache_path: str
Expand Down Expand Up @@ -433,6 +463,14 @@ def get_recipe(name: str) -> str:
"suggestions": result.get("suggestions", []),
}
)
if not result.get("dispatch_feasible", True):
return json.dumps(
{
"error": "Recipe is infeasible on current backend",
"dispatch_feasible": False,
"infeasible_steps": result.get("infeasible_steps", []),
}
)
return result.get("content", json.dumps({"error": "Recipe composition failed."}))


Expand Down Expand Up @@ -673,6 +711,10 @@ async def open_kitchen(
tool_ctx.gate.disable()
tool_ctx.gate_infrastructure_ready = False
return _recipe_validation_error_response(name, result)
if not result.get("dispatch_feasible", True):
return await _dispatch_infeasible_response(
result, tool_ctx.backend, tool_ctx.gate, ctx
)
# Dispatch-feasibility preflight: verify the backend can enforce
# all fix-required hooks for the recipe's run_skill steps.
if tool_ctx.active_recipe_steps is not None:
Expand Down Expand Up @@ -782,6 +824,11 @@ async def open_kitchen(
tool_ctx.gate_infrastructure_ready = False
return _recipe_validation_error_response(name, result)

if not result.get("dispatch_feasible", True):
return await _dispatch_infeasible_response(
result, tool_ctx.backend, tool_ctx.gate, ctx
)

# Dispatch-feasibility preflight: verify the backend can enforce
# all fix-required hooks for the recipe's run_skill steps.
if tool_ctx.active_recipe_steps is not None:
Expand Down
6 changes: 6 additions & 0 deletions src/autoskillit/server/tools/tools_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ async def load_recipe(
result = await _apply_triage_gate(result, name, recipe_info=recipe_info)
if not result.get("valid", False):
result["validation_failed"] = True
if not result.get("dispatch_feasible", True):
result["dispatch_infeasible"] = True
result["user_visible_message"] = (
f"Recipe is infeasible on current backend: "
f"steps {result.get('infeasible_steps', [])} route to terminal failure."
)
if ingredients_only:
result = strip_ingredients_only_keys(result)
return json.dumps(result)
Expand Down
1 change: 1 addition & 0 deletions tests/_test_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,6 +756,7 @@ class ImportContext(enum.StrEnum):
"server/test_tools_kitchen_visibility.py",
"server/test_tools_execution_step_resolution.py",
"server/test_tools_execution_input_gates.py",
"server/test_capability_admission_e2e.py",
# CLI file-level entries (6 of 38 import autoskillit.recipe):
"cli/test_cli_prompts.py",
"cli/test_l3_orchestrator_prompt.py",
Expand Down
1 change: 1 addition & 0 deletions tests/arch/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests.
| `test_backend_flag_isolation.py` | AST guard: ClaudeFlags must not appear in _session_launch.py — backend-specific flags belong inside each backend's build_interactive_cmd() |
| `test_backend_coherence.py` | Architectural tests for backend coherence enforcement |
| `test_canonical_constant_consumption.py` | Architectural invariant: every *_ENV_FORWARD_VARS constant must have a production consumer |
| `test_capability_admission_control.py` | Architectural structural tests for capability admission control: dispatch_feasible gating on all four content-serving surfaces, _compute_capability_feasibility call in load_and_validate, CAPABILITY_GATE_CALLABLES/BACKEND_CAPABILITY_INGREDIENTS registry sync |
| `test_backend_name_sync.py` | Architectural invariant: KNOWN_BACKEND_NAMES (IL-0) must match BACKEND_REGISTRY keys (IL-1) |
| `test_capability_consistency.py` | Behavioral arch tests: BackendCapabilities filesystem consistency — applicable guards exist on disk, required session files are created, session_dir_symlinks entries are symlinks |
| `test_cross_registry_dispatch_sufficiency.py` | Cross-registry dispatch sufficiency: real HOOK_REGISTRY × real BackendCapabilities — detects fix-required hooks that would brick backends with insufficient applicable_guards |
Expand Down
Loading
Loading