diff --git a/AGENTS.md b/AGENTS.md index 3b19674fc..d44206323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,6 +103,7 @@ generic_automation_mcp/ | `migration/` | IL-2 | Versioned migration engine + failure store | | `fleet/` | IL-2 | Campaign dispatch, semaphore, sidecar, liveness, state persistence | | `server/` | IL-3 | FastMCP server — tools/, kitchen gating, session-type dispatch | +| `server/recipe_section/` | IL-3 | Final invariant verification for schema-driven recipe-section page plans | | `cli/` | IL-3 | CLI — doctor/, update/, fleet/ subcommands, ui/, session/ management | | `hooks/` | — | coding-agent hook scripts — guards/, formatters/ | | `agents/` | — | Bundled agent definition markdown files served as MCP resources | diff --git a/docs/decisions/0004-recipe-redelivery.md b/docs/decisions/0004-recipe-redelivery.md index 262c4aa3d..9b6ef656f 100644 --- a/docs/decisions/0004-recipe-redelivery.md +++ b/docs/decisions/0004-recipe-redelivery.md @@ -50,6 +50,25 @@ is relaxed, the `load_recipe` / `open_kitchen` / `get_recipe_section` end-to-end recovery path — including envelope re-delivery plus per-step pulls — must be tested as the recovery path. +### Schema-driven pull continuation + +The fixed pullable sections are `content`, `ingredients_table`, `orchestration_rules`, +`stop_step_semantics`, `errors`, and `warnings`; a validated post-prune step is a +separate dynamic raw-YAML definition. Every page pins `pagination_version`, +`section_registry_sha256`, `section_sha256`, and `page_plan_sha256`. + +The four exhaustive reconstruction algorithms are: + +- `raw-text`: verify contiguous UTF-8 byte ranges and concatenate content. +- `json-array-page`: run `json.loads` on every complete array page and extend in order. +- `json-scalar-page`: run `json.loads` on every string page and concatenate decoded text. +- `json-element-fragment`: decode string fragments, concatenate one canonical element, + verify `element_sha256`, then parse that element once. + +Consumers reject an unknown pagination version or unknown content format, mixed +identities, gaps, overlaps, duplicates, a page after the terminal page, or a terminal +page carrying `next_part`. They must not guess or repair a malformed continuation. + ## Consequences - `recipe_read_guard.py` error messages direct the agent to call `load_recipe` (and, @@ -59,4 +78,4 @@ the recovery path. `load_recipe` / `open_kitchen` / `get_recipe_section` re-delivery restores full pipeline execution capability, including envelope re-delivery plus per-step pulls. - `test_copied_config_has_auto_compact_limit` validates the primary defense path: - `setup_session_dir` preserves the override in the copied `config.toml`. \ No newline at end of file + `setup_session_dir` preserves the override in the copied `config.toml`. diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index 2ebb9118f..48a9404e9 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -150,6 +150,21 @@ recorded explicitly in the issue body. explicit decision through final enforcement. History retention is never read as the selected outer result. +### Recipe-section byte budget + +`RECIPE_SECTION_MANDATORY_FAILURE_CODES` defines the exact compact code-only failures, +and `RECIPE_SECTION_RESPONSE_FLOOR_BYTES` is their maximum UTF-8 size. Configuration +below that floor is invalid. After request admission, the request-specific ceiling is: + +`recipe_section_bound_bytes = min(response_max_bytes, conservative_general_result_limit)` + +The current ordinary Codex policy deliberately keeps the conservative limit at +10,000 bytes. It is not the generic token×4 projection. Planning trial-renders each +complete outer response with compact canonical JSON and accepts only pages within the +captured UTF-8 bound. Oversized arrays use complete pages or +`json-element-fragment`; there is no truncation and no dropped element. A terminal +page omits `next_part`. + ## Operational Signals Output-budget instrumentation uses low-cardinality structured counters or events for: diff --git a/docs/research/codex-delivery-conformance.md b/docs/research/codex-delivery-conformance.md index e8ba35578..91711ce9f 100644 --- a/docs/research/codex-delivery-conformance.md +++ b/docs/research/codex-delivery-conformance.md @@ -2,7 +2,7 @@ Status: blocked -**Probe contract:** `codex-recipe-delivery-v1` +**Probe contract:** `codex-recipe-delivery-v2` **Decision date:** 2026-07-22 **Model identity:** `gpt-5.6-sol` @@ -54,6 +54,11 @@ probe retained the bounded `recipe_pull`, pulled content with the same `body_sha no transport-truncation marker, and echoed the digest in the terminal next-request canary. `task test-smoke-codex` passed the dedicated oracle on `gpt-5.6-sol`. +The v2 oracle also creates multiple server-authoritative override warnings, including one +oversized `warnings` element. It reconstructs the ordered list from `json-array-page` and +`json-element-fragment` pages, verifies `section_sha256`, `element_sha256`, and +`page_plan_sha256`, and requires terminal omission of `next_part`. + This pass proves the fail-closed envelope/pull recovery path. It does not prove an authoritative selected outer limit, raw pre-truncation bytes, or a protected pre-call event, so it cannot enable `ATTESTED_INLINE`. diff --git a/src/autoskillit/cli/_prompts_kitchen.py b/src/autoskillit/cli/_prompts_kitchen.py index 6512a48c8..c646aec8c 100755 --- a/src/autoskillit/cli/_prompts_kitchen.py +++ b/src/autoskillit/cli/_prompts_kitchen.py @@ -75,6 +75,14 @@ def _build_open_kitchen_prompt( "artifact_blob_size_bytes=recipe_pull.artifact_blob_size_bytes, " "body_sha256=recipe_pull.body_sha256, " "body_size_bytes=recipe_pull.body_size_bytes). " + "Require a known pagination_version and stable section_registry_sha256, " + "section_sha256, and page_plan_sha256 across pages. Reconstruct raw-text by " + "concatenating byte ranges; json-array-page by JSON-decoding and extending; " + "json-scalar-page by JSON-decoding and concatenating strings; and " + "json-element-fragment by JSON-decoding fragments, concatenating and verifying " + "the canonical element, then parsing it once. Reject unknown pagination_version " + "or unknown content_format; do not guess. Follow has_more/next_part, and require " + "a terminal page to omit next_part. " "Do not read recipe YAML files directly.\n\n" "OPTIONAL STEP SEMANTICS:\n" "- optional: true means the step is SKIPPED when its skip_when_false ingredient\n" diff --git a/src/autoskillit/config/_config_dataclasses.py b/src/autoskillit/config/_config_dataclasses.py index 38952558e..e5b205a2d 100644 --- a/src/autoskillit/config/_config_dataclasses.py +++ b/src/autoskillit/config/_config_dataclasses.py @@ -11,6 +11,7 @@ DRY_WALKTHROUGH_VERIFIED_MARKER, KNOWN_BACKEND_NAMES, LABEL_LIFECYCLE_REGISTRY, + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, IssueLabelState, OutputFormat, get_logger, @@ -358,8 +359,10 @@ class OutputBudgetConfig: shell_max_inline_bytes: int = 12_000 def __post_init__(self) -> None: - if self.response_max_bytes <= 0: - raise ValueError("response_max_bytes must be positive") + if self.response_max_bytes < RECIPE_SECTION_RESPONSE_FLOOR_BYTES: + raise ValueError( + f"response_max_bytes must be at least {RECIPE_SECTION_RESPONSE_FLOOR_BYTES} bytes" + ) @dataclass diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index f10c7002f..a6dd3f675 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -170,6 +170,7 @@ from .types import CORE_PACKS as CORE_PACKS from .types import DATA_MANIFEST_SOURCE_TYPES as DATA_MANIFEST_SOURCE_TYPES 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 DYNAMIC_RECIPE_SECTION_DEF as DYNAMIC_RECIPE_SECTION_DEF from .types import FEATURE_REGISTRY as FEATURE_REGISTRY from .types import FLEET_DISPATCH_MODE as FLEET_DISPATCH_MODE from .types import FLEET_DISPATCH_TOOLS as FLEET_DISPATCH_TOOLS @@ -220,6 +221,19 @@ from .types import ( ) from .types import RECIPE_PACK_REGISTRY as RECIPE_PACK_REGISTRY from .types import RECIPE_PACK_TAGS as RECIPE_PACK_TAGS +from .types import ( + RECIPE_SECTION_CONTENT_FORMAT_REGISTRY as RECIPE_SECTION_CONTENT_FORMAT_REGISTRY, +) +from .types import ( + RECIPE_SECTION_MANDATORY_FAILURE_CODES as RECIPE_SECTION_MANDATORY_FAILURE_CODES, +) +from .types import ( + RECIPE_SECTION_PAGINATION_POLICY_DIGEST as RECIPE_SECTION_PAGINATION_POLICY_DIGEST, +) +from .types import RECIPE_SECTION_PAGINATION_VERSION as RECIPE_SECTION_PAGINATION_VERSION +from .types import RECIPE_SECTION_REGISTRY as RECIPE_SECTION_REGISTRY +from .types import RECIPE_SECTION_REGISTRY_DIGEST as RECIPE_SECTION_REGISTRY_DIGEST +from .types import RECIPE_SECTION_RESPONSE_FLOOR_BYTES as RECIPE_SECTION_RESPONSE_FLOOR_BYTES from .types import REQUIRED_CONSUMER_FIELDS as REQUIRED_CONSUMER_FIELDS from .types import RESERVED_LOG_RECORD_KEYS as RESERVED_LOG_RECORD_KEYS from .types import RESPONSE_BACKSTOP_EXEMPTION_REGISTRY as RESPONSE_BACKSTOP_EXEMPTION_REGISTRY @@ -367,6 +381,9 @@ from .types import RecipeLoadError as RecipeLoadError from .types import RecipeNotFoundError as RecipeNotFoundError from .types import RecipePackDef as RecipePackDef from .types import RecipeRepository as RecipeRepository +from .types import RecipeSectionContentFormatDef as RecipeSectionContentFormatDef +from .types import RecipeSectionDef as RecipeSectionDef +from .types import RecipeSectionValidationFinding as RecipeSectionValidationFinding from .types import RecipeSource as RecipeSource from .types import ResponseBackstopExemptionDef as ResponseBackstopExemptionDef from .types import RestartScope as RestartScope @@ -413,6 +430,7 @@ from .types import WriteBehaviorSpec as WriteBehaviorSpec from .types import WriteEvidence as WriteEvidence from .types import WriteExpectedResolver as WriteExpectedResolver from .types import assert_prompt_sentinel as assert_prompt_sentinel +from .types import canonical_recipe_section_json as canonical_recipe_section_json from .types import closure_authority_spec_from_args as closure_authority_spec_from_args from .types import compute_remaining as compute_remaining from .types import describe_capability_mismatches as describe_capability_mismatches @@ -424,6 +442,9 @@ from .types import is_path_like_token as is_path_like_token from .types import is_valid_codex_model_id as is_valid_codex_model_id from .types import model_class as model_class from .types import parse_plan_paths as parse_plan_paths +from .types import recipe_section_digest as recipe_section_digest +from .types import recipe_section_element_digest as recipe_section_element_digest +from .types import recipe_section_plan_digest as recipe_section_plan_digest from .types import resolve_payload_field as resolve_payload_field from .types import resolve_skill_name as resolve_skill_name from .types import resolve_target_skill as resolve_target_skill @@ -433,3 +454,4 @@ from .types import strip_context_window_suffix as strip_context_window_suffix from .types import truncate_text as truncate_text from .types import unsatisfied_backend_capabilities as unsatisfied_backend_capabilities from .types import validate_label_transition as validate_label_transition +from .types import validate_recipe_artifact_sections as validate_recipe_artifact_sections diff --git a/src/autoskillit/core/types/AGENTS.md b/src/autoskillit/core/types/AGENTS.md index b261fec55..eda2c6667 100644 --- a/src/autoskillit/core/types/AGENTS.md +++ b/src/autoskillit/core/types/AGENTS.md @@ -30,6 +30,7 @@ Type re-export hub and all typed building blocks for the autoskillit package (IL | `_type_checkpoint.py` | `SessionCheckpoint` frozen dataclass and `compute_remaining()` helper for session resume | | `_type_backend.py` | `BackendCapabilities` frozen dataclass, `CLAUDE_CODE_CAPABILITIES` constant, `CmdSpec`, `SkillSessionConfig`, `ClaudeEventData`, `CodexEventData`, `SessionEvent`, `AgentSessionResult` | | `_type_recipe_delivery.py` | Typed Codex recipe budgets, protected-host evidence definitions, requests, attestations, and delivery decisions | +| `_type_recipe_sections.py` | Recipe-section schema validation plus canonical section, element, and plan digest helpers | | `_type_capture.py` | `CaptureEntrySpec` and `CaptureValueTypeError` for typed capture contract enforcement | | `_type_dispatch_identity.py` | `DispatchIdentity` frozen value object, `PromptContractError`, and `assert_prompt_sentinel` for sentinel contract enforcement | | `_type_helpers.py` | Text processing and skill-name extraction utilities | diff --git a/src/autoskillit/core/types/__init__.py b/src/autoskillit/core/types/__init__.py index 0c39ab4bd..f0800eb82 100644 --- a/src/autoskillit/core/types/__init__.py +++ b/src/autoskillit/core/types/__init__.py @@ -56,6 +56,8 @@ from ._type_protocols_workspace import __all__ as _protocols_workspace_all from ._type_recipe_delivery import * # noqa: F401, F403 from ._type_recipe_delivery import __all__ as _recipe_delivery_all +from ._type_recipe_sections import * # noqa: F401, F403 +from ._type_recipe_sections import __all__ as _recipe_sections_all from ._type_results import * # noqa: F401, F403 from ._type_results import __all__ as _results_all from ._type_results_execution import * # noqa: F401, F403 @@ -99,6 +101,7 @@ + _results_all + _results_execution_all + _recipe_delivery_all + + _recipe_sections_all + _resume_all + _session_env_all + _subprocess_all diff --git a/src/autoskillit/core/types/_type_constants_registries.py b/src/autoskillit/core/types/_type_constants_registries.py index ca76b3548..3b5cfd0e8 100644 --- a/src/autoskillit/core/types/_type_constants_registries.py +++ b/src/autoskillit/core/types/_type_constants_registries.py @@ -10,7 +10,7 @@ import hashlib import json from collections.abc import Mapping -from dataclasses import dataclass, fields +from dataclasses import asdict, dataclass, fields from types import MappingProxyType from typing import Literal, NamedTuple @@ -49,6 +49,16 @@ "RecipeDeliverySurfaceDef", "RECIPE_DELIVERY_SURFACE_REGISTRY", "RECIPE_DELIVERY_SURFACE_REGISTRY_DIGEST", + "RecipeSectionDef", + "RECIPE_SECTION_REGISTRY", + "DYNAMIC_RECIPE_SECTION_DEF", + "RECIPE_SECTION_PAGINATION_VERSION", + "RECIPE_SECTION_REGISTRY_DIGEST", + "RECIPE_SECTION_PAGINATION_POLICY_DIGEST", + "RecipeSectionContentFormatDef", + "RECIPE_SECTION_CONTENT_FORMAT_REGISTRY", + "RECIPE_SECTION_MANDATORY_FAILURE_CODES", + "RECIPE_SECTION_RESPONSE_FLOOR_BYTES", "SkillCapabilityDef", "HardCapabilityMismatch", "SKILL_CAPABILITY_REGISTRY", @@ -266,6 +276,317 @@ def _recipe_delivery_surface_registry_digest() -> str: RECIPE_DELIVERY_SURFACE_REGISTRY_DIGEST: str = _recipe_delivery_surface_registry_digest() + +@dataclass(frozen=True, slots=True) +class RecipeSectionDef: + """Static schema and pagination definition for one pullable recipe section.""" + + name: str + value_kind: Literal["string", "array"] + element_kind: Literal["string"] | None + missing_behavior: Literal["invalid", "absent", "default"] + none_behavior: Literal["invalid", "absent"] + section_strategy: Literal["raw", "scalar", "array"] + range_unit: Literal["utf8-bytes", "decoded-utf8-bytes", "elements"] + ordinary_content_format: Literal["raw-text", "json-scalar-page", "json-array-page"] + oversized_content_format: Literal["json-element-fragment"] | None + has_default: bool = False + default_value: tuple[str, ...] | None = None + + def __post_init__(self) -> None: + """Reject contradictory public definitions at their construction boundary.""" + if not self.name: + raise ValueError("recipe section definition name must not be empty") + if self.missing_behavior not in {"invalid", "absent", "default"}: + raise ValueError("invalid recipe section missing behavior") + if self.none_behavior not in {"invalid", "absent"}: + raise ValueError("invalid recipe section null behavior") + layout = ( + self.value_kind, + self.element_kind, + self.section_strategy, + self.range_unit, + self.ordinary_content_format, + self.oversized_content_format, + ) + valid_layouts = { + ("string", None, "raw", "utf8-bytes", "raw-text", None), + ( + "string", + None, + "scalar", + "decoded-utf8-bytes", + "json-scalar-page", + None, + ), + ( + "array", + "string", + "array", + "elements", + "json-array-page", + "json-element-fragment", + ), + } + if layout not in valid_layouts: + raise ValueError("invalid recipe section strategy and content-format combination") + if self.has_default != (self.missing_behavior == "default"): + raise ValueError("recipe section default flag must match missing behavior") + if self.has_default: + if self.value_kind != "array" or self.default_value != (): + raise ValueError("defaulted recipe sections require an empty array default") + elif self.default_value is not None: + raise ValueError("recipe sections without defaults must not declare a default value") + + +def _validated_recipe_section_registry( + definitions: dict[str, RecipeSectionDef], +) -> Mapping[str, RecipeSectionDef]: + for name, definition in definitions.items(): + if definition.name != name: + raise ValueError(f"recipe section registry key {name!r} must match definition name") + return MappingProxyType(definitions) + + +RECIPE_SECTION_REGISTRY: Mapping[str, RecipeSectionDef] = _validated_recipe_section_registry( + { + "content": RecipeSectionDef( + name="content", + value_kind="string", + element_kind=None, + missing_behavior="invalid", + none_behavior="invalid", + section_strategy="raw", + range_unit="utf8-bytes", + ordinary_content_format="raw-text", + oversized_content_format=None, + ), + "ingredients_table": RecipeSectionDef( + name="ingredients_table", + value_kind="string", + element_kind=None, + missing_behavior="absent", + none_behavior="absent", + section_strategy="scalar", + range_unit="decoded-utf8-bytes", + ordinary_content_format="json-scalar-page", + oversized_content_format=None, + ), + "orchestration_rules": RecipeSectionDef( + name="orchestration_rules", + value_kind="string", + element_kind=None, + missing_behavior="absent", + none_behavior="invalid", + section_strategy="raw", + range_unit="utf8-bytes", + ordinary_content_format="raw-text", + oversized_content_format=None, + ), + "stop_step_semantics": RecipeSectionDef( + name="stop_step_semantics", + value_kind="string", + element_kind=None, + missing_behavior="absent", + none_behavior="invalid", + section_strategy="raw", + range_unit="utf8-bytes", + ordinary_content_format="raw-text", + oversized_content_format=None, + ), + "errors": RecipeSectionDef( + name="errors", + value_kind="array", + element_kind="string", + missing_behavior="default", + none_behavior="invalid", + section_strategy="array", + range_unit="elements", + ordinary_content_format="json-array-page", + oversized_content_format="json-element-fragment", + has_default=True, + default_value=(), + ), + "warnings": RecipeSectionDef( + name="warnings", + value_kind="array", + element_kind="string", + missing_behavior="default", + none_behavior="invalid", + section_strategy="array", + range_unit="elements", + ordinary_content_format="json-array-page", + oversized_content_format="json-element-fragment", + has_default=True, + default_value=(), + ), + }, +) + +DYNAMIC_RECIPE_SECTION_DEF = RecipeSectionDef( + name="post_prune_step", + value_kind="string", + element_kind=None, + missing_behavior="absent", + none_behavior="invalid", + section_strategy="raw", + range_unit="utf8-bytes", + ordinary_content_format="raw-text", + oversized_content_format=None, +) + +RECIPE_SECTION_PAGINATION_VERSION = 1 +_RECIPE_SECTION_CANONICAL_JSON_ENSURE_ASCII = False +_RECIPE_SECTION_CANONICAL_JSON_SEPARATORS = (",", ":") +_RECIPE_SECTION_CANONICAL_JSON_SORT_KEYS = True +_RECIPE_SECTION_DIGEST_DOMAINS: Mapping[str, str] = MappingProxyType( + { + "raw_section": "autoskillit.recipe-section.raw.v1", + "structured_section": "autoskillit.recipe-section.structured.v1", + "element": "autoskillit.recipe-section.element.v1", + "plan": "autoskillit.recipe-section.plan.v1", + } +) + + +def _qualified_registry_digest(value: object) -> str: + payload = json.dumps( + value, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("ascii") + return f"sha256:{hashlib.sha256(payload).hexdigest()}" + + +RECIPE_SECTION_REGISTRY_DIGEST = _qualified_registry_digest( + {name: asdict(definition) for name, definition in sorted(RECIPE_SECTION_REGISTRY.items())} + | {"$dynamic": asdict(DYNAMIC_RECIPE_SECTION_DEF)} +) + + +@dataclass(frozen=True, slots=True) +class RecipeSectionContentFormatDef: + range_fields: tuple[str, ...] + reconstruction: str + + +RECIPE_SECTION_CONTENT_FORMAT_REGISTRY: Mapping[str, RecipeSectionContentFormatDef] = ( + MappingProxyType( + { + "raw-text": RecipeSectionContentFormatDef( + range_fields=("byte_start", "byte_end", "byte_total"), + reconstruction="concatenate-content", + ), + "json-array-page": RecipeSectionContentFormatDef( + range_fields=("element_start", "element_end", "element_total"), + reconstruction="json-load-each-and-extend", + ), + "json-scalar-page": RecipeSectionContentFormatDef( + range_fields=("scalar_byte_start", "scalar_byte_end", "scalar_byte_total"), + reconstruction="json-load-each-and-concatenate-strings", + ), + "json-element-fragment": RecipeSectionContentFormatDef( + range_fields=( + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + ), + reconstruction="json-load-fragments-concatenate-verify-and-json-load", + ), + } + ) +) + + +def _recipe_section_policy_definitions() -> tuple[RecipeSectionDef, ...]: + return (*RECIPE_SECTION_REGISTRY.values(), DYNAMIC_RECIPE_SECTION_DEF) + + +def _declared_recipe_section_content_formats() -> tuple[str, ...]: + formats = { + content_format + for definition in _recipe_section_policy_definitions() + for content_format in ( + definition.ordinary_content_format, + definition.oversized_content_format, + ) + if content_format is not None + } + if formats != RECIPE_SECTION_CONTENT_FORMAT_REGISTRY.keys(): + raise ValueError("recipe section definitions and content-format metadata must agree") + return tuple(sorted(formats)) + + +_DECLARED_RECIPE_SECTION_CONTENT_FORMATS = _declared_recipe_section_content_formats() + +_RECIPE_SECTION_PAGINATION_POLICY = { + "version": RECIPE_SECTION_PAGINATION_VERSION, + "canonical_json": { + "ensure_ascii": _RECIPE_SECTION_CANONICAL_JSON_ENSURE_ASCII, + "separators": list(_RECIPE_SECTION_CANONICAL_JSON_SEPARATORS), + "sort_keys": _RECIPE_SECTION_CANONICAL_JSON_SORT_KEYS, + }, + "success_fields": [ + "success", + "pagination_version", + "section_registry_sha256", + "section", + "content_format", + "content", + "part", + "total_parts", + "has_more", + "next_part", + "section_sha256", + "page_plan_sha256", + "payload_sha256", + "body_sha256", + ], + "optional_fields": {"next_part": "omit_on_terminal"}, + "content_formats": { + content_format: list(RECIPE_SECTION_CONTENT_FORMAT_REGISTRY[content_format].range_fields) + for content_format in _DECLARED_RECIPE_SECTION_CONTENT_FORMATS + }, + "range_units": { + definition.section_strategy: definition.range_unit + for definition in _recipe_section_policy_definitions() + }, + "digest_domains": dict(_RECIPE_SECTION_DIGEST_DOMAINS), + "reconstruction": { + content_format: RECIPE_SECTION_CONTENT_FORMAT_REGISTRY[content_format].reconstruction + for content_format in _DECLARED_RECIPE_SECTION_CONTENT_FORMATS + }, +} +RECIPE_SECTION_PAGINATION_POLICY_DIGEST = _qualified_registry_digest( + _RECIPE_SECTION_PAGINATION_POLICY +) + +RECIPE_SECTION_MANDATORY_FAILURE_CODES: tuple[str, ...] = ( + "invalid_recipe_artifact_identity", + "invalid_recipe_section_part", + "recipe_artifact_identity_required", + "recipe_artifact_parse_failed", + "recipe_artifact_schema_mismatch", + "recipe_artifact_unavailable", + "recipe_section_bound_too_small", + "recipe_section_cancelled", + "recipe_section_internal_error", + "recipe_section_pagination_nonconvergent", + "recipe_section_serialization_failed", + "section_not_found", +) + + +_RECIPE_SECTION_FAILURE_ENVELOPE_WITH_EMPTY_CODE_BYTES = len(b'{"error":"","success":false}') +RECIPE_SECTION_RESPONSE_FLOOR_BYTES = _RECIPE_SECTION_FAILURE_ENVELOPE_WITH_EMPTY_CODE_BYTES + max( + len(code.encode("utf-8")) for code in RECIPE_SECTION_MANDATORY_FAILURE_CODES +) + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY: Mapping[str, ResponseBackstopExemptionDef] = ( MappingProxyType( { diff --git a/src/autoskillit/core/types/_type_recipe_sections.py b/src/autoskillit/core/types/_type_recipe_sections.py new file mode 100644 index 000000000..068f22284 --- /dev/null +++ b/src/autoskillit/core/types/_type_recipe_sections.py @@ -0,0 +1,223 @@ +"""Recipe-section schema validation and canonical digest helpers.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from dataclasses import asdict, dataclass, is_dataclass + +from ._type_constants_registries import ( + _RECIPE_SECTION_CANONICAL_JSON_ENSURE_ASCII, + _RECIPE_SECTION_CANONICAL_JSON_SEPARATORS, + _RECIPE_SECTION_CANONICAL_JSON_SORT_KEYS, + _RECIPE_SECTION_DIGEST_DOMAINS, + RECIPE_SECTION_REGISTRY, +) + +__all__ = [ + "RecipeSectionValidationFinding", + "canonical_recipe_section_json", + "recipe_section_digest", + "recipe_section_element_digest", + "recipe_section_plan_digest", + "validate_recipe_artifact_sections", +] + +_MISSING = object() +_RECIPE_SECTION_VALIDATION_FINDING_LIMIT = 100 + + +@dataclass(frozen=True, slots=True) +class RecipeSectionValidationFinding: + """One stable schema mismatch without embedding an artifact value.""" + + section: str + code: str + path: tuple[str | int, ...] + expected: str + actual_type: str + omitted_count: int = 0 + + def diagnostic(self) -> str: + """Render one bounded value-free diagnostic token.""" + if self.omitted_count: + return f"{self.omitted_count} additional findings omitted" + return f"{self.code}@{'.'.join(str(part) for part in self.path)}" + + +def canonical_recipe_section_json(value: object) -> str: + """Return the application canonical JSON used by recipe-section identities.""" + if is_dataclass(value) and not isinstance(value, type): + value = asdict(value) + return json.dumps( + value, + ensure_ascii=_RECIPE_SECTION_CANONICAL_JSON_ENSURE_ASCII, + separators=_RECIPE_SECTION_CANONICAL_JSON_SEPARATORS, + sort_keys=_RECIPE_SECTION_CANONICAL_JSON_SORT_KEYS, + ) + + +def _domain_digest(domain: str, payload: bytes) -> str: + digest = hashlib.sha256(domain.encode("ascii") + b"\0" + payload).hexdigest() + return f"sha256:{digest}" + + +def recipe_section_digest(value: object, *, raw: bool) -> str: + """Hash one complete section in its declared raw or structured domain.""" + if raw: + if type(value) is not str: + raise TypeError("raw recipe section digest requires a string") + return _domain_digest(_RECIPE_SECTION_DIGEST_DOMAINS["raw_section"], value.encode("utf-8")) + return _domain_digest( + _RECIPE_SECTION_DIGEST_DOMAINS["structured_section"], + canonical_recipe_section_json(value).encode("utf-8"), + ) + + +def recipe_section_element_digest(value: object) -> str: + """Hash one complete canonical structured element.""" + return _domain_digest( + _RECIPE_SECTION_DIGEST_DOMAINS["element"], + canonical_recipe_section_json(value).encode("utf-8"), + ) + + +def recipe_section_plan_digest(manifest: object) -> str: + """Hash a plan manifest that excludes its resulting digest.""" + return _domain_digest( + _RECIPE_SECTION_DIGEST_DOMAINS["plan"], + canonical_recipe_section_json(manifest).encode("utf-8"), + ) + + +def _finding( + section: str, + code: str, + path: tuple[str | int, ...], + expected: str, + value: object, +) -> RecipeSectionValidationFinding: + actual_type = ( + "missing" if value is _MISSING else "null" if value is None else type(value).__name__ + ) + return RecipeSectionValidationFinding( + section=section, + code=code, + path=path, + expected=expected, + actual_type=actual_type, + ) + + +def validate_recipe_artifact_sections( + payload: Mapping[str, object], +) -> tuple[RecipeSectionValidationFinding, ...]: + """Validate pullable fields; an empty tuple is the sole valid result.""" + findings: list[RecipeSectionValidationFinding] = [] + omitted_count = 0 + + def record(finding: RecipeSectionValidationFinding) -> None: + nonlocal omitted_count + if len(findings) < _RECIPE_SECTION_VALIDATION_FINDING_LIMIT: + findings.append(finding) + else: + omitted_count += 1 + + for section, definition in RECIPE_SECTION_REGISTRY.items(): + value = payload.get(section, _MISSING) + if value is _MISSING: + if definition.missing_behavior == "invalid": + record( + _finding( + section, + "missing_required_section", + (section,), + definition.value_kind, + value, + ) + ) + continue + if value is None: + if definition.none_behavior == "invalid": + record( + _finding( + section, + "invalid_section_type", + (section,), + definition.value_kind, + value, + ) + ) + continue + if definition.value_kind == "string": + if type(value) is not str: + record( + _finding( + section, + "invalid_section_type", + (section,), + "string", + value, + ) + ) + continue + if type(value) is not list: + record( + _finding( + section, + "invalid_section_type", + (section,), + "array", + value, + ) + ) + continue + for index, element in enumerate(value): + if type(element) is not str: + record( + _finding( + section, + "invalid_section_element_type", + (section, index), + "string", + element, + ) + ) + + step_names = payload.get("post_prune_step_names", _MISSING) + if step_names is not _MISSING: + if type(step_names) is not list: + record( + _finding( + "post_prune_step_names", + "invalid_post_prune_step_names", + ("post_prune_step_names",), + "array of strings", + step_names, + ) + ) + else: + for index, step_name in enumerate(step_names): + if type(step_name) is not str: + record( + _finding( + "post_prune_step_names", + "invalid_post_prune_step_name", + ("post_prune_step_names", index), + "string", + step_name, + ) + ) + if omitted_count: + findings.append( + RecipeSectionValidationFinding( + section="$artifact", + code="additional_findings_omitted", + path=(), + expected="valid recipe section values", + actual_type="multiple", + omitted_count=omitted_count, + ) + ) + return tuple(findings) diff --git a/src/autoskillit/execution/backends/_probe_cache.py b/src/autoskillit/execution/backends/_probe_cache.py index 893e562c6..073f486ee 100644 --- a/src/autoskillit/execution/backends/_probe_cache.py +++ b/src/autoskillit/execution/backends/_probe_cache.py @@ -17,6 +17,8 @@ OUTPUT_DISCIPLINE_COMBINED_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, RECIPE_DELIVERY_SURFACE_REGISTRY_DIGEST, + RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + RECIPE_SECTION_REGISTRY_DIGEST, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, get_logger, read_versioned_json, @@ -36,7 +38,7 @@ "generated-codex-child-v1", "deep-investigate-codex-v2", "deep-investigate-claude-200k-v2", - "codex-recipe-delivery-v1", + "codex-recipe-delivery-v2", ) PROBE_SUITE_CONTRACT_DIGEST: str = hashlib.sha256( "\n".join(PROBE_SUITE_CONTRACT).encode("utf-8") @@ -59,6 +61,8 @@ f"evidence-schema:{CODEX_RECIPE_DELIVERY_BUDGET.evidence_version}", f"attestation-registry:{_SUPPORTED_RECIPE_EVIDENCE_DIGEST}", f"surface-registry:{RECIPE_DELIVERY_SURFACE_REGISTRY_DIGEST}", + f"section-registry:{RECIPE_SECTION_REGISTRY_DIGEST}", + f"pagination-policy:{RECIPE_SECTION_PAGINATION_POLICY_DIGEST}", f"prompt:{CODEX_RECIPE_DELIVERY_CALLING_CONTRACT_DIGEST}", f"response-exemptions:{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}", "cli-pin:" + ".".join(str(value) for value in CODEX_LIMITS_LAST_VERIFIED_VERSION), diff --git a/src/autoskillit/recipe/_recipe_ingredients.py b/src/autoskillit/recipe/_recipe_ingredients.py index 7f54c64f4..c9025e8e8 100644 --- a/src/autoskillit/recipe/_recipe_ingredients.py +++ b/src/autoskillit/recipe/_recipe_ingredients.py @@ -120,7 +120,7 @@ class LoadRecipeResult(TypedDict, total=False): requires_packs: list[str] requires_features: list[str] greeting: str - ingredients_table: str + ingredients_table: str | None orchestration_rules: str stop_step_semantics: str content_hash: str diff --git a/src/autoskillit/server/AGENTS.md b/src/autoskillit/server/AGENTS.md index aba586e1a..9827ee06a 100644 --- a/src/autoskillit/server/AGENTS.md +++ b/src/autoskillit/server/AGENTS.md @@ -17,6 +17,9 @@ Sub-package: tools/ (see tools/AGENTS.md). | `_response_budget.py` | Lossless response spill, exact canonical projection finalization, measured exemptions, and privacy-safe budget telemetry | | `_response_conformance.py` | Post-FastMCP-conversion conformance gate for registered string tool responses | | `_recipe_delivery.py` | Unified recipe finalization, content-addressed generations, pull integrity, and receipt completion | +| `_recipe_section_pagination.py` | Typed recipe-section selection, deterministic byte-bounded page planning, rendering, and verified-plan caching | +| `recipe_section/__init__.py` | Package marker for focused recipe-section planning support | +| `recipe_section/_verification.py` | Post-digest descriptor, reconstruction, ordering, and rendered-bound invariant proof | | `_session_type.py` | Session-type tag visibility dispatcher — controls which tools are visible per session type | | `_state.py` | Mutable singleton state and context accessor functions (`_ctx` sentinel, `get_ctx`, `set_ctx`) | | `_subprocess.py` | Subprocess execution helpers for MCP tools | diff --git a/src/autoskillit/server/_recipe_delivery.py b/src/autoskillit/server/_recipe_delivery.py index ba2b7d0d9..2c15dd1c9 100644 --- a/src/autoskillit/server/_recipe_delivery.py +++ b/src/autoskillit/server/_recipe_delivery.py @@ -31,6 +31,7 @@ get_logger, resolve_general_output_token_limit, resolve_recipe_delivery_decision, + validate_recipe_artifact_sections, ) from autoskillit.execution import ( RecipeDeliveryReceiptLedger, @@ -39,6 +40,7 @@ ) from autoskillit.recipe import _extract_routing_edges, step_byte_ranges_from_yaml from autoskillit.server._response_budget import enforce_response_budget +from autoskillit.server.recipe_section._lifecycle import notify_kitchen_retired if TYPE_CHECKING: from autoskillit.core import RecipeDeliveryBudgetDef, RecipeDeliveryEvidenceDef @@ -61,6 +63,10 @@ class RecipeArtifactError(RuntimeError): """A requested immutable recipe generation is absent or corrupt.""" +class RecipeArtifactSchemaError(RecipeArtifactError): + """A recipe artifact violates the static pullable-section schema.""" + + @dataclass(frozen=True, slots=True) class RecipeArtifactGeneration: """Exact identities for one immutable canonical recipe payload.""" @@ -126,6 +132,13 @@ def _canonical_payload(payload: dict[str, Any]) -> bytes: ).encode("utf-8") +def _validate_recipe_artifact_schema(payload: dict[str, Any]) -> None: + findings = validate_recipe_artifact_sections(payload) + if findings: + summary = ",".join(finding.diagnostic() for finding in findings) + raise RecipeArtifactSchemaError(f"recipe artifact section schema mismatch: {summary}") + + def _read_bounded_bytes(path: Path, *, max_bytes: int, error: str) -> bytes: """Read through one descriptor with an explicit allocation ceiling.""" try: @@ -234,6 +247,7 @@ def persist_recipe_artifact( payload: dict[str, Any], ) -> RecipeArtifactGeneration: """Publish an immutable canonical payload and its generation descriptor.""" + _validate_recipe_artifact_schema(payload) blob = _canonical_payload(payload) if len(blob) > RECIPE_ARTIFACT_MAX_BLOB_BYTES: raise RecipeArtifactError("recipe artifact blob exceeds persistence limit") @@ -339,6 +353,7 @@ def load_recipe_artifact( ) if expected != identity: raise RecipeArtifactError("recipe body identity mismatch") + _validate_recipe_artifact_schema(payload) return payload @@ -354,6 +369,14 @@ def retire_recipe_artifacts(temp_dir: Path, *, kitchen_id: str) -> bool: shutil.rmtree(namespace) except (OSError, RecipeArtifactError, TypeError): return False + try: + notify_kitchen_retired(kitchen_id) + except Exception: + get_logger(__name__).warning( + "recipe_section_cache_retirement_eviction_failed", + kitchen_id=kitchen_id, + exc_info=True, + ) return True @@ -959,6 +982,7 @@ def enforce_recipe_resource_response( "RECIPE_BODY_START", "RECIPE_COMPLETION_SENTINEL", "RecipeArtifactError", + "RecipeArtifactSchemaError", "RecipeArtifactGeneration", "complete_finalized_recipe_response", "enforce_recipe_resource_response", diff --git a/src/autoskillit/server/_recipe_section_pagination.py b/src/autoskillit/server/_recipe_section_pagination.py new file mode 100644 index 000000000..761994ba6 --- /dev/null +++ b/src/autoskillit/server/_recipe_section_pagination.py @@ -0,0 +1,955 @@ +"""Deterministic grammar-aware pagination for persisted recipe sections.""" + +from __future__ import annotations + +import hashlib +import json +from bisect import bisect_right +from collections import OrderedDict +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field, replace +from threading import Event, Lock, RLock + +from autoskillit.core import ( + DYNAMIC_RECIPE_SECTION_DEF, + RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + RECIPE_SECTION_PAGINATION_VERSION, + RECIPE_SECTION_REGISTRY, + RECIPE_SECTION_REGISTRY_DIGEST, + RecipeSectionDef, + canonical_recipe_section_json, + get_logger, + recipe_section_digest, + recipe_section_element_digest, + recipe_section_plan_digest, +) +from autoskillit.server._recipe_delivery import ( + RECIPE_ARTIFACT_MAX_BLOB_BYTES, + RecipeArtifactGeneration, +) +from autoskillit.server.recipe_section._contracts import ( + PlannedRecipeSectionPage, + RecipeSectionBoundError, + RecipeSectionNonConvergenceError, + RecipeSectionPageDescriptor, + RecipeSectionPagePlan, + RecipeSectionPaginationError, + RecipeSectionPlanManifest, + RecipeSectionRequestState, + SelectedRecipeSection, +) +from autoskillit.server.recipe_section._lifecycle import register_kitchen_retirement_callback +from autoskillit.server.recipe_section._rendering import render_recipe_section_failure +from autoskillit.server.recipe_section._verification import ( + verify_finalized_recipe_section_plan, +) + +logger = get_logger(__name__) + +PAGE_PLAN_CACHE_MAX_ENTRIES = 8 +PAGE_PLAN_CACHE_MAX_BYTES = 32 * 1024 * 1024 +_PLAN_DIGEST_PLACEHOLDER = "sha256:" + ("0" * 64) + + +def _convergence_iteration_ceiling() -> int: + """Derive the monotone width-growth ceiling from artifact policy.""" + max_count_digits = len(str(RECIPE_ARTIFACT_MAX_BLOB_BYTES)) + # One total width plus at most one fragment width per persisted byte; + # every nonterminal pass grows at least one width by one digit. + return 1 + (RECIPE_ARTIFACT_MAX_BLOB_BYTES + 1) * (max_count_digits - 1) + + +__all__ = [ + "PAGE_PLAN_CACHE_MAX_BYTES", + "PAGE_PLAN_CACHE_MAX_ENTRIES", + "PagePlanCache", + "RecipeSectionBoundError", + "RecipeSectionNonConvergenceError", + "RecipeSectionPageDescriptor", + "RecipeSectionPagePlan", + "RecipeSectionPaginationError", + "RecipeSectionPlanManifest", + "RecipeSectionRequestState", + "SelectedRecipeSection", + "build_recipe_section_page_plan", + "evict_kitchen", + "get_or_build_recipe_section_page_plan", + "render_recipe_section_failure", + "render_recipe_section_page", + "resolve_recipe_section_definition", + "resolve_recipe_section_bound_bytes", + "select_recipe_section", +] + + +@dataclass(frozen=True, slots=True) +class _PagePlanCacheKey: + kitchen_id: str + generation: RecipeArtifactGeneration + section: str + section_sha256: str + recipe_section_bound_bytes: int + section_registry_sha256: str + pagination_policy_sha256: str + pagination_version: int + + +@dataclass(slots=True) +class _PagePlanBuildState: + """One shared in-flight page-plan build for a cache key.""" + + completed: Event = field(default_factory=Event) + plan: RecipeSectionPagePlan | None = None + error: BaseException | None = None + + +class PagePlanCache: + """Bounded thread-safe LRU storing only verified immutable plans.""" + + def __init__( + self, + *, + max_entries: int = PAGE_PLAN_CACHE_MAX_ENTRIES, + max_bytes: int = PAGE_PLAN_CACHE_MAX_BYTES, + ) -> None: + if max_entries < 0: + raise ValueError("page-plan cache max_entries must not be negative") + if max_bytes < 0: + raise ValueError("page-plan cache max_bytes must not be negative") + self._max_entries = max_entries + self._max_bytes = max_bytes + self._entries: OrderedDict[_PagePlanCacheKey, RecipeSectionPagePlan] = OrderedDict() + self._weight_bytes = 0 + self._builds: dict[_PagePlanCacheKey, _PagePlanBuildState] = {} + self._retired_kitchens: set[str] = set() + self._lock = RLock() + + def get(self, key: _PagePlanCacheKey) -> RecipeSectionPagePlan | None: + with self._lock: + plan = self._entries.get(key) + if plan is not None: + self._entries.move_to_end(key) + return plan + + def _put_locked(self, key: _PagePlanCacheKey, plan: RecipeSectionPagePlan) -> None: + if plan.cache_weight_bytes > self._max_bytes: + return + existing = self._entries.pop(key, None) + if existing is not None: + self._weight_bytes -= existing.cache_weight_bytes + self._entries[key] = plan + self._weight_bytes += plan.cache_weight_bytes + while len(self._entries) > self._max_entries or self._weight_bytes > self._max_bytes: + _, evicted = self._entries.popitem(last=False) + self._weight_bytes -= evicted.cache_weight_bytes + + def put(self, key: _PagePlanCacheKey, plan: RecipeSectionPagePlan) -> None: + with self._lock: + if key.kitchen_id not in self._retired_kitchens: + self._put_locked(key, plan) + + def get_or_build( + self, + key: _PagePlanCacheKey, + builder: Callable[[], RecipeSectionPagePlan], + ) -> RecipeSectionPagePlan: + """Share a key build and admit it only while its kitchen remains active.""" + with self._lock: + cached = self._entries.get(key) + if cached is not None: + self._entries.move_to_end(key) + return cached + build_state = self._builds.get(key) + build_here = build_state is None + if build_state is None: + build_state = _PagePlanBuildState() + self._builds[key] = build_state + + if build_here: + try: + plan = builder() + except BaseException as exc: + with self._lock: + build_state.error = exc + self._builds.pop(key, None) + build_state.completed.set() + raise + with self._lock: + if key.kitchen_id not in self._retired_kitchens: + self._put_locked(key, plan) + build_state.plan = plan + self._builds.pop(key, None) + build_state.completed.set() + return plan + + build_state.completed.wait() + if build_state.error is not None: + raise build_state.error + if build_state.plan is None: + raise RuntimeError("page-plan build completed without a plan") + return build_state.plan + + def clear(self) -> None: + with self._lock: + self._entries.clear() + self._weight_bytes = 0 + self._retired_kitchens.clear() + + def evict_kitchen(self, kitchen_id: str) -> None: + with self._lock: + self._retired_kitchens.add(kitchen_id) + keys = [key for key in self._entries if key.kitchen_id == kitchen_id] + for key in keys: + self._weight_bytes -= self._entries.pop(key).cache_weight_bytes + + +_PAGE_PLAN_CACHE: PagePlanCache | None = None +_PAGE_PLAN_CACHE_LOCK = Lock() + + +def _page_plan_cache() -> PagePlanCache: + global _PAGE_PLAN_CACHE + with _PAGE_PLAN_CACHE_LOCK: + if _PAGE_PLAN_CACHE is None: + _PAGE_PLAN_CACHE = PagePlanCache() + return _PAGE_PLAN_CACHE + + +def evict_kitchen(kitchen_id: str) -> None: + """Best-effort idempotent eviction for a retired kitchen namespace.""" + try: + with _PAGE_PLAN_CACHE_LOCK: + cache = _PAGE_PLAN_CACHE + if cache is not None: + cache.evict_kitchen(kitchen_id) + except Exception: + logger.warning( + "recipe_section_cache_eviction_failed", kitchen_id=kitchen_id, exc_info=True + ) + + +def _evict_retired_kitchen(kitchen_id: str) -> None: + evict_kitchen(kitchen_id) + + +register_kitchen_retirement_callback(_evict_retired_kitchen) + + +def resolve_recipe_section_bound_bytes( + response_max_bytes: int, + conservative_general_result_limit: int, +) -> int: + """Resolve the deliberately conservative ordinary recipe-pull ceiling.""" + return min(response_max_bytes, conservative_general_result_limit) + + +def resolve_recipe_section_definition( + payload: Mapping[str, object], + section: str, +) -> RecipeSectionDef | None: + """Resolve the sole fixed-or-dynamic definition for a pullable section.""" + definition = RECIPE_SECTION_REGISTRY.get(section) + if definition is not None: + return definition + step_names = payload.get("post_prune_step_names") + if ( + type(step_names) is list + and type(section) is str + and any(type(name) is str and name == section for name in step_names) + ): + return DYNAMIC_RECIPE_SECTION_DEF + return None + + +def select_recipe_section( + payload: Mapping[str, object], + section: str, + *, + dynamic_content: str | None = None, + dynamic_content_loader: Callable[[str], str | None] | None = None, +) -> SelectedRecipeSection: + """Select a fixed or validated dynamic section without pre-serializing its value.""" + definition = resolve_recipe_section_definition(payload, section) + if definition is None: + return SelectedRecipeSection( + section=section, + definition=DYNAMIC_RECIPE_SECTION_DEF, + value=None, + present=False, + ) + if definition is not DYNAMIC_RECIPE_SECTION_DEF: + if section not in payload: + if definition.missing_behavior == "default": + return SelectedRecipeSection( + section=section, + definition=definition, + value=list(definition.default_value or ()), + present=True, + ) + return SelectedRecipeSection(section, definition, None, False) + value = payload[section] + if value is None and definition.none_behavior == "absent": + return SelectedRecipeSection(section, definition, None, False) + return SelectedRecipeSection(section, definition, value, True) + + if dynamic_content_loader is not None: + dynamic_content = dynamic_content_loader(section) + return SelectedRecipeSection( + section=section, + definition=DYNAMIC_RECIPE_SECTION_DEF, + value=dynamic_content, + present=bool(dynamic_content), + ) + + +def _qualified_content_digest(content: str) -> str: + return f"sha256:{hashlib.sha256(content.encode('utf-8')).hexdigest()}" + + +def _utf8_prefix_offsets(value: str) -> list[int]: + offsets = [0] + total = 0 + for character in value: + total += len(character.encode("utf-8")) + offsets.append(total) + return offsets + + +def _max_utf8_prefix_end( + offsets: list[int], + *, + start: int, + bound_bytes: int, +) -> int: + """Return the largest end whose content bytes alone can fit the response bound.""" + return ( + bisect_right( + offsets, + offsets[start] + bound_bytes, + lo=start + 1, + ) + - 1 + ) + + +def _render_candidate( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + page: PlannedRecipeSectionPage, + part: int, + total_parts: int, + page_plan_sha256: str, + terminal: bool, +) -> str: + body: dict[str, object] = { + "body_sha256": generation.body_sha256, + "content": page.content, + "content_format": page.descriptor.content_format, + "has_more": not terminal, + "page_plan_sha256": page_plan_sha256, + "pagination_version": RECIPE_SECTION_PAGINATION_VERSION, + "part": part, + "payload_sha256": generation.payload_sha256, + "section": selected.section, + "section_registry_sha256": RECIPE_SECTION_REGISTRY_DIGEST, + "section_sha256": section_sha256, + "success": True, + "total_parts": total_parts, + } + if not terminal: + body["next_part"] = part + 1 + body.update(page.descriptor.wire_ranges()) + return json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + + +def _fits( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + page: PlannedRecipeSectionPage, + part: int, + total_width: int, + terminal: bool, + bound_bytes: int, +) -> bool: + rendered = _render_candidate( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=page, + part=part, + total_parts=(10**total_width) - 1, + page_plan_sha256=_PLAN_DIGEST_PLACEHOLDER, + terminal=terminal, + ) + return len(rendered.encode("utf-8")) <= bound_bytes + + +def _raw_or_scalar_pages( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + value: str, + total_width: int, + bound_bytes: int, + scalar: bool, +) -> list[PlannedRecipeSectionPage]: + offsets = _utf8_prefix_offsets(value) + pages: list[PlannedRecipeSectionPage] = [] + if not value: + content = canonical_recipe_section_json("") if scalar else "" + if scalar: + descriptor = RecipeSectionPageDescriptor( + content_format=selected.definition.ordinary_content_format, + page_content_sha256=_qualified_content_digest(content), + scalar_byte_start=0, + scalar_byte_end=0, + scalar_byte_total=0, + ) + else: + descriptor = RecipeSectionPageDescriptor( + content_format=selected.definition.ordinary_content_format, + page_content_sha256=_qualified_content_digest(content), + byte_start=0, + byte_end=0, + byte_total=0, + ) + page = PlannedRecipeSectionPage(descriptor, content) + if not _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=page, + part=0, + total_width=total_width, + terminal=True, + bound_bytes=bound_bytes, + ): + raise RecipeSectionBoundError("recipe section bound cannot fit an empty page") + return [page] + + start = 0 + while start < len(value): + part = len(pages) + + def candidate_for(end: int) -> PlannedRecipeSectionPage: + chunk = value[start:end] + content = canonical_recipe_section_json(chunk) if scalar else chunk + if scalar: + descriptor = RecipeSectionPageDescriptor( + content_format=selected.definition.ordinary_content_format, + page_content_sha256=_qualified_content_digest(content), + scalar_byte_start=offsets[start], + scalar_byte_end=offsets[end], + scalar_byte_total=offsets[-1], + ) + else: + descriptor = RecipeSectionPageDescriptor( + content_format=selected.definition.ordinary_content_format, + page_content_sha256=_qualified_content_digest(content), + byte_start=offsets[start], + byte_end=offsets[end], + byte_total=offsets[-1], + ) + return PlannedRecipeSectionPage(descriptor, content) + + max_end = _max_utf8_prefix_end( + offsets, + start=start, + bound_bytes=bound_bytes, + ) + if max_end == len(value): + terminal_candidate = candidate_for(max_end) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=terminal_candidate, + part=part, + total_width=total_width, + terminal=True, + bound_bytes=bound_bytes, + ): + pages.append(terminal_candidate) + break + + low = start + 1 + high = min(max_end, len(value) - 1) + accepted: PlannedRecipeSectionPage | None = None + accepted_end = start + while low <= high: + end = (low + high) // 2 + candidate = candidate_for(end) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=candidate, + part=part, + total_width=total_width, + terminal=False, + bound_bytes=bound_bytes, + ): + accepted = candidate + accepted_end = end + low = end + 1 + else: + high = end - 1 + if accepted is None or accepted_end == start: + raise RecipeSectionBoundError("recipe section bound cannot fit progress") + pages.append(accepted) + start = accepted_end + return pages + + +def _array_page( + *, + definition: RecipeSectionDef, + canonical_elements: list[str], + start: int, + end: int, +) -> PlannedRecipeSectionPage: + content = "[" + ",".join(canonical_elements[start:end]) + "]" + return PlannedRecipeSectionPage( + RecipeSectionPageDescriptor( + content_format=definition.ordinary_content_format, + page_content_sha256=_qualified_content_digest(content), + element_start=start, + element_end=end, + element_total=len(canonical_elements), + ), + content, + ) + + +def _canonical_array_prefix_bytes(canonical_elements: list[str]) -> list[int]: + prefix_bytes = [0] + for element in canonical_elements: + prefix_bytes.append(prefix_bytes[-1] + len(element.encode("utf-8")) + 1) + return prefix_bytes + + +def _max_array_page_end( + prefix_bytes: list[int], + *, + start: int, + bound_bytes: int, +) -> int: + """Bound one candidate by the bytes of its JSON array content alone.""" + return ( + bisect_right( + prefix_bytes, + prefix_bytes[start] + bound_bytes - 1, + lo=start + 1, + ) + - 1 + ) + + +def _fragment_pages( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + element: object, + canonical_element: str, + element_index: int, + element_total: int, + part_start: int, + total_width: int, + fragment_width: int, + bound_bytes: int, +) -> list[PlannedRecipeSectionPage]: + offsets = _utf8_prefix_offsets(canonical_element) + element_sha256 = recipe_section_element_digest(element) + pages: list[PlannedRecipeSectionPage] = [] + start = 0 + assumed_fragment_count = (10**fragment_width) - 1 + content_format = selected.definition.oversized_content_format + if content_format is None: + raise ValueError("array recipe section definition requires an oversized format") + while start < len(canonical_element): + part = part_start + len(pages) + fragment_index = len(pages) + + def candidate_for(end: int) -> PlannedRecipeSectionPage: + content = canonical_recipe_section_json(canonical_element[start:end]) + descriptor = RecipeSectionPageDescriptor( + content_format=content_format, + page_content_sha256=_qualified_content_digest(content), + element_index=element_index, + element_sha256=element_sha256, + fragment_index=fragment_index, + fragment_count=max(assumed_fragment_count, fragment_index + 1), + fragment_byte_start=offsets[start], + fragment_byte_end=offsets[end], + fragment_byte_total=offsets[-1], + ) + return PlannedRecipeSectionPage(descriptor, content) + + max_end = _max_utf8_prefix_end( + offsets, + start=start, + bound_bytes=bound_bytes, + ) + is_final_element = element_index + 1 == element_total + if is_final_element and max_end == len(canonical_element): + terminal_candidate = candidate_for(max_end) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=terminal_candidate, + part=part, + total_width=total_width, + terminal=True, + bound_bytes=bound_bytes, + ): + pages.append(terminal_candidate) + break + + low = start + 1 + high = min(max_end, len(canonical_element) - 1) if is_final_element else max_end + accepted: PlannedRecipeSectionPage | None = None + accepted_end = start + while low <= high: + end = (low + high) // 2 + candidate = candidate_for(end) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=candidate, + part=part, + total_width=total_width, + terminal=False, + bound_bytes=bound_bytes, + ): + accepted = candidate + accepted_end = end + low = end + 1 + else: + high = end - 1 + if accepted is None or accepted_end == start: + raise RecipeSectionBoundError("recipe section bound cannot fit element fragment") + pages.append(accepted) + start = accepted_end + fragment_count = len(pages) + return [ + PlannedRecipeSectionPage( + replace(page.descriptor, fragment_count=fragment_count), + page.content, + ) + for page in pages + ] + + +def _array_pages( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + values: list[object], + total_width: int, + fragment_widths: Mapping[int, int], + bound_bytes: int, +) -> tuple[list[PlannedRecipeSectionPage], dict[int, int]]: + canonical_elements = [canonical_recipe_section_json(value) for value in values] + prefix_bytes = _canonical_array_prefix_bytes(canonical_elements) + if not values: + page = _array_page( + definition=selected.definition, + canonical_elements=canonical_elements, + start=0, + end=0, + ) + if not _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=page, + part=0, + total_width=total_width, + terminal=True, + bound_bytes=bound_bytes, + ): + raise RecipeSectionBoundError("recipe section bound cannot fit an empty array") + return [page], {} + + pages: list[PlannedRecipeSectionPage] = [] + observed_fragments: dict[int, int] = {} + start = 0 + while start < len(values): + part = len(pages) + max_end = _max_array_page_end( + prefix_bytes, + start=start, + bound_bytes=bound_bytes, + ) + if max_end == len(values): + terminal_candidate = _array_page( + definition=selected.definition, + canonical_elements=canonical_elements, + start=start, + end=max_end, + ) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=terminal_candidate, + part=part, + total_width=total_width, + terminal=True, + bound_bytes=bound_bytes, + ): + pages.append(terminal_candidate) + break + + low = start + 1 + high = min(max_end, len(values) - 1) + accepted: PlannedRecipeSectionPage | None = None + accepted_end = start + while low <= high: + end = (low + high) // 2 + candidate = _array_page( + definition=selected.definition, + canonical_elements=canonical_elements, + start=start, + end=end, + ) + if _fits( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=candidate, + part=part, + total_width=total_width, + terminal=False, + bound_bytes=bound_bytes, + ): + accepted = candidate + accepted_end = end + low = end + 1 + else: + high = end - 1 + if accepted is not None: + pages.append(accepted) + start = accepted_end + continue + + fragments = _fragment_pages( + selected=selected, + generation=generation, + section_sha256=section_sha256, + element=values[start], + canonical_element=canonical_elements[start], + element_index=start, + element_total=len(values), + part_start=len(pages), + total_width=total_width, + fragment_width=fragment_widths.get(start, 1), + bound_bytes=bound_bytes, + ) + pages.extend(fragments) + observed_fragments[start] = len(fragments) + start += 1 + return pages, observed_fragments + + +def _selected_section_sha256(selected: SelectedRecipeSection) -> str: + if not selected.present: + raise ValueError(f"recipe section {selected.section!r} is absent") + raw = selected.definition.section_strategy == "raw" + return recipe_section_digest(selected.value, raw=raw) + + +def _plan_pages( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + section_sha256: str, + total_width: int, + fragment_widths: Mapping[int, int], + bound_bytes: int, +) -> tuple[list[PlannedRecipeSectionPage], dict[int, int]]: + strategy = selected.definition.section_strategy + if strategy in {"raw", "scalar"}: + if type(selected.value) is not str: + raise TypeError(f"{strategy} recipe section strategy requires a string") + return ( + _raw_or_scalar_pages( + selected=selected, + generation=generation, + section_sha256=section_sha256, + value=selected.value, + total_width=total_width, + bound_bytes=bound_bytes, + scalar=strategy == "scalar", + ), + {}, + ) + if strategy == "array": + if type(selected.value) is not list: + raise TypeError("array recipe section strategy requires a list") + return _array_pages( + selected=selected, + generation=generation, + section_sha256=section_sha256, + values=selected.value, + total_width=total_width, + fragment_widths=fragment_widths, + bound_bytes=bound_bytes, + ) + raise ValueError(f"unknown recipe section strategy: {strategy}") + + +def build_recipe_section_page_plan( + *, + kitchen_id: str, + generation: RecipeArtifactGeneration, + selected: SelectedRecipeSection, + recipe_section_bound_bytes: int, +) -> RecipeSectionPagePlan: + """Build, finalize, and verify one deterministic immutable page plan.""" + del kitchen_id # Identity participates in the cache key, not the manifest. + if recipe_section_bound_bytes <= 0: + raise RecipeSectionBoundError("recipe section bound must be positive") + section_sha256 = _selected_section_sha256(selected) + total_width = 1 + fragment_widths: dict[int, int] = {} + seen_states: set[tuple[int, tuple[tuple[int, int], ...]]] = set() + pages: list[PlannedRecipeSectionPage] = [] + for _ in range(_convergence_iteration_ceiling()): + state = (total_width, tuple(sorted(fragment_widths.items()))) + if state in seen_states: + raise RecipeSectionNonConvergenceError("recipe section pagination did not converge") + seen_states.add(state) + pages, observed_fragments = _plan_pages( + selected=selected, + generation=generation, + section_sha256=section_sha256, + total_width=total_width, + fragment_widths=fragment_widths, + bound_bytes=recipe_section_bound_bytes, + ) + required_total_width = len(str(len(pages))) + required_fragment_widths = { + index: len(str(count)) for index, count in observed_fragments.items() + } + grew = required_total_width > total_width + if grew: + total_width = required_total_width + for index, width in required_fragment_widths.items(): + if width > fragment_widths.get(index, 1): + fragment_widths[index] = width + grew = True + if not grew: + break + else: + raise RecipeSectionNonConvergenceError("recipe section pagination did not converge") + + manifest = RecipeSectionPlanManifest( + pagination_version=RECIPE_SECTION_PAGINATION_VERSION, + section_registry_sha256=RECIPE_SECTION_REGISTRY_DIGEST, + pagination_policy_sha256=RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + generation=generation, + section=selected.section, + section_strategy=selected.definition.section_strategy, + section_sha256=section_sha256, + recipe_section_bound_bytes=recipe_section_bound_bytes, + pages=tuple(page.descriptor for page in pages), + ) + page_plan_sha256 = recipe_section_plan_digest(manifest) + rendered_pages = tuple( + _render_candidate( + selected=selected, + generation=generation, + section_sha256=section_sha256, + page=page, + part=index, + total_parts=len(pages), + page_plan_sha256=page_plan_sha256, + terminal=index + 1 == len(pages), + ) + for index, page in enumerate(pages) + ) + verify_finalized_recipe_section_plan( + selected=selected, + generation=generation, + pages=pages, + rendered_pages=rendered_pages, + page_plan_sha256=page_plan_sha256, + bound_bytes=recipe_section_bound_bytes, + pagination_version=RECIPE_SECTION_PAGINATION_VERSION, + section_registry_sha256=RECIPE_SECTION_REGISTRY_DIGEST, + section_sha256=section_sha256, + ) + cache_weight = sum(len(rendered.encode("utf-8")) for rendered in rendered_pages) + cache_weight += len(canonical_recipe_section_json(manifest).encode("utf-8")) + cache_weight += sum(len(page.content.encode("utf-8")) for page in pages) + return RecipeSectionPagePlan( + manifest=manifest, + page_plan_sha256=page_plan_sha256, + rendered_pages=rendered_pages, + cache_weight_bytes=cache_weight, + ) + + +def _cache_key( + *, + kitchen_id: str, + generation: RecipeArtifactGeneration, + selected: SelectedRecipeSection, + recipe_section_bound_bytes: int, +) -> _PagePlanCacheKey: + return _PagePlanCacheKey( + kitchen_id=kitchen_id, + generation=generation, + section=selected.section, + section_sha256=_selected_section_sha256(selected), + recipe_section_bound_bytes=recipe_section_bound_bytes, + section_registry_sha256=RECIPE_SECTION_REGISTRY_DIGEST, + pagination_policy_sha256=RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + pagination_version=RECIPE_SECTION_PAGINATION_VERSION, + ) + + +def get_or_build_recipe_section_page_plan( + *, + kitchen_id: str, + generation: RecipeArtifactGeneration, + selected: SelectedRecipeSection, + recipe_section_bound_bytes: int, +) -> RecipeSectionPagePlan: + """Return a verified cached plan or build and admit one.""" + key = _cache_key( + kitchen_id=kitchen_id, + generation=generation, + selected=selected, + recipe_section_bound_bytes=recipe_section_bound_bytes, + ) + cache = _page_plan_cache() + return cache.get_or_build( + key, + lambda: build_recipe_section_page_plan( + kitchen_id=kitchen_id, + generation=generation, + selected=selected, + recipe_section_bound_bytes=recipe_section_bound_bytes, + ), + ) + + +def render_recipe_section_page(plan: RecipeSectionPagePlan, part: int) -> str: + """Return one already-finalized exact page rendering.""" + return plan.rendered_pages[part] diff --git a/src/autoskillit/server/recipe_section/AGENTS.md b/src/autoskillit/server/recipe_section/AGENTS.md new file mode 100644 index 000000000..d050bd3c1 --- /dev/null +++ b/src/autoskillit/server/recipe_section/AGENTS.md @@ -0,0 +1,13 @@ +# recipe_section/ + +Supporting internals for deterministic, schema-driven recipe-section delivery. + +## Files + +| File | Purpose | +|---|---| +| `__init__.py` | Package marker with no public re-exports | +| `_contracts.py` | Shared planner/verifier errors, selections, descriptors, manifests, and page-plan types | +| `_lifecycle.py` | One-way kitchen-retirement callback registry for supporting-state cleanup | +| `_rendering.py` | Bounded wire rendering for registered recipe-section tool failures | +| `_verification.py` | Post-digest descriptor, reconstruction, ordering, and rendered-bound invariant proof | diff --git a/src/autoskillit/server/recipe_section/CLAUDE.md b/src/autoskillit/server/recipe_section/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/src/autoskillit/server/recipe_section/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/autoskillit/server/recipe_section/__init__.py b/src/autoskillit/server/recipe_section/__init__.py new file mode 100644 index 000000000..3b42acd84 --- /dev/null +++ b/src/autoskillit/server/recipe_section/__init__.py @@ -0,0 +1 @@ +"""Recipe-section planning support internals.""" diff --git a/src/autoskillit/server/recipe_section/_contracts.py b/src/autoskillit/server/recipe_section/_contracts.py new file mode 100644 index 000000000..e861bb02f --- /dev/null +++ b/src/autoskillit/server/recipe_section/_contracts.py @@ -0,0 +1,214 @@ +"""Shared contracts for recipe-section planning and final verification.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from autoskillit.core import ( + RECIPE_SECTION_CONTENT_FORMAT_REGISTRY, + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, + RecipeSectionDef, +) +from autoskillit.server._recipe_delivery import RecipeArtifactGeneration + +RecipeSectionContentFormat = Literal[ + "raw-text", + "json-scalar-page", + "json-array-page", + "json-element-fragment", +] +_RANGE_FIELDS_BY_FORMAT = { + content_format: frozenset(definition.range_fields) + for content_format, definition in RECIPE_SECTION_CONTENT_FORMAT_REGISTRY.items() +} +_RANGE_TRIPLE_BY_FORMAT = { + "raw-text": ("byte_start", "byte_end", "byte_total"), + "json-scalar-page": ("scalar_byte_start", "scalar_byte_end", "scalar_byte_total"), + "json-array-page": ("element_start", "element_end", "element_total"), + "json-element-fragment": ( + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + ), +} +RECIPE_SECTION_PAGE_RANGE_FIELDS = frozenset( + field for range_fields in _RANGE_FIELDS_BY_FORMAT.values() for field in range_fields +) +_SHA256_PREFIX = "sha256:" +_LOWERCASE_HEX = frozenset("0123456789abcdef") + + +def _is_sha256_digest(value: object) -> bool: + return ( + type(value) is str + and value.startswith(_SHA256_PREFIX) + and len(value) == len(_SHA256_PREFIX) + 64 + and all(character in _LOWERCASE_HEX for character in value[len(_SHA256_PREFIX) :]) + ) + + +class RecipeSectionPaginationError(RuntimeError): + """A verified immutable page plan could not be established.""" + + +class RecipeSectionNonConvergenceError(RecipeSectionPaginationError): + """Pagination width planning could not reach a stable state.""" + + +class RecipeSectionBoundError(RecipeSectionPaginationError): + """The captured request bound cannot fit one progress-making page.""" + + +@dataclass(frozen=True, slots=True) +class RecipeSectionRequestState: + """One captured request admission and byte-bound decision.""" + + admitted: bool + recipe_section_bound_bytes: int + + def __post_init__(self) -> None: + """Reject bounds that cannot contain every mandatory failure response.""" + if ( + type(self.recipe_section_bound_bytes) is not int + or self.recipe_section_bound_bytes < RECIPE_SECTION_RESPONSE_FLOOR_BYTES + ): + raise ValueError( + "recipe section bound must be an integer at least " + f"{RECIPE_SECTION_RESPONSE_FLOOR_BYTES} bytes" + ) + + +@dataclass(frozen=True, slots=True) +class SelectedRecipeSection: + """A registry-selected section whose value remains typed.""" + + section: str + definition: RecipeSectionDef + value: object + present: bool + + +@dataclass(frozen=True, slots=True) +class RecipeSectionPageDescriptor: + """One immutable page boundary in a page-plan manifest.""" + + content_format: RecipeSectionContentFormat + page_content_sha256: str + byte_start: int | None = None + byte_end: int | None = None + byte_total: int | None = None + element_start: int | None = None + element_end: int | None = None + element_total: int | None = None + scalar_byte_start: int | None = None + scalar_byte_end: int | None = None + scalar_byte_total: int | None = None + element_index: int | None = None + element_sha256: str | None = None + fragment_index: int | None = None + fragment_count: int | None = None + fragment_byte_start: int | None = None + fragment_byte_end: int | None = None + fragment_byte_total: int | None = None + + def __post_init__(self) -> None: + """Require a typed, ordered range family and valid content digests.""" + expected_fields = _RANGE_FIELDS_BY_FORMAT.get(self.content_format) + if expected_fields is None: + raise ValueError(f"unknown recipe section content format: {self.content_format!r}") + populated_fields = { + name for name in RECIPE_SECTION_PAGE_RANGE_FIELDS if getattr(self, name) is not None + } + if populated_fields != expected_fields: + raise ValueError( + "recipe section page descriptor range fields must exactly match content format" + ) + if not _is_sha256_digest(self.page_content_sha256): + raise ValueError("recipe section page content digest must be lowercase sha256") + + numeric_values: dict[str, int] = {} + for name in expected_fields: + value = getattr(self, name) + if name.endswith("_sha256"): + if not _is_sha256_digest(value): + raise ValueError(f"recipe section {name} must be a lowercase sha256 digest") + continue + if type(value) is not int or value < 0: + raise ValueError(f"recipe section {name} must be a non-negative integer") + numeric_values[name] = value + + start_name, end_name, total_name = _RANGE_TRIPLE_BY_FORMAT[self.content_format] + start = numeric_values[start_name] + end = numeric_values[end_name] + total = numeric_values[total_name] + if not start <= end <= total or (total > 0 and start == end): + raise ValueError( + "recipe section page range must make ordered progress within its total" + ) + + if self.content_format == "json-element-fragment": + fragment_index = numeric_values["fragment_index"] + fragment_count = numeric_values["fragment_count"] + if fragment_count == 0 or fragment_index >= fragment_count: + raise ValueError( + "recipe section fragment index must be within a positive fragment count" + ) + + def wire_ranges(self) -> dict[str, int | str]: + values: dict[str, int | str | None] = { + "byte_start": self.byte_start, + "byte_end": self.byte_end, + "byte_total": self.byte_total, + "element_start": self.element_start, + "element_end": self.element_end, + "element_total": self.element_total, + "scalar_byte_start": self.scalar_byte_start, + "scalar_byte_end": self.scalar_byte_end, + "scalar_byte_total": self.scalar_byte_total, + "element_index": self.element_index, + "element_sha256": self.element_sha256, + "fragment_index": self.fragment_index, + "fragment_count": self.fragment_count, + "fragment_byte_start": self.fragment_byte_start, + "fragment_byte_end": self.fragment_byte_end, + "fragment_byte_total": self.fragment_byte_total, + } + return {name: value for name, value in values.items() if value is not None} + + +@dataclass(frozen=True, slots=True) +class PlannedRecipeSectionPage: + """One descriptor paired with its exact independently decodable content.""" + + descriptor: RecipeSectionPageDescriptor + content: str + + +@dataclass(frozen=True, slots=True) +class RecipeSectionPlanManifest: + """Sole non-self-referential preimage for one page-plan digest.""" + + pagination_version: int + section_registry_sha256: str + pagination_policy_sha256: str + generation: RecipeArtifactGeneration + section: str + section_strategy: str + section_sha256: str + recipe_section_bound_bytes: int + pages: tuple[RecipeSectionPageDescriptor, ...] + + +@dataclass(frozen=True, slots=True) +class RecipeSectionPagePlan: + """A finalized immutable manifest and its exact rendered pages.""" + + manifest: RecipeSectionPlanManifest + page_plan_sha256: str + rendered_pages: tuple[str, ...] + cache_weight_bytes: int + + @property + def total_parts(self) -> int: + return len(self.rendered_pages) diff --git a/src/autoskillit/server/recipe_section/_lifecycle.py b/src/autoskillit/server/recipe_section/_lifecycle.py new file mode 100644 index 000000000..b764c9ecc --- /dev/null +++ b/src/autoskillit/server/recipe_section/_lifecycle.py @@ -0,0 +1,38 @@ +"""One-way lifecycle notifications for recipe-section supporting state.""" + +from __future__ import annotations + +from collections.abc import Callable +from threading import RLock + +from autoskillit.core import get_logger + +logger = get_logger(__name__) + +_KitchenRetirementCallback = Callable[[str], None] +_KITCHEN_RETIREMENT_CALLBACKS: set[_KitchenRetirementCallback] = set() +_KITCHEN_RETIREMENT_LOCK = RLock() + + +def register_kitchen_retirement_callback(callback: _KitchenRetirementCallback) -> None: + """Register an idempotent recipe-section cleanup callback.""" + with _KITCHEN_RETIREMENT_LOCK: + _KITCHEN_RETIREMENT_CALLBACKS.add(callback) + + +def notify_kitchen_retired(kitchen_id: str) -> None: + """Notify registered supporting state after artifact retirement succeeds.""" + with _KITCHEN_RETIREMENT_LOCK: + callbacks = tuple(_KITCHEN_RETIREMENT_CALLBACKS) + for callback in callbacks: + try: + callback(kitchen_id) + except Exception: + callback_module = getattr(callback, "__module__", type(callback).__module__) + callback_name = getattr(callback, "__qualname__", type(callback).__qualname__) + logger.warning( + "recipe_section_retirement_callback_failed", + kitchen_id=kitchen_id, + callback=f"{callback_module}.{callback_name}", + exc_info=True, + ) diff --git a/src/autoskillit/server/recipe_section/_rendering.py b/src/autoskillit/server/recipe_section/_rendering.py new file mode 100644 index 000000000..1626c8190 --- /dev/null +++ b/src/autoskillit/server/recipe_section/_rendering.py @@ -0,0 +1,32 @@ +"""Bounded wire rendering for recipe-section tool failures.""" + +from __future__ import annotations + +from collections.abc import Mapping + +from autoskillit.core import ( + RECIPE_SECTION_MANDATORY_FAILURE_CODES, + canonical_recipe_section_json, +) + + +def render_recipe_section_failure( + code: str, + *, + bound_bytes: int, + context: Mapping[str, object] | None = None, +) -> str: + """Render one registered atomic failure, dropping context before truncation.""" + if code not in RECIPE_SECTION_MANDATORY_FAILURE_CODES: + raise ValueError(f"unregistered recipe section failure code: {code}") + + base: dict[str, object] = {"error": code, "success": False} + if context: + candidate = dict(base) + candidate.update( + {name: value for name, value in context.items() if name not in {"error", "success"}} + ) + rendered = canonical_recipe_section_json(candidate) + if len(rendered.encode("utf-8")) <= bound_bytes: + return rendered + return canonical_recipe_section_json(base) diff --git a/src/autoskillit/server/recipe_section/_verification.py b/src/autoskillit/server/recipe_section/_verification.py new file mode 100644 index 000000000..ec8f9a080 --- /dev/null +++ b/src/autoskillit/server/recipe_section/_verification.py @@ -0,0 +1,313 @@ +"""Final invariant proof for immutable recipe-section page plans.""" + +from __future__ import annotations + +import hashlib +import json + +from autoskillit.core import recipe_section_element_digest +from autoskillit.server._recipe_delivery import RecipeArtifactGeneration +from autoskillit.server.recipe_section._contracts import ( + RECIPE_SECTION_PAGE_RANGE_FIELDS, + PlannedRecipeSectionPage, + RecipeSectionPaginationError, + SelectedRecipeSection, +) + + +def _pagination_error(message: str, *, cause: Exception | None = None) -> Exception: + error = RecipeSectionPaginationError(message) + if cause is not None: + error.__cause__ = cause + return error + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise _pagination_error(message) + + +def _decode(content: str, message: str) -> object: + try: + return json.loads(content) + except (TypeError, json.JSONDecodeError) as exc: + raise _pagination_error(message, cause=exc) + + +def _content_digest(content: str) -> str: + return f"sha256:{hashlib.sha256(content.encode('utf-8')).hexdigest()}" + + +def _verify_reconstruction( + selected: SelectedRecipeSection, + pages: list[PlannedRecipeSectionPage], +) -> None: + strategy = selected.definition.section_strategy + reconstructed: object + if strategy == "raw": + reconstructed = "".join(page.content for page in pages) + elif strategy == "scalar": + reconstructed = "".join(json.loads(page.content) for page in pages) + else: + reconstructed_values: list[object] = [] + index = 0 + while index < len(pages): + page = pages[index] + descriptor = page.descriptor + if descriptor.content_format == selected.definition.ordinary_content_format: + reconstructed_values.extend(json.loads(page.content)) + index += 1 + continue + count = descriptor.fragment_count or 0 + canonical = "".join( + json.loads(pages[index + offset].content) for offset in range(count) + ) + reconstructed_values.append(json.loads(canonical)) + index += count + reconstructed = reconstructed_values + _require( + reconstructed == selected.value, + "recipe section reconstruction mismatch", + ) + + +def _verify_string_descriptors( + selected: SelectedRecipeSection, + pages: list[PlannedRecipeSectionPage], +) -> None: + strategy = selected.definition.section_strategy + value = selected.value + if type(value) is not str: + raise _pagination_error("string recipe section strategy requires a string") + expected_format = selected.definition.ordinary_content_format + range_prefix = "byte" if strategy == "raw" else "scalar_byte" + total = len(value.encode("utf-8")) + expected_start = 0 + for page in pages: + descriptor = page.descriptor + start = getattr(descriptor, f"{range_prefix}_start") + end = getattr(descriptor, f"{range_prefix}_end") + descriptor_total = getattr(descriptor, f"{range_prefix}_total") + decoded = ( + page.content + if strategy == "raw" + else _decode( + page.content, + "recipe section string page is not independently decodable", + ) + ) + _require( + descriptor.content_format == expected_format, + "recipe section page format changed during finalization", + ) + if type(decoded) is not str: + raise _pagination_error("recipe section string page is not independently decodable") + if ( + type(start) is not int + or type(end) is not int + or start != expected_start + or end < start + ): + raise _pagination_error("recipe section string ranges are not contiguous") + _require( + end > start or (total == 0 and len(pages) == 1), + "recipe section string page makes no progress", + ) + _require( + descriptor_total == total + and end <= total + and end - start == len(decoded.encode("utf-8")), + "recipe section string range does not match its content", + ) + expected_start = end + _require( + expected_start == total, + "recipe section string ranges do not reconstruct the section", + ) + + +def _verify_array_descriptors( + selected: SelectedRecipeSection, + pages: list[PlannedRecipeSectionPage], +) -> None: + values = selected.value + if type(values) is not list: + raise _pagination_error("array recipe section strategy requires a list") + expected_element = 0 + fragment_chunks: list[str] = [] + fragment_count: int | None = None + fragment_total: int | None = None + fragment_digest: str | None = None + fragment_end = 0 + for page in pages: + descriptor = page.descriptor + if descriptor.content_format == selected.definition.ordinary_content_format: + _require( + fragment_count is None, + "complete array page interrupts an element fragment", + ) + decoded = _decode(page.content, "array page is not independently decodable") + if type(decoded) is not list: + raise _pagination_error("array page is not independently decodable") + element_end = descriptor.element_end + if ( + descriptor.element_start != expected_element + or type(element_end) is not int + or element_end < expected_element + ): + raise _pagination_error("array element ranges are not contiguous") + _require( + element_end > expected_element or (not values and len(pages) == 1), + "array page makes no progress", + ) + _require( + descriptor.element_total == len(values) + and element_end <= len(values) + and element_end - expected_element == len(decoded), + "array range does not match its content", + ) + expected_element = element_end + continue + + _require( + descriptor.content_format == selected.definition.oversized_content_format, + "array plan contains an unknown page format", + ) + decoded_fragment = _decode( + page.content, + "array element fragment is not independently decodable", + ) + if type(decoded_fragment) is not str: + raise _pagination_error("array element fragment is not independently decodable") + _require( + descriptor.element_index == expected_element and expected_element < len(values), + "array element fragment ordering is not contiguous", + ) + if fragment_count is None: + next_count = descriptor.fragment_count + next_total = descriptor.fragment_byte_total + if ( + descriptor.fragment_index != 0 + or type(next_count) is not int + or next_count <= 0 + or type(next_total) is not int + or next_total <= 0 + or not isinstance(descriptor.element_sha256, str) + ): + raise _pagination_error("array element fragment identity is invalid") + fragment_count = next_count + fragment_total = next_total + fragment_digest = descriptor.element_sha256 + fragment_end = 0 + if fragment_total is None: + raise _pagination_error("array element fragment identity is invalid") + _require( + descriptor.fragment_count == fragment_count + and descriptor.fragment_byte_total == fragment_total + and descriptor.element_sha256 == fragment_digest + and descriptor.fragment_index == len(fragment_chunks), + "array element fragment identity changed", + ) + fragment_byte_end = descriptor.fragment_byte_end + if ( + descriptor.fragment_byte_start != fragment_end + or type(fragment_byte_end) is not int + or fragment_byte_end <= fragment_end + ): + raise _pagination_error("array element fragment ranges are not contiguous") + _require( + fragment_byte_end - fragment_end == len(decoded_fragment.encode("utf-8")) + and fragment_byte_end <= fragment_total, + "array element fragment range does not match its content", + ) + fragment_chunks.append(decoded_fragment) + fragment_end = fragment_byte_end + if len(fragment_chunks) == fragment_count: + canonical_element = "".join(fragment_chunks) + reconstructed_element = _decode( + canonical_element, + "array element fragments do not reconstruct their element", + ) + _require( + fragment_end == fragment_total + and recipe_section_element_digest(reconstructed_element) == fragment_digest + and reconstructed_element == values[expected_element], + "array element fragments do not reconstruct their element", + ) + expected_element += 1 + fragment_chunks = [] + fragment_count = None + fragment_total = None + fragment_digest = None + fragment_end = 0 + _require( + fragment_count is None and expected_element == len(values), + "array ranges do not reconstruct the section", + ) + + +def verify_finalized_recipe_section_plan( + *, + selected: SelectedRecipeSection, + generation: RecipeArtifactGeneration, + pages: list[PlannedRecipeSectionPage], + rendered_pages: tuple[str, ...], + page_plan_sha256: str, + bound_bytes: int, + pagination_version: int, + section_registry_sha256: str, + section_sha256: str, +) -> None: + """Validate finalized descriptors and renderings after digest injection.""" + _require(bool(pages), "recipe section plan has no pages") + strategy = selected.definition.section_strategy + if strategy in {"raw", "scalar"}: + _verify_string_descriptors(selected, pages) + else: + _require(strategy == "array", "unknown recipe section strategy") + _verify_array_descriptors(selected, pages) + _verify_reconstruction(selected, pages) + _require( + len(rendered_pages) == len(pages), + "final recipe section rendering count changed", + ) + for part, (page, rendered) in enumerate(zip(pages, rendered_pages, strict=True)): + _require( + len(rendered.encode("utf-8")) <= bound_bytes, + "final recipe section page exceeds captured bound", + ) + response = _decode(rendered, "final recipe section page is not valid JSON") + if type(response) is not dict: + raise _pagination_error("final recipe section page is not a JSON object") + terminal = part + 1 == len(pages) + expected_ranges = page.descriptor.wire_ranges() + actual_ranges = { + name: response[name] for name in RECIPE_SECTION_PAGE_RANGE_FIELDS if name in response + } + _require( + response.get("success") is True + and response.get("pagination_version") == pagination_version + and response.get("section_registry_sha256") == section_registry_sha256 + and response.get("section") == selected.section + and response.get("section_sha256") == section_sha256 + and response.get("page_plan_sha256") == page_plan_sha256 + and response.get("payload_sha256") == generation.payload_sha256 + and response.get("body_sha256") == generation.body_sha256 + and response.get("part") == part + and response.get("total_parts") == len(pages) + and response.get("has_more") is not terminal + and response.get("content_format") == page.descriptor.content_format + and response.get("content") == page.content + and actual_ranges == expected_ranges, + "final recipe section rendering changed plan identity or boundaries", + ) + _require( + _content_digest(page.content) == page.descriptor.page_content_sha256, + "recipe section page content digest changed", + ) + _require( + (response.get("next_part") == part + 1) + if not terminal + else "next_part" not in response, + "recipe section continuation metadata is inconsistent", + ) diff --git a/src/autoskillit/server/tools/AGENTS.md b/src/autoskillit/server/tools/AGENTS.md index eb26cc897..600e0f4d7 100644 --- a/src/autoskillit/server/tools/AGENTS.md +++ b/src/autoskillit/server/tools/AGENTS.md @@ -9,7 +9,7 @@ MCP `@mcp.tool()` handlers registered on import (20 tool modules). | `__init__.py` | Docstring-only — tools register via `@mcp.tool()` on import | | `_auto_overrides.py` | Shared `_build_auto_overrides()` factory for server-authoritative ingredient injection | | `_authority_feedback.py` | Authority-clobber warning builder and structured rejection envelope constructor for server-authoritative ingredient violations (single source of truth shared by open_kitchen, load_recipe, lock_ingredients) | -| `_cancellation_shield.py` | `_cancellation_shield` decorator — catches `asyncio.CancelledError` at MCP tool boundary, returns structured JSON | +| `_cancellation_shield.py` | `_cancellation_shield` decorator — legacy and typed request-state conversion of transport `asyncio.CancelledError` into structured MCP results | | `_backend_compat.py` | Shared target resolution and fail-closed backend compatibility gate for direct headless executor callers | | `_types.py` | TypedDict definitions for server tool JSON responses (RunSkillResult, RunCmdResult, ToolFailureEnvelope, etc.), failure envelope factory helpers, and `deny_envelope()` canonical pre-flight deny constructor | | `tools_kitchen.py` | `open_kitchen`, `close_kitchen` (gate lifecycle), `recipe://` MCP resource, `prune_stale_kitchen_state` (liveness-gated tracker pruning at open; uses `kitchen_entry_alive` from `core/_plugin_cache`) | @@ -34,7 +34,7 @@ MCP `@mcp.tool()` handlers registered on import (20 tool modules). | `tools_issue_labels.py` | `claim_issue`, `release_issue` (GitHub label management) | | `tools_issue_composite.py` | `claim_and_resolve_issue` | | `tools_pr_ops.py` | `get_pr_reviews`, `bulk_close_issues` | -| `tools_recipe.py` | `load_recipe`, `list_recipes`, `validate_recipe`, `migrate_recipe` | +| `tools_recipe.py` | Recipe tools including schema-validated, versioned `get_recipe_section` request-state capture, recreation, pagination, and bounded failure routing | | `tools_status.py` | `kitchen_status`, `get_pipeline_report`, `get_token_summary`, `get_timing_summary`, `analyze_tool_sequences`, `get_quota_events`, `write_telemetry_files`, `read_db` | | `tools_pipeline_tracker.py` | `record_pipeline_step` (pipeline step tracker init/status/complete), `resolve_tracker_order_id` (shared 3-tier tracker resolver), `mark_step_complete` (adjudication-point marker) | | `tools_workspace.py` | `test_check`, `reset_test_dir`, `reset_workspace` | diff --git a/src/autoskillit/server/tools/_cancellation_shield.py b/src/autoskillit/server/tools/_cancellation_shield.py index 0a74170b6..a8491994e 100644 --- a/src/autoskillit/server/tools/_cancellation_shield.py +++ b/src/autoskillit/server/tools/_cancellation_shield.py @@ -11,8 +11,9 @@ import asyncio import json from collections.abc import Callable +from contextvars import ContextVar from functools import wraps -from typing import Any, Literal, TypeVar +from typing import Any, Literal, TypeVar, cast, overload import anyio @@ -21,29 +22,92 @@ logger = get_logger(__name__) F = TypeVar("F", bound=Callable[..., Any]) +StateT = TypeVar("StateT") +ResultType = Literal["fleet_error", "run_cmd", "run_python", "generic"] +_RESULT_TYPE_UNSET = object() +_TYPED_ARGUMENT_UNSET = object() + + +@overload +def _cancellation_shield() -> Callable[[F], F]: ... + + +@overload +def _cancellation_shield( + result_type: ResultType, +) -> Callable[[F], F]: ... + + +@overload +def _cancellation_shield( + *, + state_factory: Callable[[], StateT], + state_context_var: ContextVar[StateT], + response_factory: Callable[[StateT, asyncio.CancelledError], str], +) -> Callable[[F], F]: ... def _cancellation_shield( - result_type: Literal["fleet_error", "run_cmd", "run_python", "generic"] = "generic", + result_type: ResultType | object = _RESULT_TYPE_UNSET, + *, + state_factory: Callable[[], Any] | object = _TYPED_ARGUMENT_UNSET, + state_context_var: ContextVar[Any] | object = _TYPED_ARGUMENT_UNSET, + response_factory: Callable[[Any, asyncio.CancelledError], str] + | object = _TYPED_ARGUMENT_UNSET, ) -> Callable[[F], F]: - """Apply BELOW @mcp.tool() and ABOVE @track_response_size(). + """Convert transport cancellation into a structured MCP-boundary response. - result_type controls the response schema: - - "fleet_error": fleet_error() envelope (dispatch_food_truck, record_gate_dispatch) - - "run_cmd": {"success": False, "exit_code": -1, "stdout": "", "stderr": ...} - - "run_python": {"success": False, "exit_code": -1, "stdout": "", "stderr": ...} - - "generic" (default): {"success": False, "error": "cancelled", "subtype": "cancelled"} + Legacy result modes retain their existing response schemas. Typed mode captures + one request state before the handler, publishes that exact object through a + ContextVar, and passes it to the cancellation response factory. """ + typed_arguments = (state_factory, state_context_var, response_factory) + typed_count = sum(argument is not _TYPED_ARGUMENT_UNSET for argument in typed_arguments) + if typed_count not in {0, 3}: + raise TypeError( + "state_factory, state_context_var, and response_factory must be provided together" + ) + typed_mode = typed_count == 3 + if typed_mode and result_type is not _RESULT_TYPE_UNSET: + raise TypeError("result_type cannot be supplied with typed cancellation mode") + if typed_mode: + if not callable(state_factory): + raise TypeError("state_factory must be callable") + if not isinstance(state_context_var, ContextVar): + raise TypeError("state_context_var must be a ContextVar") + if not callable(response_factory): + raise TypeError("response_factory must be callable") + resolved_result_type = ( + "generic" if result_type is _RESULT_TYPE_UNSET else cast(ResultType, result_type) + ) def decorator(fn: F) -> F: @wraps(fn) async def wrapper(*args: Any, **kwargs: Any) -> Any: + if typed_mode: + typed_state_factory = cast(Callable[[], Any], state_factory) + typed_context_var = cast(ContextVar[Any], state_context_var) + typed_response_factory = cast( + Callable[[Any, asyncio.CancelledError], str], + response_factory, + ) + state = typed_state_factory() + token = typed_context_var.set(state) + try: + try: + return await fn(*args, **kwargs) + except asyncio.CancelledError as exc: + with anyio.CancelScope(shield=True): + logger.warning("mcp_tool_cancelled", tool=fn.__name__) + return typed_response_factory(state, exc) + finally: + typed_context_var.reset(token) try: return await fn(*args, **kwargs) except asyncio.CancelledError: with anyio.CancelScope(shield=True): logger.warning("mcp_tool_cancelled", tool=fn.__name__) - return _build_cancellation_response(result_type) + return _build_cancellation_response(resolved_result_type) return wrapper # type: ignore[return-value] diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index 46a495c6b..8dfd6b913 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -2,7 +2,10 @@ from __future__ import annotations +import asyncio import json +from collections.abc import Mapping +from contextvars import ContextVar from pathlib import Path from typing import Any, cast @@ -37,6 +40,7 @@ from autoskillit.server._recipe_delivery import ( RecipeArtifactError, RecipeArtifactGeneration, + RecipeArtifactSchemaError, document_recipe_delivery_contract, finalize_recipe_delivery, load_recipe_artifact, @@ -44,6 +48,17 @@ recipe_pull_producers, recipe_recreation_producers, ) +from autoskillit.server._recipe_section_pagination import ( + RecipeSectionBoundError, + RecipeSectionNonConvergenceError, + RecipeSectionPaginationError, + RecipeSectionRequestState, + get_or_build_recipe_section_page_plan, + render_recipe_section_failure, + render_recipe_section_page, + resolve_recipe_section_bound_bytes, + select_recipe_section, +) from autoskillit.server._state import _get_ctx_or_none from autoskillit.server.tools._authority_feedback import build_authority_clobber_warnings from autoskillit.server.tools._auto_overrides import ( @@ -72,61 +87,70 @@ def __init__(self, code: str, detail: str) -> None: self.code = code -def _bounded_recipe_section_response( - section: str, - content: str, +_RECIPE_SECTION_REQUEST_STATE: ContextVar[RecipeSectionRequestState] = ContextVar( + "recipe_section_request_state" +) + + +def _recipe_section_request_state_factory() -> RecipeSectionRequestState: + tool_ctx = _get_ctx_or_none() + admitted = tool_ctx is not None and tool_ctx.recipes is not None + response_max_bytes = 90_000 + conservative_limit = 10_000 + if tool_ctx is not None: + configured_response_max = getattr( + getattr(tool_ctx.config, "output_budget", None), + "response_max_bytes", + None, + ) + if isinstance(configured_response_max, int) and configured_response_max > 0: + response_max_bytes = configured_response_max + backend_capabilities = ( + tool_ctx.backend.capabilities + if tool_ctx.backend is not None + and isinstance( + getattr(tool_ctx.backend, "capabilities", None), + BackendCapabilities, + ) + else None + ) + if backend_capabilities is not None: + conservative_limit = resolve_general_output_token_limit(backend_capabilities) + return RecipeSectionRequestState( + admitted=admitted, + recipe_section_bound_bytes=resolve_recipe_section_bound_bytes( + response_max_bytes, + conservative_limit, + ), + ) + + +def _current_recipe_section_request_state() -> RecipeSectionRequestState: + return _RECIPE_SECTION_REQUEST_STATE.get() + + +def _recipe_section_cancellation_response( + state: RecipeSectionRequestState, + _error: asyncio.CancelledError, +) -> str: + return render_recipe_section_failure( + "recipe_section_cancelled", + bound_bytes=state.recipe_section_bound_bytes, + context={"admitted": state.admitted}, + ) + + +def _recipe_section_failure( + code: str, *, - part: int, - bound_bytes: int, - generation: RecipeArtifactGeneration, + context: Mapping[str, object] | None = None, ) -> str: - """Render one continuation chunk whose serialized UTF-8 size fits the bound.""" - - def _render(start: int, end: int) -> str: - has_more = end < len(content) - response: dict[str, Any] = { - "success": True, - "section": section, - "content": content[start:end], - "has_more": has_more, - "byte_start": len(content[:start].encode("utf-8")), - "byte_end": len(content[:end].encode("utf-8")), - "byte_total": len(content.encode("utf-8")), - "payload_sha256": generation.payload_sha256, - "body_sha256": generation.body_sha256, - } - if has_more: - response["next_part"] = chunk_index + 1 - return json.dumps(response, ensure_ascii=False) - - start = 0 - for chunk_index in range(part + 1): - full = _render(start, len(content)) - if len(full.encode("utf-8")) <= bound_bytes: - end = len(content) - rendered = full - else: - low = start + 1 - high = len(content) - 1 - end = start - rendered = "" - while low <= high: - candidate_end = (low + high) // 2 - candidate = _render(start, candidate_end) - if len(candidate.encode("utf-8")) <= bound_bytes: - end = candidate_end - rendered = candidate - low = candidate_end + 1 - else: - high = candidate_end - 1 - if end == start: - return json.dumps({"success": False, "error": "recipe_section_bound_too_small"}) - if chunk_index == part: - return rendered - start = end - if start >= len(content): - return _render(start, start) - raise AssertionError("continuation loop must return") + state = _current_recipe_section_request_state() + return render_recipe_section_failure( + code, + bound_bytes=state.recipe_section_bound_bytes, + context=context, + ) @mcp.tool( @@ -431,8 +455,12 @@ async def load_recipe( @mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True}) -@_cancellation_shield() @track_response_size("get_recipe_section") +@_cancellation_shield( + state_factory=_recipe_section_request_state_factory, + state_context_var=_RECIPE_SECTION_REQUEST_STATE, + response_factory=_recipe_section_cancellation_response, +) async def get_recipe_section( section: str, recipe_name: str, @@ -448,20 +476,25 @@ async def get_recipe_section( ) -> str: """Retrieve a recipe step or section from the persisted recipe artifact. - Returns the body for the named step or section, bounded to the - effective delivery limit. The body is the same YAML that - ``open_kitchen`` / ``load_recipe`` would have returned inline — the - envelope omits ``content`` and the pull tool serves it on demand. - - For sections larger than the bound, use ``part=1``, ``part=2``, - etc. to retrieve continuation chunks. The response includes - ``has_more=True`` and ``next_part=N`` when more chunks remain. + Fixed sections are ``content``, ``ingredients_table``, + ``orchestration_rules``, ``stop_step_semantics``, ``errors``, and + ``warnings``. A validated ``post_prune_step_names`` entry selects raw + named-step YAML. Every page carries ``pagination_version``, + ``section_registry_sha256``, section/plan digests, and immutable payload + and body identities. Consumers must reject unknown versions or formats. + + ``content_format`` selects exactly one reconstruction algorithm: + ``raw-text`` concatenates contiguous UTF-8 byte ranges; + ``json-scalar-page`` JSON-decodes and concatenates string pages; + ``json-array-page`` JSON-decodes and extends complete array pages; and + ``json-element-fragment`` JSON-decodes string fragments, concatenates and + verifies one canonical element, then JSON-decodes that element. Arrays may + interleave complete pages and oversized-element fragments. Args: section: The step or section name to retrieve. Must match a ``post_prune_step_names`` entry from the envelope, or the - special values ``"content"`` (full raw recipe YAML), - ``"ingredients_table"``, or ``"orchestration_rules"``. + fixed section names documented above. part: Continuation index (0-based). Default 0 returns the first chunk; pass the value from the previous response's ``next_part`` to retrieve the next chunk. @@ -476,8 +509,8 @@ async def get_recipe_section( body_size_bytes: Exact recipe body byte size. Returns: - JSON with ``success``, ``section``, ``content``, ``has_more``, - and (when more chunks remain) ``next_part``. + A versioned JSON page. Nonterminal pages include ``next_part``; + terminal pages omit it. This tool requires the kitchen to be open (gated by open_kitchen). @@ -486,21 +519,22 @@ async def get_recipe_section( if (gate := _require_enabled()) is not None: return gate try: + request_state = _current_recipe_section_request_state() with structlog.contextvars.bound_contextvars(tool="get_recipe_section"): tool_ctx = _get_ctx_or_none() if tool_ctx is None or tool_ctx.recipes is None: return json.dumps({"success": False, "error": "kitchen not open"}) if not recipe_name or not producer_tool: - return json.dumps({"success": False, "error": "recipe_artifact_identity_required"}) + return _recipe_section_failure("recipe_artifact_identity_required") requested_recipe_name = recipe_name if producer_tool not in recipe_pull_producers(): - return json.dumps({"success": False, "error": "invalid_recipe_artifact_identity"}) + return _recipe_section_failure("invalid_recipe_artifact_identity") artifact_dir = getattr(tool_ctx, "temp_dir", None) if not isinstance(artifact_dir, Path): - return json.dumps({"success": False, "error": "kitchen temp_dir not available"}) + return _recipe_section_failure("invalid_recipe_artifact_identity") identity = RecipeArtifactGeneration( producer_tool=producer_tool, @@ -514,7 +548,9 @@ async def get_recipe_section( body_size_bytes=body_size_bytes, ) if not identity.has_valid_read_bounds(): - return json.dumps({"success": False, "error": "invalid_recipe_artifact_identity"}) + return _recipe_section_failure("invalid_recipe_artifact_identity") + if part < 0: + return _recipe_section_failure("invalid_recipe_section_part") try: persisted = load_recipe_artifact( @@ -522,9 +558,16 @@ async def get_recipe_section( kitchen_id=tool_ctx.kitchen_id, identity=identity, ) + except RecipeArtifactSchemaError as exc: + logger.warning( + "get_recipe_section_schema_mismatch", + stage="load", + detail=str(exc), + ) + return _recipe_section_failure("recipe_artifact_schema_mismatch") except RecipeArtifactError: if producer_tool not in recipe_recreation_producers(): - return json.dumps({"success": False, "error": "recipe_artifact_unavailable"}) + return _recipe_section_failure("recipe_artifact_unavailable") # Recreation path: re-invoke the same serve pipeline that # built the artifact originally. This handles the case # where the artifact was pruned/garbage-collected between @@ -559,12 +602,9 @@ async def get_recipe_section( ingredients_only=False, ) if not _recreate.get("valid", False): - return json.dumps( - { - "success": False, - "error": "recipe_artifact_unavailable", - "detail": "recreation returned invalid recipe", - } + return _recipe_section_failure( + "recipe_artifact_unavailable", + context={"detail": "recreation returned invalid recipe"}, ) if producer_tool == "open_kitchen": _recreate = build_open_kitchen_recipe_payload( @@ -579,35 +619,31 @@ async def get_recipe_section( recipe_name=requested_recipe_name, payload=_recreate, ) + except RecipeArtifactSchemaError as exc: + logger.warning( + "get_recipe_section_schema_mismatch", + stage="recreate_persist", + detail=str(exc), + ) + return _recipe_section_failure("recipe_artifact_schema_mismatch") except (OSError, RecipeArtifactError): - return json.dumps( - { - "success": False, - "error": "recipe_artifact_unavailable", - "detail": "recreation write failed", - } + return _recipe_section_failure( + "recipe_artifact_unavailable", + context={"detail": "recreation write failed"}, ) if recreated_generation != identity: - return json.dumps( - { - "success": False, - "error": "invalid_recipe_artifact_identity", - } - ) - except Exception as exc: + return _recipe_section_failure("invalid_recipe_artifact_identity") + except Exception: logger.warning( "get_recipe_section_recreate_failed", recipe_name=requested_recipe_name, exc_info=True, ) - _recreate_envelope_err = f"{type(exc).__name__}: {exc}" + _recreate_envelope_err = "recreation failed" if _recreate_envelope_err is not None: - return json.dumps( - { - "success": False, - "error": "recipe_artifact_unavailable", - "detail": _recreate_envelope_err, - } + return _recipe_section_failure( + "recipe_artifact_unavailable", + context={"detail": _recreate_envelope_err}, ) try: @@ -616,111 +652,64 @@ async def get_recipe_section( kitchen_id=tool_ctx.kitchen_id, identity=identity, ) - except RecipeArtifactError as exc: - return json.dumps( - { - "success": False, - "error": "recipe_artifact_unavailable", - "detail": str(exc), - } + except RecipeArtifactSchemaError as exc: + logger.warning( + "get_recipe_section_schema_mismatch", + stage="reload", + detail=str(exc), ) - - content = "" - if section == "content": - content = persisted.get("content", "") or "" - elif section == "ingredients_table": - _it = persisted.get("ingredients_table") - content = json.dumps(_it) if _it is not None else "" - elif section == "orchestration_rules": - content = persisted.get("orchestration_rules", "") or "" - elif section == "stop_step_semantics": - content = persisted.get("stop_step_semantics", "") or "" - elif section == "errors": - content = json.dumps(persisted.get("errors", []) or []) - elif section == "warnings": - content = json.dumps(persisted.get("warnings", []) or []) - else: - if not _is_post_prune_step(persisted, section): - return json.dumps( - { - "success": False, - "error": "section_not_found", - "section": section, - } + return _recipe_section_failure("recipe_artifact_schema_mismatch") + except RecipeArtifactError as exc: + logger.warning( + "get_recipe_section_artifact_unavailable", + stage="reload", + detail=str(exc), + exc_info=True, ) - # Treat as a step name; extract the step definition from - # the post-prune step slice of the YAML content. The - # full recipe YAML uses the step name as a key; we parse - # the YAML and pull out only the requested step's subtree. - try: - content = _extract_step_body_from_persisted(persisted, section) - except _RecipeSectionError as exc: - return json.dumps( - { - "success": False, - "error": exc.code, - "detail": str(exc), - } + return _recipe_section_failure( + "recipe_artifact_unavailable", + context={"detail": "post-recreation reload failed"}, ) - if not content: - return json.dumps( - { - "success": False, - "error": "section_not_found", - "section": section, - } + try: + selected = select_recipe_section( + persisted, + section, + dynamic_content_loader=lambda step_name: _extract_step_body_from_persisted( + persisted, step_name + ), ) - - # Bound the chunk size to the effective delivery limit - # (smallest backend bound by construction) so the response - # itself never re-enters the spill path. ``track_response_size`` - # still enforces the response-side ceiling, but bounding - # here means a single response never accidentally exceeds - # even the Codex 40KB bound. - _backend_caps = ( - tool_ctx.backend.capabilities - if tool_ctx.backend is not None - and isinstance( - getattr(tool_ctx.backend, "capabilities", None), - BackendCapabilities, + except _RecipeSectionError as exc: + return _recipe_section_failure(exc.code) + if not selected.present: + return _recipe_section_failure( + "section_not_found", + context={"section": section}, ) - else None - ) - bound_tokens = ( - resolve_general_output_token_limit(_backend_caps) - if _backend_caps is not None - else 10_000 - ) - bound_bytes = bound_tokens - configured_response_max = getattr( - getattr(tool_ctx.config, "output_budget", None), - "response_max_bytes", - None, - ) - if isinstance(configured_response_max, int) and configured_response_max > 0: - bound_bytes = min(bound_bytes, configured_response_max) - if part < 0: - part = 0 - return _bounded_recipe_section_response( - section, - content, - part=part, - bound_bytes=bound_bytes, - generation=identity, - ) - except Exception as exc: + try: + page_plan = get_or_build_recipe_section_page_plan( + kitchen_id=tool_ctx.kitchen_id, + generation=identity, + selected=selected, + recipe_section_bound_bytes=request_state.recipe_section_bound_bytes, + ) + except RecipeSectionBoundError: + return _recipe_section_failure("recipe_section_bound_too_small") + except RecipeSectionNonConvergenceError: + return _recipe_section_failure("recipe_section_pagination_nonconvergent") + except RecipeSectionPaginationError: + logger.error("get_recipe_section pagination invariant failure", exc_info=True) + return _recipe_section_failure("recipe_section_internal_error") + if part >= page_plan.total_parts: + return _recipe_section_failure( + "invalid_recipe_section_part", + context={"total_parts": page_plan.total_parts}, + ) + return render_recipe_section_page(page_plan, part) + except Exception: logger.error("get_recipe_section unhandled exception", exc_info=True) - return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) - - -def _is_post_prune_step(persisted: dict[str, Any], section: str) -> bool: - """Return whether ``section`` is an executable step in the persisted payload.""" - post_prune_step_names = persisted.get("post_prune_step_names") - if not isinstance(post_prune_step_names, list): - return False - return any(name == section for name in post_prune_step_names if isinstance(name, str)) + return _recipe_section_failure("recipe_section_internal_error") def _extract_step_body_from_persisted(persisted: dict[str, Any], step_name: str) -> str: @@ -759,7 +748,10 @@ def _extract_step_body_from_persisted(persisted: dict[str, Any], step_name: str) if step_obj is None: return "" if not isinstance(step_obj, dict): - return str(step_obj) + raise _RecipeSectionError( + "recipe_section_serialization_failed", + "recipe step is not a mapping", + ) # Render just this step's subtree as compact YAML. try: return fast_dumps({step_name: step_obj}) diff --git a/src/autoskillit/skills/sous-chef/SKILL.md b/src/autoskillit/skills/sous-chef/SKILL.md index 211eecb8e..3bd0e9fe5 100644 --- a/src/autoskillit/skills/sous-chef/SKILL.md +++ b/src/autoskillit/skills/sous-chef/SKILL.md @@ -975,7 +975,14 @@ the backend's delivery bound), the envelope omits full step bodies. Call `get_recipe_section(section=)` to pull the body of a specific step on demand — this is still the authoritative channel (backed by the same persisted artifact `load_recipe` wrote), not a raw-file read. For sections spanning multiple -chunks, follow `has_more`/`next_part` until exhausted. +pages, require one known `pagination_version` and stable `section_registry_sha256`, +`section_sha256`, and `page_plan_sha256`; reject an unknown pagination_version or +unknown content_format and do not guess. Reconstruct `raw-text` by concatenating +contiguous byte ranges. For `json-array-page`, call `json.loads` on every page and +extend in order. For `json-scalar-page`, call `json.loads` and concatenate decoded +strings. For `json-element-fragment`, call `json.loads` on each string fragment, +concatenate and verify the canonical element, then parse it once. Follow +`has_more`/`next_part`; a terminal page must omit `next_part`. Similarly, NEVER read SKILL.md files directly from the filesystem. Use the Skill tool to load skill instructions — it applies runtime transformations (namespace rewriting, diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 4782fe21f..100a10b81 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -236,6 +236,7 @@ class ImportContext(enum.StrEnum): "_type_results_execution": frozenset({"core", "execution", "server", "pipeline"}), "_type_backend": frozenset({"core", "execution", "cli", "recipe", "server", "workspace"}), "_type_recipe_delivery": frozenset({"core", "execution", "server"}), + "_type_recipe_sections": frozenset({"core", "execution", "server"}), "_type_dispatch_identity": frozenset({"core", "fleet", "execution"}), "_type_figure_spec": frozenset({"core", "report"}), "_type_session_env": frozenset({"core", "cli"}), @@ -765,13 +766,14 @@ class ImportContext(enum.StrEnum): "core", "hooks/test_recipe_contract_freshness.py", "migration", - # Server file-level entries (14 of 52 import autoskillit.recipe): + # Server file-level entries importing autoskillit.recipe: "server/test_serve_idempotence.py", "server/test_backend_ingredient_injection.py", "server/test_factory.py", "server/test_tools_dispatch_validation.py", "server/test_tools_kitchen_gate_features.py", "server/test_tools_load_recipe.py", + "server/test_tools_recipe_pull.py", "server/test_server_tool_registration.py", "server/test_mcp_overrides.py", "server/test_smoke_pipeline.py", diff --git a/tests/arch/AGENTS.md b/tests/arch/AGENTS.md index 52d21c2c2..d130d3821 100644 --- a/tests/arch/AGENTS.md +++ b/tests/arch/AGENTS.md @@ -63,6 +63,7 @@ AST enforcement, sub-package layer contracts, and architectural invariant tests. | `test_layer_enforcement.py` | MCP tool registry + import layer contracts + cross-package rules | | `test_pyi_stub_completeness.py` | Stub-symbol completeness: verifies core submodule public symbols appear in __init__.pyi | | `test_pipeline_ordering.py` | Structural enforcement: semantic rules must run after _prune_skipped_steps in load_and_validate | +| `test_recipe_section_registry.py` | Static recipe-section registry, schema, digest, and public-export contracts | | `test_layer_markers.py` | Enforce pytestmark layer markers on all in-scope test files | | `test_conftest_env_coverage.py` | Structural guard: root conftest _clear_private_env fixture must reference AUTOSKILLIT_PRIVATE_ENV_VARS and _HEADLESS_EXCLUSIVE_VARS programmatically | | `test_make_context_env_boundary.py` | AST guard: _factory.py functions must not read AUTOSKILLIT_PRIVATE_ENV_VARS from os.environ | diff --git a/tests/arch/_helpers.py b/tests/arch/_helpers.py index d3f778536..556abf459 100644 --- a/tests/arch/_helpers.py +++ b/tests/arch/_helpers.py @@ -346,16 +346,32 @@ def _is_mcp_tool_decorator(node: ast.expr) -> bool: def _has_cancellation_shield(func_node: ast.AsyncFunctionDef | ast.FunctionDef) -> bool: - """Return True if the function has @_cancellation_shield in its decorator list.""" + """Return whether a function has one well-formed cancellation shield. + + Legacy result-envelope mode accepts the optional ``result_type`` argument. Typed + request-state mode is intentionally all-or-none and cannot be combined with + ``result_type``. Parsing these keywords here prevents a syntactically present but + incomplete shield from satisfying the architecture guard. + """ + typed_keywords = {"state_factory", "state_context_var", "response_factory"} for dec in func_node.decorator_list: - if isinstance(dec, ast.Name) and dec.id == "_cancellation_shield": - return True - if ( + if not ( isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name) and dec.func.id == "_cancellation_shield" ): - return True + continue + if any(keyword.arg is None for keyword in dec.keywords): + return False + keyword_names = {keyword.arg for keyword in dec.keywords} + supplied_typed = keyword_names & typed_keywords + if supplied_typed: + return ( + not dec.args + and supplied_typed == typed_keywords + and keyword_names == typed_keywords + ) + return len(dec.args) <= 1 and keyword_names <= {"result_type"} return False diff --git a/tests/arch/test_never_raises_contracts.py b/tests/arch/test_never_raises_contracts.py index ac1aadaea..0b0b843b6 100644 --- a/tests/arch/test_never_raises_contracts.py +++ b/tests/arch/test_never_raises_contracts.py @@ -118,6 +118,44 @@ def test_all_mcp_tool_handlers_have_cancellation_shield() -> None: ) +def test_get_recipe_section_tracks_typed_cancellation_response_size() -> None: + """The recipe pull backstop must wrap its typed cancellation shield immediately.""" + path = SRC_ROOT / "server" / "tools" / "tools_recipe.py" + tree = ast.parse(path.read_text(), filename=str(path)) + function = next( + node + for node in ast.walk(tree) + if isinstance(node, ast.AsyncFunctionDef) and node.name == "get_recipe_section" + ) + + decorators = function.decorator_list + shield_index = next( + index + for index, decorator in enumerate(decorators) + if isinstance(decorator, ast.Call) + and isinstance(decorator.func, ast.Name) + and decorator.func.id == "_cancellation_shield" + ) + assert shield_index > 0 + + outer = decorators[shield_index - 1] + assert ( + isinstance(outer, ast.Call) + and isinstance(outer.func, ast.Name) + and outer.func.id == "track_response_size" + ), ( + "get_recipe_section must place @track_response_size immediately above " + "@_cancellation_shield so cancellation output reaches the universal backstop" + ) + shield = decorators[shield_index] + assert isinstance(shield, ast.Call) + assert {keyword.arg for keyword in shield.keywords} == { + "state_factory", + "state_context_var", + "response_factory", + } + + def test_never_raises_contracts_are_structurally_enforced() -> None: """All 'Never raises' functions in server/ must have a top-level try/except Exception.""" server_dir = _repo_root() / "src" / "autoskillit" / "server" diff --git a/tests/arch/test_recipe_section_registry.py b/tests/arch/test_recipe_section_registry.py new file mode 100644 index 000000000..1071ca5f8 --- /dev/null +++ b/tests/arch/test_recipe_section_registry.py @@ -0,0 +1,256 @@ +"""Contracts for the schema-driven recipe-section registry.""" + +from __future__ import annotations + +import dataclasses +import operator +import typing +from collections.abc import Mapping + +import pytest + +pytestmark = [pytest.mark.layer("arch"), pytest.mark.small] + +_FIXED_SECTION_NAMES = { + "content", + "ingredients_table", + "orchestration_rules", + "stop_step_semantics", + "errors", + "warnings", +} + + +def test_recipe_section_registry_has_exact_fixed_and_separate_dynamic_definitions() -> None: + from autoskillit.core import ( + DYNAMIC_RECIPE_SECTION_DEF, + RECIPE_SECTION_REGISTRY, + RecipeSectionDef, + ) + + assert isinstance(RECIPE_SECTION_REGISTRY, Mapping) + assert set(RECIPE_SECTION_REGISTRY) == _FIXED_SECTION_NAMES + assert all( + isinstance(definition, RecipeSectionDef) for definition in RECIPE_SECTION_REGISTRY.values() + ) + assert isinstance(DYNAMIC_RECIPE_SECTION_DEF, RecipeSectionDef) + assert DYNAMIC_RECIPE_SECTION_DEF.name not in RECIPE_SECTION_REGISTRY + assert DYNAMIC_RECIPE_SECTION_DEF.section_strategy == "raw" + assert DYNAMIC_RECIPE_SECTION_DEF.ordinary_content_format == "raw-text" + + +def test_recipe_section_definition_and_registry_are_immutable() -> None: + from autoskillit.core import RECIPE_SECTION_REGISTRY, RecipeSectionDef + + definition = RECIPE_SECTION_REGISTRY["content"] + assert dataclasses.is_dataclass(RecipeSectionDef) + assert RecipeSectionDef.__dataclass_params__.frozen is True + assert "__slots__" in RecipeSectionDef.__dict__ + + with pytest.raises(dataclasses.FrozenInstanceError): + definition.name = "changed" # type: ignore[misc] + with pytest.raises(TypeError): + operator.setitem(RECIPE_SECTION_REGISTRY, "changed", definition) + + +@pytest.mark.parametrize( + ("section", "changes"), + [ + ("content", {"name": ""}), + ("content", {"ordinary_content_format": "json-scalar-page"}), + ("content", {"has_default": True, "default_value": ()}), + ("content", {"missing_behavior": "unknown"}), + ("content", {"none_behavior": "unknown"}), + ("errors", {"missing_behavior": "absent"}), + ("errors", {"has_default": False}), + ], +) +def test_recipe_section_definition_rejects_contradictory_construction( + section: str, + changes: dict[str, typing.Any], +) -> None: + from autoskillit.core import RECIPE_SECTION_REGISTRY + + with pytest.raises(ValueError): + dataclasses.replace(RECIPE_SECTION_REGISTRY[section], **changes) + + +@pytest.mark.parametrize( + ( + "section", + "value_kind", + "element_kind", + "strategy", + "range_unit", + "ordinary", + "oversized", + ), + [ + ("content", "string", None, "raw", "utf8-bytes", "raw-text", None), + ( + "ingredients_table", + "string", + None, + "scalar", + "decoded-utf8-bytes", + "json-scalar-page", + None, + ), + ( + "orchestration_rules", + "string", + None, + "raw", + "utf8-bytes", + "raw-text", + None, + ), + ( + "stop_step_semantics", + "string", + None, + "raw", + "utf8-bytes", + "raw-text", + None, + ), + ( + "errors", + "array", + "string", + "array", + "elements", + "json-array-page", + "json-element-fragment", + ), + ( + "warnings", + "array", + "string", + "array", + "elements", + "json-array-page", + "json-element-fragment", + ), + ], +) +def test_recipe_section_strategy_and_format_combinations_are_pinned( + section: str, + value_kind: str, + element_kind: str | None, + strategy: str, + range_unit: str, + ordinary: str, + oversized: str | None, +) -> None: + from autoskillit.core import RECIPE_SECTION_REGISTRY + + definition = RECIPE_SECTION_REGISTRY[section] + assert ( + definition.value_kind, + definition.element_kind, + definition.section_strategy, + definition.range_unit, + definition.ordinary_content_format, + definition.oversized_content_format, + ) == (value_kind, element_kind, strategy, range_unit, ordinary, oversized) + + +def test_recipe_section_presence_and_default_semantics_are_explicit() -> None: + from autoskillit.core import DYNAMIC_RECIPE_SECTION_DEF, RECIPE_SECTION_REGISTRY + + definitions = dict(RECIPE_SECTION_REGISTRY) + definitions["$dynamic"] = DYNAMIC_RECIPE_SECTION_DEF + expected = { + "content": ("invalid", "invalid", False, None), + "ingredients_table": ("absent", "absent", False, None), + "orchestration_rules": ("absent", "invalid", False, None), + "stop_step_semantics": ("absent", "invalid", False, None), + "errors": ("default", "invalid", True, ()), + "warnings": ("default", "invalid", True, ()), + "$dynamic": ("absent", "invalid", False, None), + } + + assert { + name: ( + definition.missing_behavior, + definition.none_behavior, + definition.has_default, + definition.default_value, + ) + for name, definition in definitions.items() + } == expected + + +def test_recipe_section_registry_identity_is_stable_and_qualified() -> None: + from autoskillit.core import ( + RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + RECIPE_SECTION_PAGINATION_VERSION, + RECIPE_SECTION_REGISTRY_DIGEST, + ) + + assert RECIPE_SECTION_PAGINATION_VERSION == 1 + assert ( + RECIPE_SECTION_REGISTRY_DIGEST + == "sha256:d2b8a9f404b264dc3963850712f7d69b0fdfcea5433331b0c4b6ea9400e1e4a1" + ) + assert ( + RECIPE_SECTION_PAGINATION_POLICY_DIGEST + == "sha256:c65ec6cec7cd4af15aa8247d67cf7fe7540bc7ee0df727e3e8a9da46dcd96c92" + ) + for digest in ( + RECIPE_SECTION_REGISTRY_DIGEST, + RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + ): + algorithm, separator, hexadecimal = digest.partition(":") + assert (algorithm, separator, len(hexadecimal)) == ("sha256", ":", 64) + int(hexadecimal, 16) + assert RECIPE_SECTION_REGISTRY_DIGEST != RECIPE_SECTION_PAGINATION_POLICY_DIGEST + + +def test_pullable_sections_match_public_result_schemas() -> None: + from autoskillit.core import RECIPE_SECTION_REGISTRY + from autoskillit.recipe import LoadRecipeResult, OpenKitchenResult + + load_hints = typing.get_type_hints(LoadRecipeResult) + open_hints = typing.get_type_hints(OpenKitchenResult) + + assert _FIXED_SECTION_NAMES <= load_hints.keys() + assert _FIXED_SECTION_NAMES <= open_hints.keys() + for hints in (load_hints, open_hints): + assert hints["content"] is str + assert hints["ingredients_table"] == str | None + assert hints["orchestration_rules"] is str + assert hints["stop_step_semantics"] is str + assert hints["errors"] == list[str] + assert hints["warnings"] == list[str] + assert hints["post_prune_step_names"] == list[str] + + assert set(RECIPE_SECTION_REGISTRY) == _FIXED_SECTION_NAMES + + +def test_recipe_section_contract_is_exported_through_both_core_gateways() -> None: + import autoskillit.core as core + import autoskillit.core.types as core_types + + expected_exports = { + "DYNAMIC_RECIPE_SECTION_DEF", + "RECIPE_SECTION_CONTENT_FORMAT_REGISTRY", + "RECIPE_SECTION_MANDATORY_FAILURE_CODES", + "RECIPE_SECTION_PAGINATION_POLICY_DIGEST", + "RECIPE_SECTION_PAGINATION_VERSION", + "RECIPE_SECTION_REGISTRY", + "RECIPE_SECTION_REGISTRY_DIGEST", + "RECIPE_SECTION_RESPONSE_FLOOR_BYTES", + "RecipeSectionContentFormatDef", + "RecipeSectionDef", + "RecipeSectionValidationFinding", + "canonical_recipe_section_json", + "recipe_section_digest", + "recipe_section_element_digest", + "recipe_section_plan_digest", + "validate_recipe_artifact_sections", + } + + for name in expected_exports: + assert getattr(core_types, name) is getattr(core, name) diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 6f7aeac97..9f6dbd2a8 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -98,6 +98,9 @@ def _get_call_func_name(node: ast.Call) -> str | None: "_codex_config", # Codex output ceiling derived from measured exemptions "_fmt_response_spill", # standalone spill schema and exemption mirror digests "_response_budget", # canonical spill schema digest + "tools_recipe", # request-scoped recipe pagination ContextVar + # Thread-safe callback registry decouples artifact retirement from page-cache lifecycle. + "_lifecycle", # _REMOVE_LABELS = sorted(...) — stable label list derived from LABEL_LIFECYCLE_REGISTRY "_label_cleanup", # fleet/_label_cleanup.py: _REMOVE_LABELS constant (see comment above) "_step_context", # core/_step_context.py: current_step_name, current_order_id ContextVars @@ -884,11 +887,11 @@ def test_no_subpackage_exceeds_10_files() -> None: logic on DispatchRecord.from_dict. Exempt at 15 files. """ EXEMPTIONS: dict[str, int] = { - "server": 15, # +_recipe_delivery unified finalizer and immutable generation store + "server": 16, # +_recipe_section_pagination deterministic bounded planner "recipe": 42, # was 33; +9 from CI/graph/dataflow splits "execution": 18, "core": 25, # +_delivery_bounds (resolve_general_output_token_limit) - "core/types": 33, # +recipe_delivery typed budget/provenance contracts + "core/types": 34, # +_type_recipe_sections schema and digest contracts "cli": 21, "hooks": 18, # +recipe_confirmed_post_hook, +quota_guard_state_post_hook, +_policy_event, +shell_capture_hook (#4286), +_capture_artifacts.py # noqa: E501 "pipeline": 12, diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 33e1e3342..768d5dba2 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -42,6 +42,7 @@ def test_core_types_has_all_type_modules(self): "_type_results", "_type_results_execution", "_type_recipe_delivery", + "_type_recipe_sections", "_type_resume", "_type_session_env", "_type_subprocess", @@ -62,8 +63,8 @@ def test_type_constants_split_completeness(self): assert len(combined) == len(remaining) + len(env) + len(features) + len(registries), ( "Duplicate symbols across split modules" ) - assert len(combined) == 114, ( - f"Expected 114 symbols total, got {len(combined)} " + assert len(combined) == 124, ( + f"Expected 124 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/cli/test_cli_prompts.py b/tests/cli/test_cli_prompts.py index 04cb1b0c6..cb1bd5fe0 100755 --- a/tests/cli/test_cli_prompts.py +++ b/tests/cli/test_cli_prompts.py @@ -62,6 +62,30 @@ def test_build_open_kitchen_prompt_includes_dispatch_routing(): assert "dispatch_food_truck" in prompt +def test_open_kitchen_prompt_fails_closed_on_unknown_recipe_pagination() -> None: + from autoskillit.cli._prompts import _build_open_kitchen_prompt + + prompt = _build_open_kitchen_prompt(DIRECT_PREFIX) + for required in ( + "pagination_version", + "content_format", + "raw-text", + "json-array-page", + "json-scalar-page", + "json-element-fragment", + "section_registry_sha256", + "section_sha256", + "page_plan_sha256", + "has_more", + "next_part", + ): + assert required in prompt + assert "unknown pagination_version" in prompt + assert "unknown content_format" in prompt + assert "do not guess" in prompt.lower() + assert "terminal" in prompt.lower() and "omit" in prompt.lower() + + def test_orchestrator_prompt_documents_confirm_action(): """The orchestrator system prompt must explain how to handle action:confirm steps.""" from autoskillit.cli._prompts import _build_orchestrator_prompt diff --git a/tests/cli/test_sous_chef_content.py b/tests/cli/test_sous_chef_content.py index 321de4540..11bd12663 100644 --- a/tests/cli/test_sous_chef_content.py +++ b/tests/cli/test_sous_chef_content.py @@ -20,3 +20,25 @@ def test_sous_chef_content_no_frontmatter(): assert content, "_read_full_sous_chef returned empty string" assert not content.startswith("---"), "Frontmatter delimiter still present" assert "uses_capabilities:" not in content, "Frontmatter field leaked into content" + + +def test_sous_chef_documents_exhaustive_recipe_section_reconstruction() -> None: + content = _read_full_sous_chef() + + for required in ( + "pagination_version", + "section_registry_sha256", + "section_sha256", + "page_plan_sha256", + "raw-text", + "json-array-page", + "json-scalar-page", + "json-element-fragment", + "json.loads", + "next_part", + ): + assert required in content + assert "unknown pagination_version" in content + assert "unknown content_format" in content + assert "do not guess" in content.lower() + assert "terminal" in content.lower() and "omit" in content.lower() diff --git a/tests/contracts/test_delivery_bound_fitness.py b/tests/contracts/test_delivery_bound_fitness.py index 6efbbff70..4921f708e 100644 --- a/tests/contracts/test_delivery_bound_fitness.py +++ b/tests/contracts/test_delivery_bound_fitness.py @@ -25,6 +25,7 @@ from autoskillit.config import OutputBudgetConfig from autoskillit.core import ( + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, BackendCapabilities, resolve_general_output_token_limit, @@ -64,6 +65,21 @@ def _effective_bound_bytes(bound_tokens: int) -> int: return bound_tokens * 4 +@pytest.mark.parametrize("backend_name", sorted(_backend_capabilities()), ids=lambda n: n) +def test_ordinary_recipe_pull_bound_meets_mandatory_failure_floor( + backend_name: str, +) -> None: + """Every backend must be able to retain the smallest recipe-pull failure.""" + capabilities = _backend_capabilities()[backend_name] + conservative_general_result_limit = resolve_general_output_token_limit(capabilities) + + assert conservative_general_result_limit >= RECIPE_SECTION_RESPONSE_FLOOR_BYTES, ( + f"{backend_name}: ordinary recipe-pull ceiling " + f"{conservative_general_result_limit} bytes is below the mandatory failure " + f"floor {RECIPE_SECTION_RESPONSE_FLOOR_BYTES} bytes" + ) + + def _full_open_kitchen_payload(recipe_name: str) -> dict[str, object]: """Build the production-shape ``open_kitchen`` payload for ``recipe_name``. diff --git a/tests/docs/AGENTS.md b/tests/docs/AGENTS.md index 0e2b7d03b..6f32025d6 100644 --- a/tests/docs/AGENTS.md +++ b/tests/docs/AGENTS.md @@ -23,4 +23,5 @@ Documentation integrity, link validity, and naming convention tests. | `test_agents_md_content.py` | Validate AGENTS.md content completeness and boundary correctness | | `test_guard_fail_mode_docs.py` | Verify guard fail-mode matrix documentation accuracy | | `test_output_budget_protocol_decision.py` | Ratchet ADR-0005 limits, accepted gaps, operational signals, corrections, and forward obligations | +| `test_recipe_redelivery_decision.py` | ADR-0004 recipe pull pagination identity and reconstruction contracts | | `test_check_sub_claude_md_script.py` | Unit and integration tests for the check_sub_claude_md.py pre-commit hook script | diff --git a/tests/docs/test_output_budget_protocol_decision.py b/tests/docs/test_output_budget_protocol_decision.py index e8bc068f9..46b11ebbb 100644 --- a/tests/docs/test_output_budget_protocol_decision.py +++ b/tests/docs/test_output_budget_protocol_decision.py @@ -150,3 +150,30 @@ def test_decision_ratchets_forward_obligations(decision_text: str) -> None: "live large-output probe", ]: assert required in obligations + + +def test_decision_defines_the_recipe_section_byte_budget(decision_text: str) -> None: + for required in ( + "RECIPE_SECTION_RESPONSE_FLOOR_BYTES", + "RECIPE_SECTION_MANDATORY_FAILURE_CODES", + "recipe_section_bound_bytes", + "min(response_max_bytes, conservative_general_result_limit)", + "10,000 bytes", + "request-specific", + "UTF-8", + ): + assert required in decision_text + assert "token×4" in decision_text or "token x 4" in decision_text + + +def test_decision_requires_exact_bounded_recipe_section_rendering(decision_text: str) -> None: + for required in ( + "complete outer response", + "compact", + "json-element-fragment", + "no truncation", + "no dropped", + "terminal", + "next_part", + ): + assert required in decision_text diff --git a/tests/docs/test_recipe_redelivery_decision.py b/tests/docs/test_recipe_redelivery_decision.py new file mode 100644 index 000000000..a1b251e17 --- /dev/null +++ b/tests/docs/test_recipe_redelivery_decision.py @@ -0,0 +1,68 @@ +"""Ratchet the accepted recipe re-delivery decision record.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DECISION = REPO_ROOT / "docs/decisions/0004-recipe-redelivery.md" +DECISION_INDEX = REPO_ROOT / "docs/decisions/README.md" + +pytestmark = [pytest.mark.layer("docs"), pytest.mark.small] + + +@pytest.fixture(scope="module") +def decision_text() -> str: + assert DECISION.exists(), "ADR-0004 must exist" + return DECISION.read_text(encoding="utf-8") + + +def test_recipe_redelivery_decision_is_indexed(decision_text: str) -> None: + assert "**Status:** Accepted" in decision_text + assert "**Date:** 2026-06-27" in decision_text + assert "0004-recipe-redelivery.md" in DECISION_INDEX.read_text(encoding="utf-8") + + +def test_decision_names_every_pullable_recipe_section(decision_text: str) -> None: + for section in ( + "content", + "ingredients_table", + "orchestration_rules", + "stop_step_semantics", + "errors", + "warnings", + ): + assert f"`{section}`" in decision_text + assert "post-prune step" in decision_text + + +def test_decision_defines_all_reconstruction_algorithms(decision_text: str) -> None: + for required in ( + "pagination_version", + "section_registry_sha256", + "section_sha256", + "page_plan_sha256", + "raw-text", + "json-array-page", + "json-scalar-page", + "json-element-fragment", + "json.loads", + "element_sha256", + ): + assert required in decision_text + + +def test_decision_requires_fail_closed_continuation(decision_text: str) -> None: + for required in ( + "unknown pagination version", + "unknown content format", + "gaps", + "overlaps", + "duplicates", + "terminal", + "next_part", + ): + assert required in decision_text.lower() + assert "must not guess" in decision_text.lower() diff --git a/tests/execution/backends/test_codex_recipe_delivery_conformance.py b/tests/execution/backends/test_codex_recipe_delivery_conformance.py index a5302f90c..03f74be64 100644 --- a/tests/execution/backends/test_codex_recipe_delivery_conformance.py +++ b/tests/execution/backends/test_codex_recipe_delivery_conformance.py @@ -20,8 +20,14 @@ HEADLESS_AUTO_GATE_ENV_VAR, HEADLESS_ENV_VAR, MCP_CLIENT_BACKEND_ENV_VAR, + RECIPE_SECTION_PAGINATION_VERSION, + RECIPE_SECTION_REGISTRY_DIGEST, RecipeDeliveryMode, RecipeDeliveryRequest, + canonical_recipe_section_json, + load_yaml, + recipe_section_digest, + recipe_section_element_digest, ) from autoskillit.execution.backends import ( BACKEND_REGISTRY, @@ -48,6 +54,15 @@ reason="Codex authentication and CODEX_SMOKE_TEST=1 are required", ) +_OVERSIZED_BASE_BRANCH = "probe-" + ("structured-warning-" * 1_200) +_PROBE_CALLER_OVERRIDES = { + "adversarial_review_level": "none", + "base_branch": "caller-branch", + "dispatch_id": "caller-dispatch", + "is_fleet_dispatch": "false", + "local_review_rounds": "999", +} + @dataclass(frozen=True, slots=True) class _RecipeProbeObservation: @@ -98,6 +113,25 @@ def _candidate_dicts(transcript: str) -> list[dict[str, object]]: return candidates +def _expected_authority_warnings() -> list[str]: + return [ + "Override for server-authoritative ingredient 'adversarial_review_level' " + "ignored — server value 'full' (from config plan.adversarial_review_level) " + "wins; set the config key and re-call open_kitchen to change it", + "Override for server-authoritative ingredient 'base_branch' ignored — " + f"server value '{_OVERSIZED_BASE_BRANCH}' (from config " + "branching.default_base_branch) wins; set the config key and re-call " + "open_kitchen to change it", + "Override for server-authoritative ingredient 'dispatch_id' ignored — " + "set by the dispatch runtime at session launch, not user-configurable", + "Override for server-authoritative ingredient 'is_fleet_dispatch' ignored — " + "set by the dispatch runtime at session launch, not user-configurable", + "Override for server-authoritative ingredient 'local_review_rounds' ignored — " + "server value '17' (from config review.local_review_rounds) wins; set the " + "config key and re-call open_kitchen to change it", + ] + + def _agent_messages(transcript: str) -> list[str]: messages: list[str] = [] for candidate in _candidate_dicts(transcript): @@ -129,6 +163,7 @@ def _isolated_environment(tmp_path: Path, workspace: Path) -> tuple[dict[str, st HEADLESS_AUTO_GATE_ENV_VAR: "", "AUTOSKILLIT_SESSION_TYPE": "", FOOD_TRUCK_TOOL_TAGS_ENV_VAR: "", + "AUTOSKILLIT_DISPATCH_ID": "probe-dispatch", } ) venv_bin = Path(__file__).resolve().parents[3] / ".venv" / "bin" @@ -136,26 +171,247 @@ def _isolated_environment(tmp_path: Path, workspace: Path) -> tuple[dict[str, st return env, codex_home +def _write_probe_project_config(workspace: Path) -> Path: + config_dir = workspace / ".autoskillit" + config_dir.mkdir() + config_path = config_dir / "config.yaml" + config_path.write_text( + "branching:\n" + f" default_base_branch: {json.dumps(_OVERSIZED_BASE_BRANCH)}\n" + "plan:\n" + " adversarial_review_level: full\n" + "review:\n" + " local_review_rounds: 17\n", + encoding="utf-8", + ) + return config_path + + def _probe_prompt(workspace: Path) -> str: + caller_overrides = json.dumps(_PROBE_CALLER_OVERRIDES, separators=(",", ":")) return ( "This is the dedicated recipe-delivery conformance probe. Use only AutoSkillit " - "MCP tools. First call open_kitchen with name=remediation and " - "ingredients_only=true. Then call load_recipe with name=remediation and omit " - "delivery_request because no protected host values were supplied. The result " - "must be a bounded recipe_pull envelope. Call get_recipe_section with " - "section=content, part=0, and copy every immutable identity field from recipe_pull " - "unchanged. While has_more is true, call it again with next_part and the same " - "identity until every content chunk has been returned. Then respond with exactly " + "MCP tools. Call open_kitchen exactly once with name=remediation, " + f"overrides={caller_overrides}, and omit delivery_request because no protected " + "host values were supplied. The result must be a bounded recipe_pull envelope. " + "Call get_recipe_section with section=content, part=0, and copy every immutable " + "identity field from recipe_pull unchanged. While has_more is true, call it again " + "with next_part and the same identity until every content page has been returned. " + "Then repeat that complete pagination loop with section=warnings, starting at " + "part=0 and following next_part until has_more is false. Then respond with exactly " "one line beginning " "RECIPE-PROBE-COMPLETE and include body_sha256=, " - "has_more=, and protected_host_evidence=unavailable. " + "has_more=, and " + "protected_host_evidence=unavailable. " f"The workspace is {workspace}." ) +_RANGE_FIELDS = frozenset( + { + "byte_start", + "byte_end", + "byte_total", + "element_start", + "element_end", + "element_total", + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + } +) +_FORMAT_RANGE_FIELDS = { + "raw-text": frozenset({"byte_start", "byte_end", "byte_total"}), + "json-array-page": frozenset({"element_start", "element_end", "element_total"}), + "json-scalar-page": frozenset({"scalar_byte_start", "scalar_byte_end", "scalar_byte_total"}), + "json-element-fragment": frozenset( + { + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + } + ), +} + + +def _is_sha256(value: object) -> bool: + if not isinstance(value, str) or not value.startswith("sha256:"): + return False + digest = value.removeprefix("sha256:") + return len(digest) == 64 and all(character in "0123456789abcdef" for character in digest) + + +def _validated_section_pages( + candidates: list[dict[str, object]], + recipe_pull: dict[str, object], + *, + section: str, +) -> list[dict[str, object]]: + matching = [ + candidate + for candidate in candidates + if candidate.get("success") is True + and candidate.get("section") == section + and candidate.get("body_sha256") == recipe_pull.get("body_sha256") + and candidate.get("payload_sha256") == recipe_pull.get("payload_sha256") + ] + by_part: dict[int, dict[str, object]] = {} + for candidate in matching: + part = candidate.get("part") + if not isinstance(part, int): + continue + previous = by_part.get(part) + assert previous is None, f"duplicate {section} part returned" + by_part[part] = candidate + assert by_part, f"live Codex probe did not retain {section} pages" + + pages = [by_part[part] for part in sorted(by_part)] + assert sorted(by_part) == list(range(len(pages))) + identity_keys = ( + "pagination_version", + "section_registry_sha256", + "section_sha256", + "page_plan_sha256", + "payload_sha256", + "body_sha256", + ) + expected_identity = {key: pages[0].get(key) for key in identity_keys} + assert expected_identity["pagination_version"] == RECIPE_SECTION_PAGINATION_VERSION + assert expected_identity["section_registry_sha256"] == RECIPE_SECTION_REGISTRY_DIGEST + assert _is_sha256(expected_identity["section_sha256"]) + assert _is_sha256(expected_identity["page_plan_sha256"]) + + for part, page in enumerate(pages): + assert page.get("part") == part + assert page.get("total_parts") == len(pages) + assert {key: page.get(key) for key in identity_keys} == expected_identity + content_format = page.get("content_format") + assert isinstance(content_format, str) + assert content_format in _FORMAT_RANGE_FIELDS + assert _RANGE_FIELDS & page.keys() == _FORMAT_RANGE_FIELDS[content_format] + if part < len(pages) - 1: + assert page.get("has_more") is True + assert page.get("next_part") == part + 1 + else: + assert page.get("has_more") is False + assert "next_part" not in page + return pages + + +def _reconstruct_raw_section(pages: list[dict[str, object]]) -> str: + chunks: list[str] = [] + byte_start = 0 + byte_total: int | None = None + for page in pages: + assert page["content_format"] == "raw-text" + content = page["content"] + assert isinstance(content, str) + assert page["byte_start"] == byte_start + byte_start += len(content.encode("utf-8")) + assert page["byte_end"] == byte_start + if byte_total is None: + assert isinstance(page["byte_total"], int) + byte_total = page["byte_total"] + assert page["byte_total"] == byte_total + chunks.append(content) + assert byte_start == byte_total + reconstructed = "".join(chunks) + assert recipe_section_digest(reconstructed, raw=True) == pages[0]["section_sha256"] + return reconstructed + + +def _reconstruct_array_section( + pages: list[dict[str, object]], +) -> tuple[list[object], set[str], set[int]]: + values: list[object] = [] + formats: set[str] = set() + fragmented_element_indices: set[int] = set() + element_total: int | None = None + fragment_chunks: list[str] = [] + fragment_count = 0 + fragment_byte_end = 0 + fragment_byte_total = 0 + fragment_element_sha256 = "" + + for page in pages: + content = page["content"] + content_format = page["content_format"] + assert isinstance(content, str) + assert isinstance(content_format, str) + formats.add(content_format) + decoded = json.loads(content) + + if content_format == "json-array-page": + assert not fragment_chunks + assert isinstance(decoded, list) + assert page["element_start"] == len(values) + values.extend(decoded) + assert page["element_end"] == len(values) + if element_total is None: + assert isinstance(page["element_total"], int) + element_total = page["element_total"] + assert page["element_total"] == element_total + continue + + assert content_format == "json-element-fragment" + assert isinstance(decoded, str) + element_index = page["element_index"] + assert element_index == len(values) + assert isinstance(element_index, int) + fragmented_element_indices.add(element_index) + if not fragment_chunks: + assert page["fragment_index"] == 0 + assert page["fragment_byte_start"] == 0 + assert isinstance(page["fragment_count"], int) + assert isinstance(page["fragment_byte_total"], int) + assert isinstance(page["element_sha256"], str) + fragment_count = page["fragment_count"] + fragment_byte_total = page["fragment_byte_total"] + fragment_element_sha256 = page["element_sha256"] + assert page["fragment_index"] == len(fragment_chunks) + assert page["fragment_count"] == fragment_count + assert page["fragment_byte_start"] == fragment_byte_end + fragment_byte_end += len(decoded.encode("utf-8")) + assert page["fragment_byte_end"] == fragment_byte_end + assert page["fragment_byte_total"] == fragment_byte_total + assert page["element_sha256"] == fragment_element_sha256 + fragment_chunks.append(decoded) + + if len(fragment_chunks) == fragment_count: + assert fragment_byte_end == fragment_byte_total + canonical_element = "".join(fragment_chunks) + element = json.loads(canonical_element) + assert canonical_recipe_section_json(element) == canonical_element + assert recipe_section_element_digest(element) == fragment_element_sha256 + values.append(element) + fragment_chunks = [] + fragment_count = 0 + fragment_byte_end = 0 + fragment_byte_total = 0 + fragment_element_sha256 = "" + + assert not fragment_chunks + if element_total is not None: + assert len(values) == element_total + assert recipe_section_digest(values, raw=False) == pages[0]["section_sha256"] + return values, formats, fragmented_element_indices + + def _run_live_probe(tmp_path: Path) -> tuple[_RecipeProbeObservation, str]: workspace = tmp_path / "workspace" workspace.mkdir() + _write_probe_project_config(workspace) env, codex_home = _isolated_environment(tmp_path / "isolated", workspace) config_path = codex_home / "config.toml" ensure_codex_mcp_registered(config_path=config_path, headless_auto_gate=False) @@ -193,58 +449,32 @@ def _run_live_probe(tmp_path: Path) -> tuple[_RecipeProbeObservation, str]: raise OSError(f"Codex recipe-delivery probe rc={result.returncode}: {transcript}") candidates = _candidate_dicts(transcript) - envelopes = [ - candidate for candidate in candidates if isinstance(candidate.get("recipe_pull"), dict) - ] - pulls = [ - candidate - for candidate in candidates - if candidate.get("success") is True - and candidate.get("section") == "content" - and isinstance(candidate.get("body_sha256"), str) - ] - assert envelopes, "live Codex probe did not retain a recipe_pull envelope" - assert pulls, "live Codex probe did not retain a get_recipe_section result" - recipe_pull = envelopes[-1]["recipe_pull"] - assert isinstance(recipe_pull, dict) - body_sha256 = recipe_pull.get("body_sha256") - payload_sha256 = recipe_pull.get("payload_sha256") - pull_parts: dict[int, dict[str, object]] = {} - for candidate in pulls: + envelopes: list[dict[str, object]] = [] + for candidate in candidates: + candidate_pull = candidate.get("recipe_pull") if ( - candidate.get("body_sha256") != body_sha256 - or candidate.get("payload_sha256") != payload_sha256 + isinstance(candidate_pull, dict) + and candidate_pull.get("producer_tool") == "open_kitchen" ): - continue - byte_start = candidate.get("byte_start") - if not isinstance(byte_start, int): - continue - existing = pull_parts.get(byte_start) - if existing is not None: - assert existing == candidate, "duplicate pull offset returned conflicting content" - pull_parts[byte_start] = candidate - assert pull_parts, "live Codex probe did not retain pull chunks for the envelope identity" - - reconstructed_parts: list[str] = [] - expected_byte_start = 0 - terminal_pull: dict[str, object] | None = None - for byte_start, pull_part in sorted(pull_parts.items()): - content = pull_part.get("content") - byte_end = pull_part.get("byte_end") - assert isinstance(content, str) - assert byte_start == expected_byte_start - assert isinstance(byte_end, int) - assert byte_end == byte_start + len(content.encode("utf-8")) - reconstructed_parts.append(content) - expected_byte_start = byte_end - if pull_part.get("has_more") is False: - terminal_pull = pull_part - assert terminal_pull is not None, "live Codex probe did not retain the terminal pull chunk" - assert terminal_pull.get("byte_total") == expected_byte_start - reconstructed_content = "".join(reconstructed_parts) - reconstructed_body_sha256 = ( - "sha256:" + hashlib.sha256(reconstructed_content.encode("utf-8")).hexdigest() + envelopes.append(candidate) + assert len(envelopes) == 1, ( + f"live Codex probe retained {len(envelopes)} open_kitchen pull envelopes" ) + recipe_pull = envelopes[0]["recipe_pull"] + assert isinstance(recipe_pull, dict) + body_sha256 = recipe_pull.get("body_sha256") + content_pages = _validated_section_pages(candidates, recipe_pull, section="content") + reconstructed_content = _reconstruct_raw_section(content_pages) + warning_pages = _validated_section_pages(candidates, recipe_pull, section="warnings") + reconstructed_warnings, warning_formats, fragmented_element_indices = ( + _reconstruct_array_section(warning_pages) + ) + assert reconstructed_warnings == _expected_authority_warnings() + assert warning_formats == {"json-array-page", "json-element-fragment"} + assert fragmented_element_indices == {1} + + terminal_content_page = content_pages[-1] + terminal_warning_page = warning_pages[-1] messages = _agent_messages(transcript) final_message = messages[-1] if messages else "" events = _transcript_events(transcript) @@ -288,10 +518,25 @@ def _run_live_probe(tmp_path: Path) -> tuple[_RecipeProbeObservation, str]: }, outer={"raw_pre_truncation_bytes": None}, retained={ - "body_sha256": terminal_pull.get("body_sha256"), - "reconstructed_body_sha256": reconstructed_body_sha256, - "content_bytes": expected_byte_start, - "has_more": terminal_pull.get("has_more"), + "body_sha256": terminal_content_page.get("body_sha256"), + "reconstructed_body_sha256": "sha256:" + + hashlib.sha256(reconstructed_content.encode("utf-8")).hexdigest(), + "content_section_sha256": terminal_content_page.get("section_sha256"), + "reconstructed_content_section_sha256": recipe_section_digest( + reconstructed_content, + raw=True, + ), + "content_bytes": len(reconstructed_content.encode("utf-8")), + "has_more": terminal_warning_page.get("has_more"), + "warnings": reconstructed_warnings, + "warning_formats": sorted(warning_formats), + "warning_fragmented_element_indices": sorted(fragmented_element_indices), + "warning_section_sha256": terminal_warning_page.get("section_sha256"), + "warning_page_plan_sha256": terminal_warning_page.get("page_plan_sha256"), + "warning_section_registry_sha256": terminal_warning_page.get( + "section_registry_sha256" + ), + "warning_terminal_has_next_part": "next_part" in terminal_warning_page, "truncation_markers": [ marker for marker in ("[tool output truncated]", "[output truncated by transport]") @@ -366,6 +611,27 @@ def test_isolated_probe_environment_identifies_codex_backend(tmp_path: Path) -> assert env[FOOD_TRUCK_TOOL_TAGS_ENV_VAR] == "" +def test_probe_project_config_pins_server_authority_values(tmp_path: Path) -> None: + workspace = tmp_path / "workspace" + workspace.mkdir() + + config = load_yaml(_write_probe_project_config(workspace)) + + assert config == { + "branching": {"default_base_branch": _OVERSIZED_BASE_BRANCH}, + "plan": {"adversarial_review_level": "full"}, + "review": {"local_review_rounds": 17}, + } + + +def test_probe_prompt_pins_one_envelope_producer_call(tmp_path: Path) -> None: + prompt = _probe_prompt(tmp_path) + + assert "Call open_kitchen exactly once with name=remediation" in prompt + assert "ingredients_only=true" not in prompt + assert "load_recipe" not in prompt + + def test_tracked_report_records_the_unsupported_host_dependency() -> None: report = ( Path(__file__).resolve().parents[3] / "docs" / "research" / "codex-delivery-conformance.md" @@ -375,6 +641,18 @@ def test_tracked_report_records_the_unsupported_host_dependency() -> None: assert "SUPPORTED_CODEX_RECIPE_EVIDENCE_REGISTRY` remains empty" in report assert "raw outer pre-truncation bytes" in report assert "protected pre-call host channel" in report + for required in ( + "codex-recipe-delivery-v2", + "server-authoritative", + "`warnings`", + "`json-array-page`", + "`json-element-fragment`", + "`section_sha256`", + "`element_sha256`", + "`page_plan_sha256`", + "terminal omission", + ): + assert required in report @pytest.mark.smoke @@ -386,8 +664,25 @@ def test_live_codex_envelope_pull_and_next_request_retention(tmp_path: Path) -> assert isinstance(body_sha256, str) and body_sha256.startswith("sha256:") assert observation.retained["body_sha256"] == body_sha256 assert observation.retained["reconstructed_body_sha256"] == body_sha256 + assert ( + observation.retained["reconstructed_content_section_sha256"] + == observation.retained["content_section_sha256"] + ) + assert observation.retained["content_section_sha256"] != body_sha256 assert int(observation.retained["content_bytes"]) > 0 assert observation.retained["has_more"] is False + assert observation.retained["warnings"] == _expected_authority_warnings() + assert observation.retained["warning_formats"] == [ + "json-array-page", + "json-element-fragment", + ] + assert observation.retained["warning_fragmented_element_indices"] == [1] + assert _is_sha256(observation.retained["warning_section_sha256"]) + assert _is_sha256(observation.retained["warning_page_plan_sha256"]) + assert ( + observation.retained["warning_section_registry_sha256"] == RECIPE_SECTION_REGISTRY_DIGEST + ) + assert observation.retained["warning_terminal_has_next_part"] is False assert observation.retained["truncation_markers"] == [] final_message = str(observation.next_request["final_message"]) assert "RECIPE-PROBE-COMPLETE" in final_message diff --git a/tests/execution/backends/test_probe_cache.py b/tests/execution/backends/test_probe_cache.py index 3861007c5..4b6d88155 100644 --- a/tests/execution/backends/test_probe_cache.py +++ b/tests/execution/backends/test_probe_cache.py @@ -9,6 +9,8 @@ from autoskillit.core import ( OUTPUT_DISCIPLINE_COMBINED_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, + RECIPE_SECTION_PAGINATION_POLICY_DIGEST, + RECIPE_SECTION_REGISTRY_DIGEST, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, ) from autoskillit.execution.backends._probe_cache import ( @@ -182,11 +184,19 @@ def test_probe_policy_identity_uses_output_discipline_authorities() -> None: "generated-codex-child-v1", "deep-investigate-codex-v2", "deep-investigate-claude-200k-v2", - "codex-recipe-delivery-v1", + "codex-recipe-delivery-v2", ) def test_recipe_probe_policy_identity_covers_every_invalidation_domain() -> None: + assert ( + f"section-registry:{RECIPE_SECTION_REGISTRY_DIGEST}" + in CODEX_RECIPE_PROBE_POLICY_COMPONENTS + ) + assert ( + f"pagination-policy:{RECIPE_SECTION_PAGINATION_POLICY_DIGEST}" + in CODEX_RECIPE_PROBE_POLICY_COMPONENTS + ) prefixes = {component.partition(":")[0] for component in CODEX_RECIPE_PROBE_POLICY_COMPONENTS} assert prefixes == { "budget", @@ -200,6 +210,8 @@ def test_recipe_probe_policy_identity_covers_every_invalidation_domain() -> None "model", "fixtures", "attestation-provider", + "section-registry", + "pagination-policy", } diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 03d9903bf..742bdfe40 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -458,6 +458,12 @@ def test_attested_recipe_delivery_region_is_preserved_byte_for_byte( payload = { "success": True, "content": "steps:\n impl:\n action: stop\n", + "post_prune_step_names": ["impl"], + "ingredients_table": None, + "orchestration_rules": "follow the graph", + "stop_step_semantics": "stop means stop", + "errors": [], + "warnings": [], } generation = persist_recipe_artifact( tmp_path, diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 39decb1e8..d1f01532c 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -36,6 +36,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_no_raw_signal_handler.py` | AST guard: no raw signal.signal(SIGTERM, ...) in cli/app.py | | `test_notify_module.py` | Contract tests: server._notify module | | `test_response_backstop.py` | Universal response-budget spill, exact projection, exemption, fail-closed, and telemetry contracts | +| `test_recipe_section_pagination.py` | Pure schema-driven recipe-section pagination, rendering, digest, and cache contracts | | `test_perform_merge_editable_guard.py` | Integration tests verifying perform_merge() aborts before cleanup on poisoned installs | | `test_profile_to_env.py` | Tests for _profile_to_env — ProviderProfileDef to env dict conversion in _guards.py | | `test_quota_refresh_loop.py` | Tests for _quota_refresh_loop in server/_misc.py | @@ -128,6 +129,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_open_kitchen_staleness.py` | Tests for ProcessStaleError propagation through open_kitchen — failure envelope with staleness context | | `test_open_kitchen_deferred_recall.py` | Tests for the _is_deferred_recall=True path: active_recipe_steps and fail-closed guard | | `test_output_budget_e2e.py` | Env-gated real kitchen/run_skill deep-investigate probes proving backend/model selection, completed agent waves, bounded large-fixture evidence, and post-report validation for Codex and Claude Code 200K | +| `test_cancellation_shield.py` | Legacy and typed-state cancellation shield behavior, isolation, and restoration contracts | | `test_tools_label_validation.py` | Tests for label whitelist validation in server tool handlers | | `test_tools_list_recipes.py` | Tests for autoskillit server list_recipes tool | | `test_tools_load_recipe.py` | Tests for autoskillit server load_recipe tool | diff --git a/tests/server/_helpers.py b/tests/server/_helpers.py index 2d8d9dba2..67df410df 100644 --- a/tests/server/_helpers.py +++ b/tests/server/_helpers.py @@ -5,44 +5,274 @@ import json from typing import Any -from autoskillit.core import SkillResult +from autoskillit.core import ( + RECIPE_SECTION_PAGINATION_VERSION, + RECIPE_SECTION_REGISTRY_DIGEST, + SkillResult, + recipe_section_digest, + recipe_section_element_digest, +) from autoskillit.core.types import RetryReason from tests.fleet._helpers import _make_recipe_info as _fleet_make_recipe_info _HOOK_CONFIG_OVERLAY_RELPATH = (".autoskillit", "temp", ".hook_config_overlay.json") -async def _resolve_recipe_content(result: dict[str, Any]) -> str: - """Return exact recipe content from either inline or pull delivery.""" +async def _resolve_recipe_section(result: dict[str, Any], *, section: str = "content") -> Any: + """Reconstruct one typed recipe section from inline or paginated delivery.""" assert result.get("success") is True, f"recipe response was not successful: {result}" - inline_content = result.get("content") - if isinstance(inline_content, str): - return inline_content - pull = result.get("recipe_pull") + if pull is None and section in result: + return result[section] assert isinstance(pull, dict), f"recipe response has neither content nor pull: {result}" assert pull.get("pull_tool") == "get_recipe_section" from autoskillit.server.tools.tools_recipe import get_recipe_section identity = {key: value for key, value in pull.items() if key != "pull_tool"} - chunks: list[str] = [] + raw_chunks: list[str] = [] + scalar_chunks: list[str] = [] + elements: list[object] = [] + fragment_chunks: list[str] = [] + fragment_count: int | None = None + fragment_byte_total: int | None = None + fragment_element_sha256: str | None = None + expected_range_start = 0 + expected_fragment_index = 0 + range_identities: set[tuple[object, ...]] = set() + shared_identity: tuple[object, ...] | None = None + expected_format_family: str | None = None + expected_total_parts: int | None = None + expected_section_total: int | None = None part = 0 - expected_byte_start = 0 while True: - response = json.loads(await get_recipe_section(section="content", part=part, **identity)) - assert response.get("success") is not False, ( - f"get_recipe_section returned error: {response}" + response = json.loads(await get_recipe_section(section=section, part=part, **identity)) + assert response.get("success") is True, f"get_recipe_section returned error: {response}" + assert response["pagination_version"] == RECIPE_SECTION_PAGINATION_VERSION + assert response["section_registry_sha256"] == RECIPE_SECTION_REGISTRY_DIGEST + assert response["section"] == section + assert response["part"] == part + assert type(response["total_parts"]) is int and response["total_parts"] > 0 + assert type(response["has_more"]) is bool + assert isinstance(response["section_sha256"], str) + assert isinstance(response["page_plan_sha256"], str) + assert isinstance(response["payload_sha256"], str) + assert isinstance(response["body_sha256"], str) + assert response["payload_sha256"] == identity["payload_sha256"] + assert response["body_sha256"] == identity["body_sha256"] + + page_identity = ( + response["pagination_version"], + response["section_registry_sha256"], + response["section_sha256"], + response["page_plan_sha256"], + response["payload_sha256"], + response["body_sha256"], ) + if shared_identity is None: + shared_identity = page_identity + expected_total_parts = response["total_parts"] + else: + assert page_identity == shared_identity + assert response["total_parts"] == expected_total_parts + chunk = response.get("content") assert isinstance(chunk, str) - assert response["byte_start"] == expected_byte_start - expected_byte_start = response["byte_end"] - chunks.append(chunk) - if not response.get("has_more", False): - assert expected_byte_start == response["byte_total"] - return "".join(chunks) - part = response["next_part"] + content_format = response.get("content_format") + assert content_format in { + "raw-text", + "json-array-page", + "json-scalar-page", + "json-element-fragment", + }, f"unknown recipe section format: {content_format!r}" + + if content_format == "raw-text": + assert expected_format_family in (None, "raw") + expected_format_family = "raw" + assert response["byte_start"] == expected_range_start + assert response["byte_end"] == response["byte_start"] + len(chunk.encode("utf-8")) + expected_range_start = response["byte_end"] + if expected_section_total is None: + expected_section_total = response["byte_total"] + else: + assert response["byte_total"] == expected_section_total + range_identity = ( + content_format, + response["byte_start"], + response["byte_end"], + ) + raw_chunks.append(chunk) + forbidden = { + "element_start", + "element_end", + "element_total", + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + } + elif content_format == "json-scalar-page": + assert expected_format_family in (None, "scalar") + expected_format_family = "scalar" + decoded = json.loads(chunk) + assert isinstance(decoded, str) + assert response["scalar_byte_start"] == expected_range_start + assert response["scalar_byte_end"] == response["scalar_byte_start"] + len( + decoded.encode("utf-8") + ) + expected_range_start = response["scalar_byte_end"] + if expected_section_total is None: + expected_section_total = response["scalar_byte_total"] + else: + assert response["scalar_byte_total"] == expected_section_total + range_identity = ( + content_format, + response["scalar_byte_start"], + response["scalar_byte_end"], + ) + scalar_chunks.append(decoded) + forbidden = { + "byte_start", + "byte_end", + "byte_total", + "element_start", + "element_end", + "element_total", + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + } + elif content_format == "json-array-page": + assert expected_format_family in (None, "array") + expected_format_family = "array" + decoded = json.loads(chunk) + assert isinstance(decoded, list) + assert response["element_start"] == len(elements) + assert response["element_end"] == response["element_start"] + len(decoded) + assert response["element_end"] > response["element_start"] or ( + response["element_total"] == 0 + and response["total_parts"] == 1 + and response["has_more"] is False + ) + expected_range_start = response["element_end"] + if expected_section_total is None: + expected_section_total = response["element_total"] + else: + assert response["element_total"] == expected_section_total + range_identity = ( + content_format, + response["element_start"], + response["element_end"], + ) + elements.extend(decoded) + forbidden = { + "byte_start", + "byte_end", + "byte_total", + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + } + else: + assert expected_format_family in (None, "array") + expected_format_family = "array" + decoded = json.loads(chunk) + assert isinstance(decoded, str) + assert response["element_index"] == len(elements) + assert response["fragment_index"] == expected_fragment_index + assert response["fragment_byte_start"] == sum( + len(value.encode("utf-8")) for value in fragment_chunks + ) + assert response["fragment_byte_end"] == response["fragment_byte_start"] + len( + decoded.encode("utf-8") + ) + if fragment_count is None: + fragment_count = response["fragment_count"] + fragment_byte_total = response["fragment_byte_total"] + fragment_element_sha256 = response["element_sha256"] + else: + assert response["fragment_count"] == fragment_count + assert response["fragment_byte_total"] == fragment_byte_total + assert response["element_sha256"] == fragment_element_sha256 + range_identity = ( + content_format, + response["element_index"], + response["fragment_index"], + response["fragment_byte_start"], + response["fragment_byte_end"], + ) + fragment_chunks.append(decoded) + expected_fragment_index += 1 + assert fragment_count is not None + if expected_fragment_index == fragment_count: + canonical_element = "".join(fragment_chunks) + assert len(canonical_element.encode("utf-8")) == fragment_byte_total + element = json.loads(canonical_element) + assert recipe_section_element_digest(element) == fragment_element_sha256 + elements.append(element) + expected_range_start = len(elements) + fragment_chunks = [] + fragment_count = None + fragment_byte_total = None + fragment_element_sha256 = None + expected_fragment_index = 0 + forbidden = { + "byte_start", + "byte_end", + "byte_total", + "element_start", + "element_end", + "element_total", + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + } + + assert not (forbidden & response.keys()) + assert range_identity not in range_identities + range_identities.add(range_identity) + if response["has_more"]: + assert response["next_part"] == part + 1 + assert part + 1 < response["total_parts"] + part = response["next_part"] + continue + + assert "next_part" not in response + assert part + 1 == response["total_parts"] + assert not fragment_chunks + if expected_section_total is not None: + assert expected_range_start == expected_section_total + else: + assert expected_format_family == "array" + assert expected_range_start == len(elements) + if expected_format_family == "raw": + value: object = "".join(raw_chunks) + raw_digest = True + elif expected_format_family == "scalar": + value = "".join(scalar_chunks) + raw_digest = False + else: + value = elements + raw_digest = False + assert recipe_section_digest(value, raw=raw_digest) == response["section_sha256"] + return value def _write_registry(monkeypatch: Any, tmp_path: Any, entries: list[dict[str, Any]]) -> Any: diff --git a/tests/server/test_cancellation_shield.py b/tests/server/test_cancellation_shield.py new file mode 100644 index 000000000..63db2c0a2 --- /dev/null +++ b/tests/server/test_cancellation_shield.py @@ -0,0 +1,342 @@ +"""Behavioral contracts for generic and typed MCP cancellation shielding.""" + +from __future__ import annotations + +import asyncio +import json +from contextvars import ContextVar +from dataclasses import dataclass +from typing import Any + +import pytest + +from autoskillit.server.tools._cancellation_shield import _cancellation_shield + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("decorator", "expected"), + [ + ( + _cancellation_shield(), + {"success": False, "error": "cancelled", "subtype": "cancelled"}, + ), + ( + _cancellation_shield(result_type="generic"), + {"success": False, "error": "cancelled", "subtype": "cancelled"}, + ), + ( + _cancellation_shield(result_type="run_cmd"), + { + "success": False, + "exit_code": -1, + "stdout": "", + "stderr": "CancelledError: transport teardown", + }, + ), + ( + _cancellation_shield(result_type="run_python"), + { + "success": False, + "exit_code": -1, + "stdout": "", + "stderr": "CancelledError: transport teardown", + }, + ), + ], +) +async def test_legacy_modes_preserve_existing_result_schemas( + decorator: Any, expected: dict[str, object] +) -> None: + @decorator + async def cancelled() -> str: + raise asyncio.CancelledError + + assert json.loads(await cancelled()) == expected + + +@pytest.mark.anyio +async def test_explicit_fleet_result_mode_is_preserved() -> None: + @_cancellation_shield(result_type="fleet_error") + async def cancelled() -> str: + raise asyncio.CancelledError + + result = json.loads(await cancelled()) + assert result["success"] is False + assert result["error"] == "fleet_l3_startup_or_crash" + assert result["user_visible_message"] == "CancelledError: transport teardown" + + +@dataclass(frozen=True, slots=True) +class _State: + request_id: str + admitted: bool + bound_bytes: int + + +def _typed_decorator( + state_factory: Any, + state_context_var: ContextVar[_State], + response_factory: Any, +) -> Any: + return _cancellation_shield( + state_factory=state_factory, + state_context_var=state_context_var, + response_factory=response_factory, + ) + + +@pytest.mark.parametrize( + "typed_kwargs", + [ + {"state_factory": lambda: None}, + {"state_context_var": ContextVar("partial-state")}, + {"response_factory": lambda state, exc: ""}, + { + "state_factory": lambda: None, + "state_context_var": ContextVar("partial-state"), + }, + { + "state_factory": lambda: None, + "response_factory": lambda state, exc: "", + }, + { + "state_context_var": ContextVar("partial-state"), + "response_factory": lambda state, exc: "", + }, + ], +) +def test_typed_mode_arguments_are_all_or_none(typed_kwargs: dict[str, object]) -> None: + with pytest.raises(TypeError, match="state_factory.*state_context_var.*response_factory"): + _cancellation_shield(**typed_kwargs) # type: ignore[arg-type] + + +def test_typed_mode_rejects_explicit_result_type() -> None: + state_var: ContextVar[_State] = ContextVar("state") + + with pytest.raises(TypeError, match="result_type"): + _cancellation_shield( + result_type="generic", + state_factory=lambda: _State("request", True, 512), + state_context_var=state_var, + response_factory=lambda state, exc: "{}", + ) + + +@pytest.mark.parametrize( + ("typed_kwargs", "message"), + [ + ( + { + "state_factory": object(), + "state_context_var": ContextVar("state"), + "response_factory": lambda state, exc: "{}", + }, + "state_factory must be callable", + ), + ( + { + "state_factory": lambda: None, + "state_context_var": object(), + "response_factory": lambda state, exc: "{}", + }, + "state_context_var must be a ContextVar", + ), + ( + { + "state_factory": lambda: None, + "state_context_var": ContextVar("state"), + "response_factory": object(), + }, + "response_factory must be callable", + ), + ], +) +def test_typed_mode_rejects_invalid_argument_types_at_construction( + typed_kwargs: dict[str, object], + message: str, +) -> None: + with pytest.raises(TypeError, match=message): + _cancellation_shield(**typed_kwargs) # type: ignore[arg-type] + + +@pytest.mark.anyio +async def test_typed_factories_use_exact_signatures_and_same_state_object() -> None: + state_var: ContextVar[_State] = ContextVar("state") + observed: list[tuple[str, object]] = [] + + def state_factory() -> _State: + state = _State("request", True, 512) + observed.append(("factory", state)) + return state + + def response_factory(state: _State, exc: asyncio.CancelledError) -> str: + observed.append(("response", state)) + observed.append(("exception", exc)) + return json.dumps({"request_id": state.request_id, "cancelled": True}) + + @_typed_decorator(state_factory, state_var, response_factory) + async def cancelled(positional: str, *, keyword: str) -> str: + assert (positional, keyword) == ("positional", "keyword") + observed.append(("handler", state_var.get())) + raise asyncio.CancelledError + + result = json.loads(await cancelled("positional", keyword="keyword")) + + assert result == {"request_id": "request", "cancelled": True} + states = [value for label, value in observed if label != "exception"] + assert states[0] is states[1] is states[2] + assert isinstance(observed[-1][1], asyncio.CancelledError) + with pytest.raises(LookupError): + state_var.get() + + +@pytest.mark.anyio +async def test_typed_context_is_restored_after_success_and_failure() -> None: + state_var: ContextVar[_State] = ContextVar("state") + prior = _State("prior", False, 256) + token = state_var.set(prior) + created: list[_State] = [] + + def state_factory() -> _State: + state = _State(f"request-{len(created)}", True, 512) + created.append(state) + return state + + def response_factory(state: _State, exc: asyncio.CancelledError) -> str: + return state.request_id + + @_typed_decorator(state_factory, state_var, response_factory) + async def succeeds() -> str: + assert state_var.get() is created[-1] + return "ok" + + @_typed_decorator(state_factory, state_var, response_factory) + async def fails() -> str: + assert state_var.get() is created[-1] + raise RuntimeError("not transport cancellation") + + try: + assert await succeeds() == "ok" + assert state_var.get() is prior + with pytest.raises(RuntimeError, match="not transport cancellation"): + await fails() + assert state_var.get() is prior + finally: + state_var.reset(token) + + +@pytest.mark.anyio +async def test_nested_typed_calls_restore_the_outer_state() -> None: + state_var: ContextVar[_State] = ContextVar("state") + created: list[_State] = [] + + def state_factory() -> _State: + state = _State(f"request-{len(created)}", True, 512) + created.append(state) + return state + + def response_factory(state: _State, exc: asyncio.CancelledError) -> str: + return state.request_id + + @_typed_decorator(state_factory, state_var, response_factory) + async def inner() -> str: + assert state_var.get() is created[1] + return "inner" + + @_typed_decorator(state_factory, state_var, response_factory) + async def outer() -> str: + outer_state = state_var.get() + assert outer_state is created[0] + assert await inner() == "inner" + assert state_var.get() is outer_state + return "outer" + + assert await outer() == "outer" + with pytest.raises(LookupError): + state_var.get() + + +@pytest.mark.anyio +async def test_typed_state_does_not_leak_between_concurrent_tasks() -> None: + state_var: ContextVar[_State] = ContextVar("state") + seed_var: ContextVar[str] = ContextVar("seed") + ready = asyncio.Event() + release = asyncio.Event() + seen: dict[str, _State] = {} + + def state_factory() -> _State: + seed = seed_var.get() + return _State(seed, True, 512) + + def response_factory(state: _State, exc: asyncio.CancelledError) -> str: + return state.request_id + + @_typed_decorator(state_factory, state_var, response_factory) + async def observe() -> str: + state = state_var.get() + seen[state.request_id] = state + if len(seen) == 2: + ready.set() + await ready.wait() + assert state_var.get() is state + await release.wait() + return state.request_id + + async def run(seed: str) -> str: + token = seed_var.set(seed) + try: + return await observe() + finally: + seed_var.reset(token) + + first = asyncio.create_task(run("first")) + second = asyncio.create_task(run("second")) + await ready.wait() + assert seen["first"] is not seen["second"] + release.set() + assert await asyncio.gather(first, second) == ["first", "second"] + with pytest.raises(LookupError): + state_var.get() + + +@pytest.mark.anyio +@pytest.mark.parametrize("admitted", [False, True]) +async def test_cancellation_before_and_after_admission_uses_captured_state( + admitted: bool, +) -> None: + from autoskillit.server._recipe_section_pagination import ( + RecipeSectionRequestState, + render_recipe_section_failure, + ) + + state_var: ContextVar[RecipeSectionRequestState] = ContextVar("recipe-request-state") + state = RecipeSectionRequestState( + admitted=admitted, + recipe_section_bound_bytes=512, + ) + seen: list[RecipeSectionRequestState] = [] + + def response_factory(captured: RecipeSectionRequestState, exc: asyncio.CancelledError) -> str: + seen.append(captured) + return render_recipe_section_failure( + "recipe_section_cancelled", + bound_bytes=captured.recipe_section_bound_bytes, + context={"admitted": captured.admitted}, + ) + + @_typed_decorator(lambda: state, state_var, response_factory) + async def cancelled() -> str: + assert state_var.get() is state + raise asyncio.CancelledError + + rendered = await cancelled() + response = json.loads(rendered) + assert seen == [state] + assert response["success"] is False + assert response["error"] == "recipe_section_cancelled" + assert len(rendered.encode("utf-8")) <= state.recipe_section_bound_bytes + with pytest.raises(LookupError): + state_var.get() diff --git a/tests/server/test_helpers_gate.py b/tests/server/test_helpers_gate.py index fe3e192bb..07683766b 100644 --- a/tests/server/test_helpers_gate.py +++ b/tests/server/test_helpers_gate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy import json import pytest @@ -9,12 +10,223 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +def _valid_recipe_section_pages(format_family: str) -> list[dict[str, object]]: + from autoskillit.server._recipe_delivery import RecipeArtifactGeneration + from autoskillit.server._recipe_section_pagination import ( + build_recipe_section_page_plan, + render_recipe_section_page, + select_recipe_section, + ) + + payload: dict[str, object] = { + "content": "raw-page-" * 600, + "ingredients_table": "scalar-page-" * 600, + "warnings": [f"array-{index:03d}-" + ("x" * 80) for index in range(40)], + } + section = { + "raw": "content", + "scalar": "ingredients_table", + "array": "warnings", + "fragment": "warnings", + }[format_family] + if format_family == "fragment": + payload["warnings"] = ["fragment-" + ("x" * 8_000)] + generation = RecipeArtifactGeneration( + producer_tool="open_kitchen", + recipe_name="remediation", + descriptor_version=1, + schema_version=1, + payload_sha256="sha256:" + ("1" * 64), + artifact_blob_sha256="sha256:" + ("2" * 64), + artifact_blob_size_bytes=10_000, + body_sha256="sha256:" + ("3" * 64), + body_size_bytes=9_000, + ) + plan = build_recipe_section_page_plan( + kitchen_id="consumer-sequence", + generation=generation, + selected=select_recipe_section(payload, section), + recipe_section_bound_bytes=1_000, + ) + pages = [ + json.loads(render_recipe_section_page(plan, part)) for part in range(plan.total_parts) + ] + assert len(pages) > 1 + if format_family == "fragment": + assert {page["content_format"] for page in pages} == {"json-element-fragment"} + return pages + + +def _mutate_recipe_section_pages( + pages: list[dict[str, object]], + mutation: str, +) -> None: + second = pages[1] + if mutation == "pagination_version": + second["pagination_version"] = -1 + elif mutation == "section_registry": + second["section_registry_sha256"] = "sha256:" + ("a" * 64) + elif mutation == "section": + second["section"] = "different-section" + elif mutation == "section_digest": + second["section_sha256"] = "sha256:" + ("b" * 64) + elif mutation == "plan_digest": + second["page_plan_sha256"] = "sha256:" + ("c" * 64) + elif mutation == "payload_identity": + second["payload_sha256"] = "sha256:" + ("d" * 64) + elif mutation == "body_identity": + second["body_sha256"] = "sha256:" + ("e" * 64) + elif mutation == "total_parts": + second["total_parts"] = int(second["total_parts"]) + 1 + elif mutation == "duplicate_part": + second["part"] = pages[0]["part"] + elif mutation == "unknown_format": + second["content_format"] = "future-format" + elif mutation in {"gap", "overlap", "duplicate_range"}: + fields = { + "raw-text": ("byte_start", "byte_end"), + "json-scalar-page": ("scalar_byte_start", "scalar_byte_end"), + "json-array-page": ("element_start", "element_end"), + "json-element-fragment": ( + "fragment_byte_start", + "fragment_byte_end", + ), + } + start_field, end_field = fields[str(second["content_format"])] + if mutation == "gap": + second[start_field] = int(second[start_field]) + 1 + elif mutation == "overlap": + second[start_field] = int(second[start_field]) - 1 + else: + second[start_field] = pages[0][start_field] + second[end_field] = pages[0][end_field] + elif mutation == "post_terminal": + pages[0]["has_more"] = False + pages[0].pop("next_part", None) + elif mutation == "terminal_next_part": + pages[-1]["next_part"] = int(pages[-1]["part"]) + 1 + else: + raise AssertionError(f"unknown mutation: {mutation}") + + @pytest.mark.anyio async def test_recipe_content_helper_rejects_failed_inline_response() -> None: - from tests.server._helpers import _resolve_recipe_content + from tests.server._helpers import _resolve_recipe_section with pytest.raises(AssertionError, match="recipe response was not successful"): - await _resolve_recipe_content({"success": False, "content": "stale"}) + await _resolve_recipe_section({"success": False, "content": "stale"}) + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("field", "value", "match"), + [ + ("pagination_version", -1, None), + ("content_format", "future-format", "unknown recipe section format"), + ], +) +async def test_recipe_section_helper_rejects_unknown_wire_contract( + monkeypatch: pytest.MonkeyPatch, + field: str, + value: object, + match: str | None, +) -> None: + from autoskillit.core import ( + RECIPE_SECTION_PAGINATION_VERSION, + RECIPE_SECTION_REGISTRY_DIGEST, + ) + from tests.server._helpers import _resolve_recipe_section + + response: dict[str, object] = { + "success": True, + "pagination_version": RECIPE_SECTION_PAGINATION_VERSION, + "section_registry_sha256": RECIPE_SECTION_REGISTRY_DIGEST, + "section": "content", + "content_format": "raw-text", + "content": "", + "part": 0, + "total_parts": 1, + "has_more": False, + "section_sha256": "sha256:" + ("1" * 64), + "page_plan_sha256": "sha256:" + ("2" * 64), + "payload_sha256": "sha256:" + ("3" * 64), + "body_sha256": "sha256:" + ("4" * 64), + "byte_start": 0, + "byte_end": 0, + "byte_total": 0, + } + response[field] = value + + async def _page(**_kwargs: object) -> str: + return json.dumps(response) + + monkeypatch.setattr( + "autoskillit.server.tools.tools_recipe.get_recipe_section", + _page, + ) + result = { + "success": True, + "recipe_pull": { + "pull_tool": "get_recipe_section", + "payload_sha256": response["payload_sha256"], + "body_sha256": response["body_sha256"], + }, + } + + with pytest.raises(AssertionError, match=match): + await _resolve_recipe_section(result) + + +@pytest.mark.anyio +@pytest.mark.parametrize("format_family", ["raw", "scalar", "array", "fragment"]) +@pytest.mark.parametrize( + "mutation", + [ + "pagination_version", + "section_registry", + "section", + "section_digest", + "plan_digest", + "payload_identity", + "body_identity", + "total_parts", + "duplicate_part", + "gap", + "overlap", + "duplicate_range", + "unknown_format", + "post_terminal", + "terminal_next_part", + ], +) +async def test_recipe_section_helper_rejects_invalid_page_sequences( + monkeypatch: pytest.MonkeyPatch, + format_family: str, + mutation: str, +) -> None: + from tests.server._helpers import _resolve_recipe_section + + pages = copy.deepcopy(_valid_recipe_section_pages(format_family)) + _mutate_recipe_section_pages(pages, mutation) + + async def _page(*, part: int, **_kwargs: object) -> str: + return json.dumps(pages[part]) + + monkeypatch.setattr( + "autoskillit.server.tools.tools_recipe.get_recipe_section", + _page, + ) + result = { + "success": True, + "recipe_pull": { + "pull_tool": "get_recipe_section", + "payload_sha256": pages[0]["payload_sha256"], + "body_sha256": pages[0]["body_sha256"], + }, + } + + with pytest.raises(AssertionError): + await _resolve_recipe_section(result, section=str(pages[0]["section"])) class TestGateDisabledSchema: diff --git a/tests/server/test_recipe_section_pagination.py b/tests/server/test_recipe_section_pagination.py new file mode 100644 index 000000000..a21ff018b --- /dev/null +++ b/tests/server/test_recipe_section_pagination.py @@ -0,0 +1,1186 @@ +"""Pure recipe-section planner, renderer, reconstruction, and cache contracts.""" + +from __future__ import annotations + +import dataclasses +import hashlib +import json +import math +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +from threading import Barrier, Event +from typing import Any + +import pytest + +from autoskillit.core import ( + RECIPE_SECTION_MANDATORY_FAILURE_CODES, + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, + recipe_section_digest, + recipe_section_element_digest, + recipe_section_plan_digest, +) +from autoskillit.server import _recipe_section_pagination as pagination +from autoskillit.server._recipe_delivery import ( + RECIPE_ARTIFACT_MAX_BLOB_BYTES, + RecipeArtifactGeneration, +) +from autoskillit.server._recipe_section_pagination import ( + PagePlanCache, + RecipeSectionPageDescriptor, + RecipeSectionPaginationError, + RecipeSectionRequestState, + build_recipe_section_page_plan, + get_or_build_recipe_section_page_plan, + render_recipe_section_failure, + render_recipe_section_page, + select_recipe_section, +) + +pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] + +_ALL_RANGE_FIELDS = { + "byte_start", + "byte_end", + "byte_total", + "element_start", + "element_end", + "element_total", + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", +} +_RANGE_FIELDS_BY_FORMAT = { + "raw-text": {"byte_start", "byte_end", "byte_total"}, + "json-array-page": {"element_start", "element_end", "element_total"}, + "json-scalar-page": { + "scalar_byte_start", + "scalar_byte_end", + "scalar_byte_total", + }, + "json-element-fragment": { + "element_index", + "element_sha256", + "fragment_index", + "fragment_count", + "fragment_byte_start", + "fragment_byte_end", + "fragment_byte_total", + }, +} + + +@pytest.fixture(autouse=True) +def _fresh_page_plan_cache(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(pagination, "_PAGE_PLAN_CACHE", PagePlanCache()) + + +def _generation(**changes: object) -> RecipeArtifactGeneration: + base: dict[str, object] = { + "producer_tool": "open_kitchen", + "recipe_name": "remediation", + "descriptor_version": 1, + "schema_version": 1, + "payload_sha256": f"sha256:{'1' * 64}", + "artifact_blob_sha256": f"sha256:{'2' * 64}", + "artifact_blob_size_bytes": 4096, + "body_sha256": f"sha256:{'3' * 64}", + "body_size_bytes": 2048, + } + base.update(changes) + return RecipeArtifactGeneration(**base) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "bound", + [True, 1.0, 0, RECIPE_SECTION_RESPONSE_FLOOR_BYTES - 1], +) +def test_request_state_rejects_non_integer_and_below_floor_bounds(bound: object) -> None: + with pytest.raises(ValueError, match="bound must be an integer at least"): + RecipeSectionRequestState( + admitted=True, + recipe_section_bound_bytes=bound, # type: ignore[arg-type] + ) + + +def test_page_descriptor_rejects_unknown_incomplete_and_mixed_range_families() -> None: + digest = f"sha256:{'0' * 64}" + with pytest.raises(ValueError, match="unknown recipe section content format"): + RecipeSectionPageDescriptor( + content_format="unknown", # type: ignore[arg-type] + page_content_sha256=digest, + ) + with pytest.raises(ValueError, match="range fields must exactly match"): + RecipeSectionPageDescriptor( + content_format="raw-text", + page_content_sha256=digest, + byte_start=0, + byte_end=1, + ) + with pytest.raises(ValueError, match="range fields must exactly match"): + RecipeSectionPageDescriptor( + content_format="raw-text", + page_content_sha256=digest, + byte_start=0, + byte_end=1, + byte_total=1, + scalar_byte_start=0, + scalar_byte_end=1, + scalar_byte_total=1, + ) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"page_content_sha256": "sha256:not-a-digest"}, "page content digest"), + ({"byte_start": False}, "byte_start must be a non-negative integer"), + ({"byte_end": 1.0}, "byte_end must be a non-negative integer"), + ({"byte_start": -1}, "byte_start must be a non-negative integer"), + ( + {"byte_start": 2, "byte_end": 1}, + "range must make ordered progress within its total", + ), + ( + {"byte_start": 1, "byte_end": 1, "byte_total": 2}, + "range must make ordered progress within its total", + ), + ( + {"byte_end": 3, "byte_total": 2}, + "range must make ordered progress within its total", + ), + ], +) +def test_page_descriptor_rejects_malformed_raw_range_values( + changes: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "content_format": "raw-text", + "page_content_sha256": f"sha256:{'0' * 64}", + "byte_start": 0, + "byte_end": 1, + "byte_total": 2, + } + values.update(changes) + + with pytest.raises(ValueError, match=message): + RecipeSectionPageDescriptor(**values) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"element_sha256": "sha256:BAD"}, "element_sha256"), + ({"element_index": False}, "element_index must be a non-negative integer"), + ({"fragment_count": 0}, "within a positive fragment count"), + ({"fragment_index": 2}, "within a positive fragment count"), + ], +) +def test_page_descriptor_rejects_malformed_fragment_values( + changes: dict[str, object], + message: str, +) -> None: + values: dict[str, object] = { + "content_format": "json-element-fragment", + "page_content_sha256": f"sha256:{'0' * 64}", + "element_index": 0, + "element_sha256": f"sha256:{'1' * 64}", + "fragment_index": 0, + "fragment_count": 2, + "fragment_byte_start": 0, + "fragment_byte_end": 1, + "fragment_byte_total": 2, + } + values.update(changes) + + with pytest.raises(ValueError, match=message): + RecipeSectionPageDescriptor(**values) # type: ignore[arg-type] + + +def test_scalar_planning_never_serializes_the_whole_oversized_remainder( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original = pagination.canonical_recipe_section_json + serialized_string_bytes: list[int] = [] + + def _record_bounded_string(value: object) -> str: + if type(value) is str: + serialized_string_bytes.append(len(value.encode("utf-8"))) + return original(value) + + monkeypatch.setattr(pagination, "canonical_recipe_section_json", _record_bounded_string) + bound = 1_000 + plan = _build( + _payload(ingredients_table="x" * 10_000), + "ingredients_table", + bound=bound, + ) + + assert plan.total_parts > 1 + assert serialized_string_bytes + assert max(serialized_string_bytes) <= bound + + +def _payload(**changes: object) -> dict[str, object]: + payload: dict[str, object] = { + "content": "name: remediation\nsteps:\n first:\n action: stop\n", + "ingredients_table": "| ingredient | value |\n|---|---|\n| task | demo |\n", + "orchestration_rules": "Follow the graph exactly.", + "stop_step_semantics": "Stop means return immediately.", + "errors": [], + "warnings": [], + "post_prune_step_names": ["first"], + } + payload.update(changes) + return payload + + +def _build( + payload: dict[str, object], + section: str, + *, + bound: int, + generation: RecipeArtifactGeneration | None = None, + kitchen_id: str = "kitchen-test", + dynamic_content: str | None = None, +) -> Any: + selected = select_recipe_section( + payload, + section, + dynamic_content=dynamic_content, + ) + return build_recipe_section_page_plan( + kitchen_id=kitchen_id, + generation=generation or _generation(), + selected=selected, + recipe_section_bound_bytes=bound, + ) + + +def test_select_recipe_section_loads_only_recognized_dynamic_content() -> None: + loaded_sections: list[str] = [] + + def _load_dynamic_content(section: str) -> str: + loaded_sections.append(section) + return "first:\n action: stop\n" + + payload = _payload() + + fixed = select_recipe_section( + payload, + "content", + dynamic_content_loader=_load_dynamic_content, + ) + dynamic = select_recipe_section( + payload, + "first", + dynamic_content_loader=_load_dynamic_content, + ) + unknown = select_recipe_section( + payload, + "unknown", + dynamic_content_loader=_load_dynamic_content, + ) + + assert fixed.present is True + assert dynamic.present is True + assert dynamic.value == "first:\n action: stop\n" + assert unknown.present is False + assert loaded_sections == ["first"] + + +def test_select_recipe_section_rejects_empty_dynamic_content() -> None: + selected = select_recipe_section( + _payload(), + "first", + dynamic_content_loader=lambda _section: "", + ) + + assert selected.present is False + assert selected.value == "" + + +def test_failure_floor_is_derived_from_the_registered_renderer() -> None: + assert ( + render_recipe_section_failure.__module__ == "autoskillit.server.recipe_section._rendering" + ) + rendered_failures = [ + render_recipe_section_failure( + code, + bound_bytes=RECIPE_SECTION_RESPONSE_FLOOR_BYTES, + ) + for code in RECIPE_SECTION_MANDATORY_FAILURE_CODES + ] + + assert max(len(rendered.encode("utf-8")) for rendered in rendered_failures) == ( + RECIPE_SECTION_RESPONSE_FLOOR_BYTES + ) + with pytest.raises(ValueError, match="unregistered recipe section failure code"): + render_recipe_section_failure( + "unregistered_failure", + bound_bytes=RECIPE_SECTION_RESPONSE_FLOOR_BYTES, + ) + + +def _rendered_pages(plan: Any) -> list[str]: + return [render_recipe_section_page(plan, part) for part in range(plan.total_parts)] + + +def _clear_page_plan_cache() -> None: + cache = pagination._PAGE_PLAN_CACHE + assert cache is not None + cache.clear() + + +def _decoded_pages(plan: Any, *, bound: int) -> list[dict[str, Any]]: + rendered_pages = _rendered_pages(plan) + decoded_pages: list[dict[str, Any]] = [] + for part, rendered in enumerate(rendered_pages): + assert len(rendered.encode("utf-8")) <= bound + page = json.loads(rendered) + assert rendered == json.dumps( + page, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + assert page["success"] is True + assert page["part"] == part + assert page["total_parts"] == len(rendered_pages) + assert page["has_more"] is (part + 1 < len(rendered_pages)) + if page["has_more"]: + assert page["next_part"] == part + 1 + else: + assert "next_part" not in page + required_ranges = _RANGE_FIELDS_BY_FORMAT[page["content_format"]] + assert required_ranges <= page.keys() + assert not ((_ALL_RANGE_FIELDS - required_ranges) & page.keys()) + assert page["content"] != "" or page["content_format"] == "json-array-page" + if page["content_format"] != "raw-text": + json.loads(page["content"]) + decoded_pages.append(page) + + identities = { + ( + page["pagination_version"], + page["section_registry_sha256"], + page["payload_sha256"], + page["body_sha256"], + page["page_plan_sha256"], + page["section_sha256"], + ) + for page in decoded_pages + } + assert len(identities) == 1 + return decoded_pages + + +def _reconstruct(pages: list[dict[str, Any]]) -> object: + formats = {page["content_format"] for page in pages} + if formats == {"raw-text"}: + cursor = 0 + chunks: list[str] = [] + for page in pages: + assert page["byte_start"] == cursor + assert page["byte_end"] > page["byte_start"] + chunk = page["content"] + assert len(chunk.encode("utf-8")) == page["byte_end"] - page["byte_start"] + chunks.append(chunk) + cursor = page["byte_end"] + assert cursor == pages[-1]["byte_total"] + return "".join(chunks) + + if formats == {"json-scalar-page"}: + cursor = 0 + chunks = [] + for page in pages: + assert page["scalar_byte_start"] == cursor + assert page["scalar_byte_end"] > page["scalar_byte_start"] + chunk = json.loads(page["content"]) + assert isinstance(chunk, str) + assert ( + len(chunk.encode("utf-8")) == page["scalar_byte_end"] - page["scalar_byte_start"] + ) + chunks.append(chunk) + cursor = page["scalar_byte_end"] + assert cursor == pages[-1]["scalar_byte_total"] + return "".join(chunks) + + assert formats <= {"json-array-page", "json-element-fragment"} + result: list[object] = [] + element_cursor = 0 + page_cursor = 0 + while page_cursor < len(pages): + page = pages[page_cursor] + if page["content_format"] == "json-array-page": + assert page["element_start"] == element_cursor + values = json.loads(page["content"]) + assert isinstance(values, list) and values + assert page["element_end"] - page["element_start"] == len(values) + result.extend(values) + element_cursor = page["element_end"] + page_cursor += 1 + continue + + element_index = page["element_index"] + assert element_index == element_cursor + fragment_count = page["fragment_count"] + fragments: list[str] = [] + fragment_byte_cursor = 0 + for expected_fragment in range(fragment_count): + fragment_page = pages[page_cursor] + assert fragment_page["content_format"] == "json-element-fragment" + assert fragment_page["element_index"] == element_index + assert fragment_page["fragment_index"] == expected_fragment + assert fragment_page["fragment_count"] == fragment_count + assert fragment_page["fragment_byte_start"] == fragment_byte_cursor + fragment = json.loads(fragment_page["content"]) + assert isinstance(fragment, str) and fragment + assert ( + len(fragment.encode("utf-8")) + == fragment_page["fragment_byte_end"] - fragment_page["fragment_byte_start"] + ) + fragments.append(fragment) + fragment_byte_cursor = fragment_page["fragment_byte_end"] + page_cursor += 1 + assert fragment_byte_cursor == page["fragment_byte_total"] + canonical_element = "".join(fragments) + assert page["element_sha256"] == recipe_section_element_digest( + json.loads(canonical_element) + ) + result.append(json.loads(canonical_element)) + element_cursor += 1 + + assert element_cursor == pages[-1].get("element_total", element_cursor) + return result + + +@pytest.mark.parametrize( + "value", + [ + "plain ASCII text " * 100, + "snowman ☃ and emoji 🥘 " * 100, + 'quotes " and backslashes \\\\ ' * 100, + "tabs\tnewlines\ncarriage\rcontrols\u0001 " * 100, + ], +) +def test_raw_pages_preserve_text_and_exact_utf8_bounds(value: str) -> None: + bound = 1_000 + plan = _build(_payload(content=value), "content", bound=bound) + pages = _decoded_pages(plan, bound=bound) + + assert _reconstruct(pages) == value + assert pages[0]["section"] == "content" + assert pages[0]["section_sha256"] == recipe_section_digest(value, raw=True) + + +@pytest.mark.parametrize( + "value", + [ + "| a | b |\n|---|---|\n| 1 | 2 |\n" * 60, + 'Markdown "quoted" \\\\ escaped ☃\n' * 80, + ], +) +def test_json_scalar_pages_are_independently_valid_and_reconstruct_markdown( + value: str, +) -> None: + bound = 1_000 + plan = _build(_payload(ingredients_table=value), "ingredients_table", bound=bound) + pages = _decoded_pages(plan, bound=bound) + + assert {page["content_format"] for page in pages} == {"json-scalar-page"} + assert _reconstruct(pages) == value + assert pages[0]["section_sha256"] == recipe_section_digest(value, raw=False) + + +def test_ordered_array_pages_are_complete_json_documents() -> None: + values = [f"warning-{index:03d}-☃" * 4 for index in range(80)] + bound = 1_000 + plan = _build(_payload(warnings=values), "warnings", bound=bound) + pages = _decoded_pages(plan, bound=bound) + + assert {page["content_format"] for page in pages} == {"json-array-page"} + assert _reconstruct(pages) == values + assert pages[0]["section_sha256"] == recipe_section_digest(values, raw=False) + + +@pytest.mark.parametrize("oversized_index", [0, 1, 2]) +def test_oversized_array_elements_fragment_in_first_middle_and_final_positions( + oversized_index: int, +) -> None: + values = ["before", "middle", "after"] + values[oversized_index] = 'oversized-"quoted"-\\\\-☃-' * 600 + bound = 1_000 + plan = _build(_payload(warnings=values), "warnings", bound=bound) + pages = _decoded_pages(plan, bound=bound) + + assert "json-element-fragment" in {page["content_format"] for page in pages} + assert _reconstruct(pages) == values + fragments = [page for page in pages if page["content_format"] == "json-element-fragment"] + assert {page["element_index"] for page in fragments} == {oversized_index} + assert {page["element_sha256"] for page in fragments} == { + recipe_section_element_digest(values[oversized_index]) + } + + +def test_array_plan_can_interleave_ordinary_and_fragment_pages() -> None: + values = [ + "ordinary-first", + "x" * 12_000, + "ordinary-middle", + "y" * 12_000, + "ordinary-final", + ] + bound = 1_000 + plan = _build(_payload(errors=values), "errors", bound=bound) + pages = _decoded_pages(plan, bound=bound) + + formats = [page["content_format"] for page in pages] + assert "json-array-page" in formats + assert "json-element-fragment" in formats + assert _reconstruct(pages) == values + + +def test_raw_recipe_and_named_step_yaml_use_unchanged_raw_reconstruction() -> None: + recipe = "name: demo\nsteps:\n first:\n run: echo unchanged\n" + named_step = "first:\n run: echo unchanged\n" + bound = 1_000 + + recipe_plan = _build(_payload(content=recipe), "content", bound=bound) + step_plan = _build( + _payload(content=recipe), + "first", + bound=bound, + dynamic_content=named_step, + ) + + assert _reconstruct(_decoded_pages(recipe_plan, bound=bound)) == recipe + step_pages = _decoded_pages(step_plan, bound=bound) + assert {page["content_format"] for page in step_pages} == {"raw-text"} + assert _reconstruct(step_pages) == named_step + + +def test_exact_fit_succeeds_and_one_byte_under_replans_without_oversize() -> None: + value = "exact-fit-☃-" * 300 + wide = _build(_payload(content=value), "content", bound=10_000) + assert wide.total_parts == 1 + exact_bound = len(render_recipe_section_page(wide, 0).encode("utf-8")) + + exact = _build(_payload(content=value), "content", bound=exact_bound) + assert exact.total_parts == 1 + assert len(render_recipe_section_page(exact, 0).encode("utf-8")) == exact_bound + + tight = _build(_payload(content=value), "content", bound=exact_bound - 1) + assert tight.total_parts > 1 + assert _reconstruct(_decoded_pages(tight, bound=exact_bound - 1)) == value + + +def test_production_like_ten_thousand_byte_bound_is_honored() -> None: + values = [f"warning-{index}-" + ("x" * 1_000) for index in range(50)] + plan = _build(_payload(warnings=values), "warnings", bound=10_000) + pages = _decoded_pages(plan, bound=10_000) + + assert len(pages) > 1 + assert _reconstruct(pages) == values + + +@pytest.mark.parametrize("strategy", ["raw", "scalar", "array", "fragment"]) +def test_candidate_sizing_uses_binary_search_scale_oracle_calls( + monkeypatch: pytest.MonkeyPatch, + strategy: str, +) -> None: + if strategy == "raw": + payload = _payload(content="r" * 50_000) + section = "content" + search_space = 50_000 + elif strategy == "scalar": + payload = _payload(ingredients_table="s" * 50_000) + section = "ingredients_table" + search_space = 50_000 + elif strategy == "array": + values = [f"value-{index:04d}-" + ("a" * 40) for index in range(2_000)] + payload = _payload(warnings=values) + section = "warnings" + search_space = len(values) + else: + fragment = "f" * 50_000 + payload = _payload(warnings=[fragment]) + section = "warnings" + search_space = len(fragment) + + oracle_calls = 0 + original_fits = pagination._fits + + def _counted_fits(**kwargs: Any) -> bool: + nonlocal oracle_calls + oracle_calls += 1 + return original_fits(**kwargs) + + monkeypatch.setattr(pagination, "_fits", _counted_fits) + + plan = _build(payload, section, bound=1_000) + + binary_search_scale = math.ceil(math.log2(search_space + 1)) + assert oracle_calls <= 6 * plan.total_parts * (binary_search_scale + 2) + if strategy == "fragment": + assert {json.loads(page)["content_format"] for page in plan.rendered_pages} == { + "json-element-fragment" + } + + +def test_convergence_ceiling_is_derived_from_artifact_policy() -> None: + max_count_digits = len(str(RECIPE_ARTIFACT_MAX_BLOB_BYTES)) + + assert pagination._convergence_iteration_ceiling() == 1 + ( + (RECIPE_ARTIFACT_MAX_BLOB_BYTES + 1) * (max_count_digits - 1) + ) + assert pagination._convergence_iteration_ceiling() > 32 + + +def test_final_digest_injection_revalidates_descriptor_boundaries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_render = pagination._render_candidate + + def _corrupt_final_boundary(**kwargs: Any) -> str: + rendered = original_render(**kwargs) + if ( + kwargs["page_plan_sha256"] != pagination._PLAN_DIGEST_PLACEHOLDER + and kwargs["part"] == 1 + ): + response = json.loads(rendered) + response["byte_start"] += 1 + return json.dumps( + response, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ) + return rendered + + monkeypatch.setattr(pagination, "_render_candidate", _corrupt_final_boundary) + + with pytest.raises( + RecipeSectionPaginationError, + match="changed plan identity or boundaries", + ): + _build(_payload(content="boundary-check-" * 500), "content", bound=1_000) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + ("fragment_count", "fragment identity changed"), + ("element_digest", "fragment identity changed"), + ("fragment_range", "fragment range does not match its content"), + ("reconstruction", "do not reconstruct their element"), + ("page_content_digest", "page content digest changed"), + ], +) +def test_final_verifier_rejects_fragment_descriptor_and_content_corruption( + mutation: str, + message: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_verify = pagination.verify_finalized_recipe_section_plan + + def _verify_corrupted(**kwargs: Any) -> None: + pages = list(kwargs["pages"]) + first = pages[0] + descriptor = first.descriptor + if mutation == "fragment_count": + assert descriptor.fragment_count is not None + descriptor = replace( + descriptor, + fragment_count=descriptor.fragment_count + 1, + ) + elif mutation == "element_digest": + descriptor = replace(descriptor, element_sha256=f"sha256:{'f' * 64}") + elif mutation == "fragment_range": + assert descriptor.fragment_byte_end is not None + descriptor = replace( + descriptor, + fragment_byte_end=descriptor.fragment_byte_end - 1, + ) + elif mutation == "reconstruction": + decoded = json.loads(first.content) + first = replace( + first, + content=json.dumps( + "x" + decoded[1:], + ensure_ascii=False, + separators=(",", ":"), + ), + ) + else: + descriptor = replace(descriptor, page_content_sha256=f"sha256:{'f' * 64}") + pages[0] = replace(first, descriptor=descriptor) + original_verify(**{**kwargs, "pages": pages}) + + monkeypatch.setattr( + pagination, + "verify_finalized_recipe_section_plan", + _verify_corrupted, + ) + + with pytest.raises(RecipeSectionPaginationError, match=message): + _build(_payload(warnings=["\\" * 5_000]), "warnings", bound=1_000) + + +def test_page_and_fragment_indices_cross_two_digit_boundaries() -> None: + raw_plan = _build(_payload(content="r" * 120_000), "content", bound=1_000) + assert raw_plan.total_parts > 100 + raw_pages = _decoded_pages(raw_plan, bound=1_000) + assert [raw_pages[index]["part"] for index in (8, 9, 10, 98, 99, 100)] == [ + 8, + 9, + 10, + 98, + 99, + 100, + ] + + fragment_plan = _build( + _payload(warnings=["\\" * 120_000]), + "warnings", + bound=1_000, + ) + fragment_pages = _decoded_pages(fragment_plan, bound=1_000) + assert len(fragment_pages) > 100 + assert [fragment_pages[index]["fragment_index"] for index in (8, 9, 10, 98, 99, 100)] == [ + 8, + 9, + 10, + 98, + 99, + 100, + ] + assert _reconstruct(fragment_pages) == ["\\" * 120_000] + + +def test_plan_manifest_is_complete_and_plan_digest_is_non_self_referential() -> None: + bound = 1_000 + generation = _generation() + plan = _build( + _payload(warnings=["a", "b", "c"] * 20), + "warnings", + bound=bound, + generation=generation, + ) + pages = _decoded_pages(plan, bound=bound) + manifest = dataclasses.asdict(plan.manifest) + + assert set(manifest) == { + "pagination_version", + "section_registry_sha256", + "pagination_policy_sha256", + "generation", + "section", + "section_strategy", + "section_sha256", + "recipe_section_bound_bytes", + "pages", + } + assert manifest["recipe_section_bound_bytes"] == bound + assert plan.page_plan_sha256 == recipe_section_plan_digest(plan.manifest) + assert {page["page_plan_sha256"] for page in pages} == {plan.page_plan_sha256} + assert len(plan.page_plan_sha256) == len(f"sha256:{'0' * 64}") + + +def test_string_scalar_strategy_rejects_non_string_values() -> None: + selected = select_recipe_section( + _payload(ingredients_table={"not": "markdown"}), + "ingredients_table", + ) + + with pytest.raises((TypeError, ValueError), match="string"): + build_recipe_section_page_plan( + kitchen_id="kitchen-test", + generation=_generation(), + selected=selected, + recipe_section_bound_bytes=1_000, + ) + + +def test_repeat_builds_and_fresh_cache_are_deterministic() -> None: + payload = _payload(warnings=[f"value-{index}" for index in range(50)]) + first = _build(payload, "warnings", bound=1_000) + second = _build(payload, "warnings", bound=1_000) + + assert first == second + assert _rendered_pages(first) == _rendered_pages(second) + assert first.page_plan_sha256 == second.page_plan_sha256 + + _clear_page_plan_cache() + third = _build(payload, "warnings", bound=1_000) + assert third == first + + +def test_cached_plans_are_reused_and_cache_clear_forces_a_rebuild( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = select_recipe_section(_payload(content="cache me " * 500), "content") + kwargs: dict[str, Any] = { + "kitchen_id": "kitchen-test", + "generation": _generation(), + "selected": selected, + "recipe_section_bound_bytes": 1_000, + } + calls = 0 + real_builder = pagination.build_recipe_section_page_plan + + def counted_builder(**builder_kwargs: object) -> Any: + nonlocal calls + calls += 1 + return real_builder(**builder_kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(pagination, "build_recipe_section_page_plan", counted_builder) + + first = get_or_build_recipe_section_page_plan(**kwargs) + second = get_or_build_recipe_section_page_plan(**kwargs) + assert second is first + assert calls == 1 + + _clear_page_plan_cache() + third = get_or_build_recipe_section_page_plan(**kwargs) + assert third == first + assert third is not first + assert calls == 2 + + +def test_concurrent_same_key_requests_share_one_page_plan_build( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = select_recipe_section(_payload(content="single flight"), "content") + kwargs: dict[str, Any] = { + "kitchen_id": "kitchen-single-flight", + "generation": _generation(), + "selected": selected, + "recipe_section_bound_bytes": 1_000, + } + start = Barrier(3) + build_started = Event() + release_build = Event() + build_calls: list[None] = [] + real_builder = pagination.build_recipe_section_page_plan + + def blocked_builder(**builder_kwargs: object) -> Any: + build_calls.append(None) + build_started.set() + assert release_build.wait(timeout=5) + return real_builder(**builder_kwargs) # type: ignore[arg-type] + + def request_plan() -> Any: + start.wait(timeout=5) + return get_or_build_recipe_section_page_plan(**kwargs) + + monkeypatch.setattr(pagination, "build_recipe_section_page_plan", blocked_builder) + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [executor.submit(request_plan) for _ in range(2)] + start.wait(timeout=5) + assert build_started.wait(timeout=5) + release_build.set() + plans = [future.result(timeout=5) for future in futures] + + assert len(build_calls) == 1 + assert plans[0] is plans[1] + + +def test_retirement_during_build_prevents_stale_cache_admission( + monkeypatch: pytest.MonkeyPatch, +) -> None: + selected = select_recipe_section(_payload(content="retirement race"), "content") + generation = _generation() + kwargs: dict[str, Any] = { + "kitchen_id": "kitchen-retirement-race", + "generation": generation, + "selected": selected, + "recipe_section_bound_bytes": 1_000, + } + key = pagination._cache_key(**kwargs) + cache = pagination._page_plan_cache() + build_started = Barrier(2) + release_build = Event() + retirement_started = Event() + real_builder = pagination.build_recipe_section_page_plan + + def blocked_builder(**builder_kwargs: object) -> Any: + build_started.wait(timeout=5) + assert release_build.wait(timeout=5) + return real_builder(**builder_kwargs) # type: ignore[arg-type] + + def retire_kitchen() -> None: + retirement_started.set() + cache.evict_kitchen("kitchen-retirement-race") + + monkeypatch.setattr(pagination, "build_recipe_section_page_plan", blocked_builder) + with ThreadPoolExecutor(max_workers=2) as executor: + build_future = executor.submit(get_or_build_recipe_section_page_plan, **kwargs) + build_started.wait(timeout=5) + retirement_future = executor.submit(retire_kitchen) + assert retirement_started.wait(timeout=5) + release_build.set() + plan = build_future.result(timeout=5) + retirement_future.result(timeout=5) + + assert cache.get(key) is None + cache.put(key, plan) + assert cache.get(key) is None + + +@pytest.mark.parametrize( + "dimension", + [ + "kitchen", + "generation", + "section", + "section_digest", + "bound", + "registry_digest", + "policy_digest", + "version", + ], +) +def test_every_cache_key_dimension_prevents_aliasing( + dimension: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + baseline_selected = select_recipe_section(_payload(content="same"), "content") + kwargs: dict[str, Any] = { + "kitchen_id": "kitchen-a", + "generation": _generation(), + "selected": baseline_selected, + "recipe_section_bound_bytes": 1_000, + } + baseline = get_or_build_recipe_section_page_plan(**kwargs) + + if dimension == "kitchen": + kwargs["kitchen_id"] = "kitchen-b" + elif dimension == "generation": + kwargs["generation"] = replace(_generation(), body_size_bytes=2049) + elif dimension == "section": + kwargs["selected"] = select_recipe_section( + _payload(orchestration_rules="same"), + "orchestration_rules", + ) + elif dimension == "section_digest": + kwargs["selected"] = select_recipe_section(_payload(content="changed"), "content") + elif dimension == "bound": + kwargs["recipe_section_bound_bytes"] = 1_001 + elif dimension == "registry_digest": + monkeypatch.setattr( + pagination, + "RECIPE_SECTION_REGISTRY_DIGEST", + f"sha256:{'a' * 64}", + ) + elif dimension == "policy_digest": + monkeypatch.setattr( + pagination, + "RECIPE_SECTION_PAGINATION_POLICY_DIGEST", + f"sha256:{'b' * 64}", + ) + else: + monkeypatch.setattr( + pagination, + "RECIPE_SECTION_PAGINATION_VERSION", + pagination.RECIPE_SECTION_PAGINATION_VERSION + 1, + ) + + changed = get_or_build_recipe_section_page_plan(**kwargs) + assert changed is not baseline + assert get_or_build_recipe_section_page_plan(**kwargs) is changed + + +def test_cache_entry_limit_evicts_oldest_plan() -> None: + selected = select_recipe_section(_payload(content="entry eviction"), "content") + generation = _generation() + first = get_or_build_recipe_section_page_plan( + kitchen_id="kitchen-0", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + for index in range(1, pagination.PAGE_PLAN_CACHE_MAX_ENTRIES + 1): + get_or_build_recipe_section_page_plan( + kitchen_id=f"kitchen-{index}", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + + rebuilt = get_or_build_recipe_section_page_plan( + kitchen_id="kitchen-0", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + assert rebuilt == first + assert rebuilt is not first + + +def test_cache_rejects_a_single_plan_over_its_byte_limit() -> None: + selected = select_recipe_section(_payload(content="overweight"), "content") + generation = _generation() + plan = _build(_payload(content="overweight"), "content", bound=1_000) + key = pagination._cache_key( + kitchen_id="kitchen-overweight", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + cache = PagePlanCache(max_bytes=plan.cache_weight_bytes - 1) + + cache.put(key, plan) + + assert cache.get(key) is None + assert cache._weight_bytes == 0 + + +def test_cache_byte_limit_evicts_oldest_plan() -> None: + selected = select_recipe_section(_payload(content="byte eviction"), "content") + generation = _generation() + plan = dataclasses.replace( + _build(_payload(content="byte eviction"), "content", bound=1_000), + cache_weight_bytes=10, + ) + keys = [ + pagination._cache_key( + kitchen_id=f"kitchen-{index}", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + for index in range(3) + ] + cache = PagePlanCache(max_entries=10, max_bytes=20) + + for key in keys: + cache.put(key, plan) + + assert cache.get(keys[0]) is None + assert cache.get(keys[1]) is plan + assert cache.get(keys[2]) is plan + assert cache._weight_bytes == 20 + + +def test_cache_replacement_subtracts_the_previous_plan_weight() -> None: + selected = select_recipe_section(_payload(content="replacement"), "content") + generation = _generation() + base_plan = _build(_payload(content="replacement"), "content", bound=1_000) + first = dataclasses.replace(base_plan, cache_weight_bytes=7) + replacement = dataclasses.replace(base_plan, cache_weight_bytes=3) + other = dataclasses.replace(base_plan, cache_weight_bytes=7) + key = pagination._cache_key( + kitchen_id="kitchen-replacement", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + other_key = pagination._cache_key( + kitchen_id="kitchen-other", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + cache = PagePlanCache(max_entries=10, max_bytes=10) + + cache.put(key, first) + cache.put(key, replacement) + cache.put(other_key, other) + + assert cache.get(key) is replacement + assert cache.get(other_key) is other + assert cache._weight_bytes == 10 + + +def test_cache_kitchen_eviction_subtracts_every_matching_plan_weight() -> None: + selected = select_recipe_section(_payload(content="kitchen eviction"), "content") + generation = _generation() + plan = dataclasses.replace( + _build(_payload(content="kitchen eviction"), "content", bound=1_000), + cache_weight_bytes=5, + ) + retired_keys = [ + pagination._cache_key( + kitchen_id="retired-kitchen", + generation=generation, + selected=selected, + recipe_section_bound_bytes=bound, + ) + for bound in (1_000, 1_001) + ] + retained_key = pagination._cache_key( + kitchen_id="retained-kitchen", + generation=generation, + selected=selected, + recipe_section_bound_bytes=1_000, + ) + cache = PagePlanCache(max_entries=10, max_bytes=20) + for key in (*retired_keys, retained_key): + cache.put(key, plan) + + cache.evict_kitchen("retired-kitchen") + + assert all(cache.get(key) is None for key in retired_keys) + assert cache.get(retained_key) is plan + assert cache._weight_bytes == plan.cache_weight_bytes + + +def test_cross_process_plan_and_rendering_are_deterministic() -> None: + payload = _payload(warnings=["alpha", "snowman-☃", 'quote-"', "slash-\\"] * 20) + local = _build(payload, "warnings", bound=1_000) + local_render_sha = hashlib.sha256( + "\0".join(_rendered_pages(local)).encode("utf-8") + ).hexdigest() + script = f""" +import hashlib +from autoskillit.server._recipe_delivery import RecipeArtifactGeneration +from autoskillit.server._recipe_section_pagination import ( + build_recipe_section_page_plan, + render_recipe_section_page, + select_recipe_section, +) +payload = {payload!r} +generation = RecipeArtifactGeneration( + producer_tool="open_kitchen", + recipe_name="remediation", + descriptor_version=1, + schema_version=1, + payload_sha256="sha256:" + "1" * 64, + artifact_blob_sha256="sha256:" + "2" * 64, + artifact_blob_size_bytes=4096, + body_sha256="sha256:" + "3" * 64, + body_size_bytes=2048, +) +plan = build_recipe_section_page_plan( + kitchen_id="kitchen-test", + generation=generation, + selected=select_recipe_section(payload, "warnings"), + recipe_section_bound_bytes=1000, +) +rendered = [render_recipe_section_page(plan, part) for part in range(plan.total_parts)] +print(plan.page_plan_sha256) +print(hashlib.sha256("\\0".join(rendered).encode("utf-8")).hexdigest()) +""" + completed = subprocess.run( + [sys.executable, "-c", script], + check=True, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.stdout.splitlines() == [ + local.page_plan_sha256, + local_render_sha, + ] diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index 202761e28..89bcf4863 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -9,7 +9,10 @@ import structlog from autoskillit.config import OutputBudgetConfig -from autoskillit.core import RESPONSE_BACKSTOP_EXEMPTION_REGISTRY +from autoskillit.core import ( + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, +) from autoskillit.server._response_budget import ( RESPONSE_SPILL_METADATA_KEY, RESPONSE_SPILL_METADATA_KEYS, @@ -309,11 +312,15 @@ def _stack_exhausted(*_args, **_kwargs): assert open(data["artifact_path"], encoding="utf-8").read() == original -def test_nonpositive_response_max_bytes_is_rejected(): - with pytest.raises(ValueError, match="response_max_bytes"): - OutputBudgetConfig(response_max_bytes=0) +@pytest.mark.parametrize( + "response_max_bytes", + [0, -10, RECIPE_SECTION_RESPONSE_FLOOR_BYTES - 1], +) +def test_response_max_bytes_below_recipe_section_floor_is_rejected( + response_max_bytes: int, +) -> None: with pytest.raises(ValueError, match="response_max_bytes"): - OutputBudgetConfig(response_max_bytes=-10) + OutputBudgetConfig(response_max_bytes=response_max_bytes) def test_spill_and_failure_telemetry_is_exact_and_path_free(tmp_path, monkeypatch): @@ -459,7 +466,7 @@ def test_plain_text_irreducible_shape_returns_failure(tmp_path): inline_max_chars=10, head_chars=5, tail_chars=5, - response_max_bytes=50, + response_max_bytes=RECIPE_SECTION_RESPONSE_FLOOR_BYTES, ) result = enforce_response_budget( "x" * 1000, diff --git a/tests/server/test_serve_idempotence.py b/tests/server/test_serve_idempotence.py index 0130ed077..eb36ca200 100644 --- a/tests/server/test_serve_idempotence.py +++ b/tests/server/test_serve_idempotence.py @@ -15,7 +15,7 @@ from autoskillit.core import RECIPE_DELIVERY_SURFACE_REGISTRY from autoskillit.pipeline.context import ToolContext -from tests.server._helpers import _resolve_recipe_content +from tests.server._helpers import _resolve_recipe_section pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @@ -85,10 +85,10 @@ async def test_load_recipe_after_open_kitchen_with_overrides_serves_identical_co monkeypatch, ) assert ok_result.get("success") is True, f"open_kitchen failed: {ok_result}" - ok_content = await _resolve_recipe_content(ok_result) + ok_content = await _resolve_recipe_section(ok_result) lr_result = json.loads(await load_recipe(name=_RECIPE)) - lr_content = await _resolve_recipe_content(lr_result) + lr_content = await _resolve_recipe_section(lr_result) assert ok_content == lr_content, ( "load_recipe content diverges from open_kitchen content — " @@ -124,8 +124,8 @@ async def test_load_recipe_after_open_kitchen_without_overrides_serves_identical ) lr_result = json.loads(await load_recipe(name=_RECIPE)) - ok_content = await _resolve_recipe_content(ok_result) - lr_content = await _resolve_recipe_content(lr_result) + ok_content = await _resolve_recipe_section(ok_result) + lr_content = await _resolve_recipe_section(lr_result) assert ok_content == lr_content, ( "load_recipe content diverges from open_kitchen content (no-override path) — " "session_serve_overrides baseline not applied" @@ -151,7 +151,7 @@ async def test_deferred_recall_open_kitchen_serves_identical_to_first_serving( monkeypatch, ) assert first_result.get("success") is True, f"first open_kitchen failed: {first_result}" - first_content = await _resolve_recipe_content(first_result) + first_content = await _resolve_recipe_section(first_result) deferred_result = await _open_kitchen_patched( _RECIPE, @@ -161,7 +161,7 @@ async def test_deferred_recall_open_kitchen_serves_identical_to_first_serving( assert deferred_result.get("success") is True, ( f"deferred-recall open_kitchen failed: {deferred_result}" ) - deferred_content = await _resolve_recipe_content(deferred_result) + deferred_content = await _resolve_recipe_section(deferred_result) assert first_content == deferred_content, ( "Deferred-recall open_kitchen content diverges from first serving — " @@ -233,7 +233,7 @@ async def test_explicit_load_recipe_overrides_layer_on_top_of_session_baseline( lr_result = json.loads( await load_recipe(name=_RECIPE, overrides={"extra_ingredient": "extra_value"}) ) - lr_content = await _resolve_recipe_content(lr_result) + lr_content = await _resolve_recipe_section(lr_result) from autoskillit.core.io import load_yaml @@ -252,7 +252,7 @@ async def _get_recipe_resource_content(recipe_name: str) -> str: resource = json.loads(get_recipe(recipe_name)) assert "error" not in resource, f"get_recipe returned error: {resource}" - return await _resolve_recipe_content(resource) + return await _resolve_recipe_section(resource) async def test_get_recipe_content_matches_open_kitchen_with_overrides( @@ -277,7 +277,7 @@ async def test_get_recipe_content_matches_open_kitchen_with_overrides( monkeypatch, ) assert ok_result.get("success") is True, f"open_kitchen failed: {ok_result}" - ok_content = await _resolve_recipe_content(ok_result) + ok_content = await _resolve_recipe_section(ok_result) gr_content = await _get_recipe_resource_content(_RECIPE) @@ -310,13 +310,13 @@ async def _call_re_serve_surface( from autoskillit.server.tools.tools_recipe import load_recipe result = json.loads(await load_recipe(name=recipe_name)) - return await _resolve_recipe_content(result) + return await _resolve_recipe_section(result) elif surface == "get_recipe": return await _get_recipe_resource_content(recipe_name) elif surface == "open_kitchen_deferred_recall": result = await _open_kitchen_patched(recipe_name, None, monkeypatch) assert result.get("success") is True, f"deferred-recall failed: {result}" - return await _resolve_recipe_content(result) + return await _resolve_recipe_section(result) else: raise ValueError(f"Unknown surface: {surface!r}") @@ -346,7 +346,7 @@ async def test_serve_surfaces_parametric_content_identity( monkeypatch, ) assert ok_result.get("success") is True, f"open_kitchen failed: {ok_result}" - ok_content = await _resolve_recipe_content(ok_result) + ok_content = await _resolve_recipe_section(ok_result) re_served_content = await _call_re_serve_surface(surface, _RECIPE, monkeypatch) @@ -375,7 +375,7 @@ async def test_get_recipe_snapshot_lifecycle( monkeypatch, ) assert ok_result.get("success") is True, f"open_kitchen failed: {ok_result}" - ok_content = await _resolve_recipe_content(ok_result) + ok_content = await _resolve_recipe_section(ok_result) gr_content = await _get_recipe_resource_content(_RECIPE) @@ -453,8 +453,8 @@ async def test_load_recipe_routing_matches_open_kitchen_for_arbitrary_overrides( lr_result = json.loads(await load_recipe(name=_RECIPE)) if lr_result.get("success") is False: assume(False) # discard: load_recipe failed - ok_content = await _resolve_recipe_content(ok_result) - lr_content = await _resolve_recipe_content(lr_result) + ok_content = await _resolve_recipe_section(ok_result) + lr_content = await _resolve_recipe_section(lr_result) assert lr_content == ok_content, ( f"Routing divergence for overrides={overrides!r}: " "load_recipe content must match open_kitchen content" diff --git a/tests/server/test_tools_kitchen_cache_poison.py b/tests/server/test_tools_kitchen_cache_poison.py index d0b6b1fc8..0bd5738a1 100644 --- a/tests/server/test_tools_kitchen_cache_poison.py +++ b/tests/server/test_tools_kitchen_cache_poison.py @@ -9,7 +9,7 @@ import pytest -from tests.server._helpers import _resolve_recipe_content +from tests.server._helpers import _resolve_recipe_section pytestmark = [pytest.mark.layer("server"), pytest.mark.anyio, pytest.mark.medium] @@ -47,5 +47,5 @@ async def test_open_kitchen_ingredients_only_does_not_poison_load_recipe( ) lr_result = json.loads(await load_recipe(name="implementation")) - content = await _resolve_recipe_content(lr_result) + content = await _resolve_recipe_section(lr_result) assert content, "load_recipe must provide pullable content after ingredients-only serving" diff --git a/tests/server/test_tools_load_recipe.py b/tests/server/test_tools_load_recipe.py index 3fd740c3a..3952d7ce1 100644 --- a/tests/server/test_tools_load_recipe.py +++ b/tests/server/test_tools_load_recipe.py @@ -13,7 +13,7 @@ from autoskillit.core.types import RetryReason from autoskillit.pipeline.gate import DefaultGateState from autoskillit.server.tools.tools_recipe import load_recipe -from tests.server._helpers import _MINIMAL_SCRIPT_YAML, _resolve_recipe_content +from tests.server._helpers import _MINIMAL_SCRIPT_YAML, _resolve_recipe_section pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -114,7 +114,7 @@ async def test_load_recipe_mcp_returns_builtin_recipe( monkeypatch.chdir(tmp_path) result = json.loads(await load_recipe(name="implementation")) assert "error" not in result, f"Unexpected error: {result.get('error')}" - assert await _resolve_recipe_content(result) + assert await _resolve_recipe_section(result) @pytest.mark.anyio async def test_load_recipe_parse_failure_is_logged_and_surfaced( diff --git a/tests/server/test_tools_recipe_pull.py b/tests/server/test_tools_recipe_pull.py index ca02e6008..81c4e76c2 100644 --- a/tests/server/test_tools_recipe_pull.py +++ b/tests/server/test_tools_recipe_pull.py @@ -6,6 +6,7 @@ import json import shutil from concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock @@ -15,6 +16,7 @@ from autoskillit.config import OutputBudgetConfig from autoskillit.core import ( RECIPE_DELIVERY_SURFACE_REGISTRY, + RECIPE_SECTION_RESPONSE_FLOOR_BYTES, RecipeDeliveryAttestation, RecipeDeliveryEvidenceDef, RecipeDeliveryMode, @@ -28,6 +30,9 @@ RecipeDeliveryReceiptLedger, ) from autoskillit.execution.backends import CodexBackend +from autoskillit.recipe import load_and_validate +from autoskillit.server import _recipe_delivery as recipe_delivery +from autoskillit.server import _recipe_section_pagination as pagination from autoskillit.server._recipe_delivery import ( RECIPE_ARTIFACT_MAX_BLOB_BYTES, RECIPE_BODY_END, @@ -35,7 +40,10 @@ RECIPE_COMPLETION_SENTINEL, RecipeArtifactError, RecipeArtifactGeneration, + RecipeArtifactSchemaError, + _canonical_payload, _generation_dir, + _generation_from_payload, build_recipe_envelope, complete_finalized_recipe_response, finalize_recipe_delivery, @@ -45,7 +53,16 @@ recipe_recreation_producers, retire_recipe_artifacts, ) +from autoskillit.server._recipe_section_pagination import ( + PagePlanCache, + RecipeSectionNonConvergenceError, + RecipeSectionPaginationError, + get_or_build_recipe_section_page_plan, + resolve_recipe_section_bound_bytes, + select_recipe_section, +) from autoskillit.server._response_budget import enforce_response_budget +from autoskillit.server.recipe_section import _lifecycle as recipe_section_lifecycle from autoskillit.server.tools.tools_recipe import get_recipe_section pytestmark = [pytest.mark.layer("server"), pytest.mark.medium] @@ -62,7 +79,10 @@ def _payload( "content": content, "post_prune_step_names": ["first"], "orchestration_rules": "follow the graph", - "ingredients_table": {"task": {"required": True}}, + "stop_step_semantics": "stop means stop", + "ingredients_table": "| Ingredient | Required |\n|---|---|\n| task | yes |", + "errors": [], + "warnings": [], } @@ -71,16 +91,157 @@ def _persist( payload: dict[str, object] | None = None, *, producer: str = "open_kitchen", + kitchen_id: str = "kitchen-test", ) -> RecipeArtifactGeneration: return persist_recipe_artifact( tmp_path, - kitchen_id="kitchen-test", + kitchen_id=kitchen_id, producer_tool=producer, recipe_name="remediation", payload=dict(payload or _payload()), ) +def _write_malformed_generation( + tmp_path: Path, + payload: dict[str, object], + *, + kitchen_id: str = "kitchen-test", + producer: str = "open_kitchen", +) -> RecipeArtifactGeneration: + """Write a digest-consistent artifact while bypassing producer validation.""" + blob = _canonical_payload(payload) + generation = _generation_from_payload( + producer_tool=producer, + recipe_name="remediation", + blob=blob, + payload=payload, + ) + directory = _generation_dir( + tmp_path, + kitchen_id=kitchen_id, + producer_tool=producer, + recipe_name="remediation", + descriptor_version=generation.descriptor_version, + schema_version=generation.schema_version, + payload_sha256=generation.payload_sha256, + ) + directory.mkdir(parents=True) + (directory / "payload.json").write_bytes(blob) + (directory / "descriptor.json").write_bytes(_canonical_payload(generation.pull_identity())) + return generation + + +def test_pull_fixture_matches_load_and_validate_section_shapes(tmp_path: Path) -> None: + recipes_dir = tmp_path / ".autoskillit" / "recipes" + recipes_dir.mkdir(parents=True) + (recipes_dir / "remediation.yaml").write_text( + """\ +name: remediation +description: Fixture recipe +summary: Fixture recipe +kitchen_rules: + - Follow routing rules +ingredients: + task: + description: Work to perform + required: true +steps: + first: + action: stop + message: Done. Emit the L3 result sentinel JSON block now. +""", + encoding="utf-8", + ) + + producer = dict(load_and_validate("remediation", project_dir=tmp_path)) + if producer.get("warnings") is None: + producer["warnings"] = [] + fixture = _payload(str(producer["content"])) + + assert producer["valid"] is True + assert producer["post_prune_step_names"] == fixture["post_prune_step_names"] + for section in ( + "content", + "ingredients_table", + "orchestration_rules", + "stop_step_semantics", + "errors", + "warnings", + ): + assert type(fixture[section]) is type(producer[section]), section + + +@pytest.mark.parametrize( + ("field", "malformed"), + [ + ("content", []), + ("ingredients_table", {"task": {"required": True}}), + ("orchestration_rules", []), + ("stop_step_semantics", 1), + ("errors", ["valid", 1]), + ("warnings", "warning"), + ("post_prune_step_names", ["first", 1]), + ], +) +def test_persistence_rejects_malformed_pullable_sections( + tmp_path: Path, field: str, malformed: object +) -> None: + payload = _payload() + payload[field] = malformed + + with pytest.raises(RecipeArtifactSchemaError): + _persist(tmp_path, payload) + + +def test_persistence_rejects_missing_required_content(tmp_path: Path) -> None: + payload = _payload() + payload.pop("content") + + with pytest.raises(RecipeArtifactSchemaError, match="missing_required_section@content"): + _persist(tmp_path, payload) + + +@pytest.mark.parametrize( + "field", + ["content", "orchestration_rules", "stop_step_semantics", "errors", "warnings"], +) +def test_persistence_rejects_null_for_sections_with_invalid_null_behavior( + tmp_path: Path, + field: str, +) -> None: + payload = _payload() + payload[field] = None + + with pytest.raises(RecipeArtifactSchemaError, match=f"invalid_section_type@{field}"): + _persist(tmp_path, payload) + + +def test_schema_mismatch_diagnostic_bounds_reported_findings(tmp_path: Path) -> None: + payload = _payload() + payload["warnings"] = list(range(130)) + + with pytest.raises(RecipeArtifactSchemaError) as exc_info: + _persist(tmp_path, payload) + + detail = str(exc_info.value) + assert detail.count("invalid_section_element_type@warnings.") == 100 + assert detail.endswith("30 additional findings omitted") + + +def test_load_rejects_digest_consistent_malformed_artifact(tmp_path: Path) -> None: + payload = _payload() + payload["errors"] = ["valid", 1] + generation = _write_malformed_generation(tmp_path, payload) + + with pytest.raises(RecipeArtifactSchemaError): + load_recipe_artifact( + tmp_path, + kitchen_id="kitchen-test", + identity=generation, + ) + + def test_artifact_namespace_encodes_colliding_kitchen_ids_injectively(tmp_path: Path) -> None: first = persist_recipe_artifact( tmp_path, @@ -323,6 +484,124 @@ def test_kitchen_retirement_removes_only_that_namespace(tmp_path: Path) -> None: ) +def test_kitchen_retirement_notifies_callbacks_after_one_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + notified: list[str] = [] + + def failing_callback(_kitchen_id: str) -> None: + raise RuntimeError("cleanup failed") + + def succeeding_callback(kitchen_id: str) -> None: + notified.append(kitchen_id) + + monkeypatch.setattr( + recipe_section_lifecycle, + "_KITCHEN_RETIREMENT_CALLBACKS", + (failing_callback, succeeding_callback), + ) + + recipe_section_lifecycle.notify_kitchen_retired("kitchen-test") + + assert notified == ["kitchen-test"] + + +@pytest.mark.parametrize("kwargs", [{"max_entries": -1}, {"max_bytes": -1}]) +def test_page_plan_cache_rejects_negative_capacity_limits(kwargs: dict[str, int]) -> None: + with pytest.raises(ValueError, match="must not be negative"): + PagePlanCache(**kwargs) + + +def test_kitchen_retirement_evicts_only_matching_page_plans( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cache = PagePlanCache() + monkeypatch.setattr(pagination, "_PAGE_PLAN_CACHE", cache) + generation = _persist(tmp_path) + selected = select_recipe_section(_payload("cached content"), "content") + common = { + "generation": generation, + "selected": selected, + "recipe_section_bound_bytes": 1_000, + } + retired = get_or_build_recipe_section_page_plan( + kitchen_id="kitchen-test", + **common, + ) + retained = get_or_build_recipe_section_page_plan( + kitchen_id="other-kitchen", + **common, + ) + + assert retire_recipe_artifacts(tmp_path, kitchen_id="kitchen-test") is True + + assert get_or_build_recipe_section_page_plan(kitchen_id="other-kitchen", **common) is retained + assert ( + get_or_build_recipe_section_page_plan(kitchen_id="kitchen-test", **common) is not retired + ) + + +def test_cold_kitchen_retirement_does_not_create_page_plan_cache( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(pagination, "_PAGE_PLAN_CACHE", None) + + assert retire_recipe_artifacts(tmp_path, kitchen_id="cold-kitchen") is True + assert pagination._PAGE_PLAN_CACHE is None + + +def test_kitchen_retirement_evicts_after_generation_lock_exits( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + lock_active = False + original_lock = recipe_delivery._generation_lock + + @contextmanager + def _tracked_lock(temp_dir: Path, *, exclusive: bool): + nonlocal lock_active + with original_lock(temp_dir, exclusive=exclusive): + lock_active = True + try: + yield + finally: + lock_active = False + + evicted: list[str] = [] + + def _evict(kitchen_id: str) -> None: + assert lock_active is False + evicted.append(kitchen_id) + + monkeypatch.setattr(recipe_delivery, "_generation_lock", _tracked_lock) + monkeypatch.setattr(pagination, "evict_kitchen", _evict) + + assert retire_recipe_artifacts(tmp_path, kitchen_id="lock-kitchen") is True + assert evicted == ["lock-kitchen"] + + +def test_kitchen_retirement_eviction_is_success_only_and_nonthrowing( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + evict = MagicMock(side_effect=RuntimeError("cache unavailable")) + monkeypatch.setattr(pagination, "evict_kitchen", evict) + + assert retire_recipe_artifacts(tmp_path, kitchen_id="successful-kitchen") is True + evict.assert_called_once_with("successful-kitchen") + + evict.reset_mock() + monkeypatch.setattr( + recipe_delivery, + "atomic_write", + MagicMock(side_effect=OSError("retirement failed")), + ) + assert retire_recipe_artifacts(tmp_path, kitchen_id="failed-kitchen") is False + evict.assert_not_called() + + @pytest.mark.parametrize("kitchen_id", [".", ".."]) def test_kitchen_retirement_rejects_dot_path_components(tmp_path: Path, kitchen_id: str) -> None: sentinel = tmp_path / "unrelated-temp-data" @@ -641,6 +920,364 @@ async def test_pull_tool_reads_exact_generation_and_reports_byte_offsets( assert "".join(chunks) == expected_content +def _assert_section_response_bound(rendered: str, tool_ctx) -> None: + bound = resolve_recipe_section_bound_bytes( + tool_ctx.config.output_budget.response_max_bytes, + CODEX_RECIPE_DELIVERY_BUDGET.ordinary_omitted_result_token_limit, + ) + assert len(rendered.encode("utf-8")) <= bound + + +@pytest.mark.parametrize( + ("failure", "expected_code"), + [ + ( + RecipeSectionNonConvergenceError("forced nonconvergence"), + "recipe_section_pagination_nonconvergent", + ), + ( + RecipeSectionPaginationError("forced invariant failure"), + "recipe_section_internal_error", + ), + (RuntimeError("forced planner failure"), "recipe_section_internal_error"), + ], +) +async def test_pull_tool_maps_planner_failures_to_exact_bounded_codes( + monkeypatch: pytest.MonkeyPatch, + tool_ctx_kitchen_open, + failure: Exception, + expected_code: str, +) -> None: + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = f"planner-failure-{expected_code}" + generation = persist_recipe_artifact( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + producer_tool="open_kitchen", + recipe_name="remediation", + payload=_payload(), + ) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + def _fail_plan(**_kwargs: object) -> None: + raise failure + + monkeypatch.setattr( + "autoskillit.server.tools.tools_recipe.get_or_build_recipe_section_page_plan", + _fail_plan, + ) + + rendered = await get_recipe_section(section="content", **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered) == {"error": expected_code, "success": False} + + +@pytest.mark.parametrize( + ("section", "state", "expected_success", "expected_value"), + [ + ("ingredients_table", "missing", False, None), + ("ingredients_table", "none", False, None), + ("ingredients_table", "empty", True, ""), + ("errors", "missing", True, []), + ("errors", "empty", True, []), + ("warnings", "missing", True, []), + ("warnings", "empty", True, []), + ], +) +async def test_pull_tool_distinguishes_missing_none_and_present_empty_sections( + tool_ctx_kitchen_open, + section: str, + state: str, + expected_success: bool, + expected_value: object, +) -> None: + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = f"pull-empty-{section}-{state}" + payload = _payload() + if state == "missing": + payload.pop(section) + elif state == "none": + payload[section] = None + elif section == "ingredients_table": + payload[section] = "" + else: + payload[section] = [] + generation = persist_recipe_artifact( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + producer_tool="open_kitchen", + recipe_name="remediation", + payload=payload, + ) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section=section, **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + response = json.loads(rendered) + assert response["success"] is expected_success + if expected_success: + assert json.loads(response["content"]) == expected_value + assert response["has_more"] is False + assert "next_part" not in response + else: + assert response["error"] == "section_not_found" + + +async def test_initial_schema_failure_is_bounded_and_never_recreates( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "pull-initial-schema-mismatch" + payload = _payload() + payload["warnings"] = ["valid", 1] + generation = _write_malformed_generation( + tool_ctx_kitchen_open.temp_dir, + payload, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + ) + recreate = MagicMock(side_effect=AssertionError("schema failure must not recreate")) + warning = MagicMock() + monkeypatch.setattr(tools_recipe, "serve_recipe", recreate) + monkeypatch.setattr(tools_recipe.logger, "warning", warning) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="warnings", **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered)["error"] == "recipe_artifact_schema_mismatch" + recreate.assert_not_called() + warning.assert_called_once_with( + "get_recipe_section_schema_mismatch", + stage="load", + detail=( + "recipe artifact section schema mismatch: invalid_section_element_type@warnings.1" + ), + ) + + +async def test_recreation_persistence_schema_failure_precedes_artifact_error( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "pull-recreate-schema-write" + generation = _persist( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + ) + _remove_persisted_namespace( + tool_ctx_kitchen_open.temp_dir, + kitchen_id=tool_ctx_kitchen_open.kitchen_id, + ) + malformed = _payload() + malformed["warnings"] = ["valid", 1] + monkeypatch.setattr(tools_recipe, "serve_recipe", lambda *_args, **_kwargs: malformed) + monkeypatch.setattr( + tools_recipe, + "build_open_kitchen_recipe_payload", + lambda data, *, version: data, + ) + monkeypatch.setattr( + tools_recipe, + "persist_recipe_artifact", + MagicMock(side_effect=RecipeArtifactSchemaError("malformed recreation")), + ) + warning = MagicMock() + monkeypatch.setattr(tools_recipe.logger, "warning", warning) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="warnings", **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered)["error"] == "recipe_artifact_schema_mismatch" + warning.assert_called_once_with( + "get_recipe_section_schema_mismatch", + stage="recreate_persist", + detail="malformed recreation", + ) + + +async def test_post_recreation_reload_schema_failure_precedes_reload_error( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "pull-recreate-schema-reload" + generation = _persist(tool_ctx_kitchen_open.temp_dir) + monkeypatch.setattr(tools_recipe, "serve_recipe", lambda *_args, **_kwargs: _payload()) + monkeypatch.setattr( + tools_recipe, + "build_open_kitchen_recipe_payload", + lambda data, *, version: data, + ) + monkeypatch.setattr( + tools_recipe, + "persist_recipe_artifact", + lambda *_args, **_kwargs: generation, + ) + monkeypatch.setattr( + tools_recipe, + "load_recipe_artifact", + MagicMock( + side_effect=[ + RecipeArtifactError("artifact missing"), + RecipeArtifactSchemaError("malformed recreation"), + ] + ), + ) + warning = MagicMock() + monkeypatch.setattr(tools_recipe.logger, "warning", warning) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="warnings", **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered)["error"] == "recipe_artifact_schema_mismatch" + warning.assert_called_once_with( + "get_recipe_section_schema_mismatch", + stage="reload", + detail="malformed recreation", + ) + + +async def test_post_recreation_reload_artifact_failure_logs_exception_context( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "pull-recreate-artifact-reload" + generation = _persist(tool_ctx_kitchen_open.temp_dir) + monkeypatch.setattr(tools_recipe, "serve_recipe", lambda *_args, **_kwargs: _payload()) + monkeypatch.setattr( + tools_recipe, + "build_open_kitchen_recipe_payload", + lambda data, *, version: data, + ) + monkeypatch.setattr( + tools_recipe, + "persist_recipe_artifact", + lambda *_args, **_kwargs: generation, + ) + monkeypatch.setattr( + tools_recipe, + "load_recipe_artifact", + MagicMock( + side_effect=[ + RecipeArtifactError("artifact missing"), + RecipeArtifactError("checksum mismatch"), + ] + ), + ) + warning = MagicMock() + monkeypatch.setattr(tools_recipe.logger, "warning", warning) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="warnings", **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered) == { + "success": False, + "error": "recipe_artifact_unavailable", + "detail": "post-recreation reload failed", + } + warning.assert_called_once_with( + "get_recipe_section_artifact_unavailable", + stage="reload", + detail="checksum mismatch", + exc_info=True, + ) + + +async def test_negative_part_is_rejected_before_artifact_load( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + import autoskillit.server.tools.tools_recipe as tools_recipe + + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + generation = _persist(tool_ctx_kitchen_open.temp_dir) + artifact_load = MagicMock(side_effect=AssertionError("negative part reached artifact load")) + monkeypatch.setattr(tools_recipe, "load_recipe_artifact", artifact_load) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="content", part=-1, **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered)["error"] == "invalid_recipe_section_part" + artifact_load.assert_not_called() + + +async def test_oversized_part_is_rejected_after_page_planning( + tool_ctx_kitchen_open, +) -> None: + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + generation = _persist(tool_ctx_kitchen_open.temp_dir) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="content", part=10_000, **kwargs) + + _assert_section_response_bound(rendered, tool_ctx_kitchen_open) + assert json.loads(rendered)["error"] == "invalid_recipe_section_part" + + +async def test_request_specific_floor_returns_exact_bounded_failure( + tool_ctx_kitchen_open, monkeypatch: pytest.MonkeyPatch +) -> None: + tool_ctx_kitchen_open.backend = CodexBackend() + tool_ctx_kitchen_open.kitchen_id = "kitchen-test" + monkeypatch.setattr( + tool_ctx_kitchen_open, + "config", + replace( + tool_ctx_kitchen_open.config, + output_budget=OutputBudgetConfig( + response_max_bytes=RECIPE_SECTION_RESPONSE_FLOOR_BYTES + ), + ), + ) + generation = _persist(tool_ctx_kitchen_open.temp_dir) + kwargs = generation.pull_identity() + kwargs.pop("pull_tool") + + rendered = await get_recipe_section(section="content", **kwargs) + + assert len(rendered.encode("utf-8")) <= RECIPE_SECTION_RESPONSE_FLOOR_BYTES + assert json.loads(rendered) == { + "success": False, + "error": "recipe_section_bound_too_small", + } + + +@pytest.mark.parametrize( + ("response_max_bytes", "conservative_limit"), + [(20_000, 10_000), (8_000, 10_000), (10_000, 10_000)], +) +def test_recipe_section_bound_resolver_keeps_conservative_policy( + response_max_bytes: int, + conservative_limit: int, +) -> None: + assert resolve_recipe_section_bound_bytes( + response_max_bytes, + conservative_limit, + ) == min(response_max_bytes, conservative_limit) + + async def test_pull_tool_returns_named_step_and_rejects_unknown_section( tool_ctx_kitchen_open, ) -> None: diff --git a/tests/server/test_track_response_size.py b/tests/server/test_track_response_size.py index b75f5aa09..a79b2d1cb 100644 --- a/tests/server/test_track_response_size.py +++ b/tests/server/test_track_response_size.py @@ -9,6 +9,7 @@ import structlog from autoskillit.config import OutputBudgetConfig +from autoskillit.core import RECIPE_SECTION_RESPONSE_FLOOR_BYTES from autoskillit.pipeline.mcp_response import DefaultMcpResponseLog pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -239,11 +240,12 @@ async def test_unexpected_enforcement_failure_is_bounded_and_centrally_emitted(s from autoskillit.server._notify import track_response_size tool_name = "/private/tool/" + "x" * 200 + response_bound = max(200, RECIPE_SECTION_RESPONSE_FLOOR_BYTES) ctx = MagicMock( response_log=MagicMock(record=MagicMock(return_value=False)), config=MagicMock( mcp_response=MagicMock(alert_threshold_tokens=0), - output_budget=OutputBudgetConfig(response_max_bytes=200), + output_budget=OutputBudgetConfig(response_max_bytes=response_bound), ), ) @@ -261,7 +263,7 @@ async def fake_handler(): ): result = await fake_handler() - assert len(result.encode("utf-8")) <= 200 + assert len(result.encode("utf-8")) <= response_bound assert "/private/project" not in result assert "/private/project" not in repr(log_info.call_args_list) event = next( diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index ebc8ec939..90a43260c 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -99,6 +99,7 @@ def test_all_entries_present(self) -> None: "_type_results_execution", "_type_backend", "_type_recipe_delivery", + "_type_recipe_sections", "_type_dispatch_identity", "_type_figure_spec", "_type_session_env",