From 014333e5be604d28f966b228e2fad1b753823518 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 15 Jul 2026 17:06:49 -0700 Subject: [PATCH 01/30] feat: enforce lossless output response budgets --- AGENTS.md | 2 +- src/autoskillit/config/AGENTS.md | 4 +- src/autoskillit/config/__init__.py | 2 + src/autoskillit/config/_config_dataclasses.py | 18 + src/autoskillit/config/defaults.yaml | 9 + src/autoskillit/config/settings.py | 3 + src/autoskillit/core/__init__.pyi | 3 + src/autoskillit/core/io.py | 51 +++ src/autoskillit/core/types/_type_results.py | 43 +++ .../execution/headless/_headless_result.py | 10 +- src/autoskillit/hooks/formatters/AGENTS.md | 2 +- .../hooks/formatters/_fmt_dispatch.py | 10 +- .../hooks/formatters/_fmt_execution.py | 48 ++- .../hooks/formatters/_fmt_primitives.py | 62 ++++ .../hooks/formatters/pretty_output_hook.py | 72 ++-- src/autoskillit/server/AGENTS.md | 1 + src/autoskillit/server/_notify.py | 92 ++++-- src/autoskillit/server/_response_budget.py | 310 ++++++++++++++++++ src/autoskillit/server/git.py | 49 ++- .../server/tools/_execution_helpers.py | 86 ++++- src/autoskillit/server/tools/_types.py | 9 +- .../server/tools/tools_execution.py | 51 +-- .../server/tools/tools_workspace.py | 36 +- tests/core/AGENTS.md | 1 + tests/core/test_io_spill.py | 59 ++++ tests/docs/test_claude_md_structure.py | 4 +- tests/execution/test_headless_core.py | 7 +- tests/server/AGENTS.md | 3 + tests/server/test_response_backstop.py | 105 ++++++ tests/server/test_tools_execution_spill.py | 69 ++++ tests/server/test_tools_git.py | 13 +- tests/server/test_tools_workspace_spill.py | 33 ++ 32 files changed, 1109 insertions(+), 158 deletions(-) create mode 100644 src/autoskillit/server/_response_budget.py create mode 100644 tests/core/test_io_spill.py create mode 100644 tests/server/test_response_backstop.py create mode 100644 tests/server/test_tools_execution_spill.py create mode 100644 tests/server/test_tools_workspace_spill.py diff --git a/AGENTS.md b/AGENTS.md index f75e6a100c..11b5405c1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,7 +101,7 @@ generic_automation_mcp/ | `./` | — | Package root: `__init__`, `__main__`, `hook_registry`, `version`, `_test_filter`, `_llm_triage` | | `smoke_utils/` | — | Callables for smoke-test pipeline `run_python` steps | | `core/` | IL-0 | Foundation — types/, runtime/, paths, IO, feature flags (zero autoskillit imports) | -| `config/` | IL-1 | `AutomationConfig` + Dynaconf loader + 28 leaf dataclasses | +| `config/` | IL-1 | `AutomationConfig` + Dynaconf loader + 29 leaf dataclasses | | `pipeline/` | IL-1 | Pipeline state — `ToolContext` DI, gate, audit log, telemetry | | `execution/` | IL-1 | Headless sessions (headless/, process/, merge_queue/, session/), backends/, CI/GitHub | | `workspace/` | IL-1 | Clone management, worktrees, skill resolution | diff --git a/src/autoskillit/config/AGENTS.md b/src/autoskillit/config/AGENTS.md index 9ec79dc2ce..1f6fcd802e 100644 --- a/src/autoskillit/config/AGENTS.md +++ b/src/autoskillit/config/AGENTS.md @@ -9,12 +9,12 @@ IL-1 configuration layer — `AutomationConfig`, Dynaconf loader, schema validat | `__init__.py` | Re-exports `AutomationConfig`, `load_config`, `ConfigSchemaError` | | `ingredient_defaults.py` | Per-recipe ingredient default resolution | | `settings.py` | `AutomationConfig` + schema validate/write API | -| `_config_dataclasses.py` | 28 leaf dataclasses + `ProviderProfileDef` + `ConfigSchemaError` | +| `_config_dataclasses.py` | 29 leaf dataclasses + `ProviderProfileDef` + `ConfigSchemaError` | | `_config_loader.py` | `_make_dynaconf` + `load_config` layer helpers | ## Architecture Notes -`_config_dataclasses.py` defines the 27 leaf config dataclasses that form the schema tree +`_config_dataclasses.py` defines the 29 leaf config dataclasses that form the schema tree rooted at `AutomationConfig`, plus the `ProviderProfileDef` frozen definition type. `defaults.yaml` (non-Python) is the Dynaconf default values file read at startup. `ingredient_defaults.py` bridges recipe-level ingredient declarations to config-layer defaults without importing from `recipe/`. diff --git a/src/autoskillit/config/__init__.py b/src/autoskillit/config/__init__.py index 2d61723e0a..cdb8850b76 100644 --- a/src/autoskillit/config/__init__.py +++ b/src/autoskillit/config/__init__.py @@ -38,6 +38,7 @@ LoggingConfig, McpResponseConfig, MigrationConfig, + OutputBudgetConfig, PacksConfig, PlanConfig, ProviderProfileDef, @@ -78,6 +79,7 @@ "LoggingConfig", "McpResponseConfig", "MigrationConfig", + "OutputBudgetConfig", "PacksConfig", "ProviderProfileDef", "ProvidersConfig", diff --git a/src/autoskillit/config/_config_dataclasses.py b/src/autoskillit/config/_config_dataclasses.py index 69e97984f5..2a96a8ca80 100644 --- a/src/autoskillit/config/_config_dataclasses.py +++ b/src/autoskillit/config/_config_dataclasses.py @@ -341,6 +341,24 @@ class McpResponseConfig: alert_threshold_tokens: int = 2000 +@dataclass +class OutputBudgetConfig: + """Model-context output limits. + + ``*_chars`` values bound human-readable previews. ``response_max_bytes`` + bounds the compact serialized handler payload; it is neither a tokenizer + estimate nor a JSON-RPC envelope limit. + """ + + inline_max_chars: int = 5000 + head_chars: int = 2500 + tail_chars: int = 2500 + response_max_bytes: int = 90_000 + guard_enabled: bool = True + small_file_max_bytes: int = 5000 + shell_max_inline_bytes: int = 12_000 + + @dataclass class BranchingConfig: default_base_branch: str = "main" diff --git a/src/autoskillit/config/defaults.yaml b/src/autoskillit/config/defaults.yaml index d4c036b845..33f6cb83fe 100644 --- a/src/autoskillit/config/defaults.yaml +++ b/src/autoskillit/config/defaults.yaml @@ -130,6 +130,15 @@ linux_tracing: mcp_response: alert_threshold_tokens: 2000 +output_budget: + inline_max_chars: 5000 + head_chars: 2500 + tail_chars: 2500 + response_max_bytes: 90000 + guard_enabled: true + small_file_max_bytes: 5000 + shell_max_inline_bytes: 12000 + branching: default_base_branch: main promotion_target: main # Canonical "done" branch for staged-label comparison. diff --git a/src/autoskillit/config/settings.py b/src/autoskillit/config/settings.py index 2aae1d7aef..fc216d73db 100644 --- a/src/autoskillit/config/settings.py +++ b/src/autoskillit/config/settings.py @@ -44,6 +44,7 @@ LoggingConfig, McpResponseConfig, MigrationConfig, + OutputBudgetConfig, PacksConfig, PlanConfig, ProviderProfileDef, @@ -178,6 +179,7 @@ def _codex_mcp_timeout_coherence_gate( "LoggingConfig", "McpResponseConfig", "MigrationConfig", + "OutputBudgetConfig", "PacksConfig", "ProviderProfileDef", "ProvidersConfig", @@ -373,6 +375,7 @@ class AutomationConfig: diagnostics: DiagnosticsConfig = field(default_factory=DiagnosticsConfig) linux_tracing: LinuxTracingConfig = field(default_factory=LinuxTracingConfig) mcp_response: McpResponseConfig = field(default_factory=McpResponseConfig) + output_budget: OutputBudgetConfig = field(default_factory=OutputBudgetConfig) branching: BranchingConfig = field(default_factory=BranchingConfig) ci: CIConfig = field(default_factory=CIConfig) review: ReviewConfig = field(default_factory=ReviewConfig) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index e020ec00aa..23974f7029 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -60,6 +60,7 @@ from .io import read_versioned_json as read_versioned_json from .io import resolve_skill_temp_dir as resolve_skill_temp_dir from .io import resolve_temp_dir as resolve_temp_dir from .io import safe_upsert_section as safe_upsert_section +from .io import spill_output as spill_output from .io import temp_dir_display_str as temp_dir_display_str from .io import write_versioned_json as write_versioned_json from .logging import configure_logging as configure_logging @@ -354,6 +355,8 @@ from .types import SkillResolver as SkillResolver from .types import SkillResult as SkillResult from .types import SkillSessionConfig as SkillSessionConfig from .types import SkillSource as SkillSource +from .types import SpilledOutput as SpilledOutput +from .types import SpillSpec as SpillSpec from .types import StreamParser as StreamParser from .types import SubprocessResult as SubprocessResult from .types import SubprocessRunner as SubprocessRunner diff --git a/src/autoskillit/core/io.py b/src/autoskillit/core/io.py index faade8db5a..755f95df45 100644 --- a/src/autoskillit/core/io.py +++ b/src/autoskillit/core/io.py @@ -10,9 +10,12 @@ from __future__ import annotations +import hashlib import os +import re import tempfile import threading +import uuid from dataclasses import dataclass from pathlib import Path from typing import Any, Protocol @@ -22,6 +25,7 @@ from ._json import fast_dumps as _fast_dumps from .types._type_helpers import extract_skill_name +from .types._type_results import SpilledOutput, SpillSpec try: from yaml import CSafeLoader as _Loader @@ -44,6 +48,7 @@ "resolve_skill_temp_dir", "resolve_temp_dir", "safe_upsert_section", + "spill_output", "temp_dir_display_str", "write_versioned_json", ] @@ -170,6 +175,52 @@ def atomic_write(path: Path, content: str) -> None: pass # Non-fatal — data is durable at path after os.replace() +def spill_output( + text: str, + artifact_dir: Path, + name_hint: str, + spec: SpillSpec, +) -> SpilledOutput: + """Persist oversized text losslessly and return a bounded inline preview. + + The final artifact path is exposed only after ``atomic_write`` has published + the complete UTF-8 payload. Small values remain byte-for-byte inline and do + not create an artifact. + """ + encoded = text.encode("utf-8") + total_lines = len(text.splitlines()) + if len(text) <= spec.inline_max_chars: + return SpilledOutput( + spilled=False, + text=text, + artifact_path=None, + total_chars=len(text), + total_utf8_bytes=len(encoded), + total_lines=total_lines, + ) + + safe_hint = re.sub(r"[^A-Za-z0-9._-]+", "_", name_hint).strip("._-") or "output" + final_path = artifact_dir / f"{safe_hint}_{uuid.uuid4().hex[:8]}.log" + atomic_write(final_path, text) + published_path = str(final_path.resolve()) + head = text[: spec.head_chars] + tail = text[-spec.tail_chars :] if spec.tail_chars else "" + marker = f"[spilled {len(text)} chars -> {published_path}]" + preview_parts = [head, marker, tail] + preview = "\n".join(part for part in preview_parts if part) + return SpilledOutput( + spilled=True, + text=preview, + artifact_path=published_path, + head=head, + tail=tail, + sha256=hashlib.sha256(encoded).hexdigest(), + total_chars=len(text), + total_utf8_bytes=len(encoded), + total_lines=total_lines, + ) + + def safe_upsert_section( path: Path, section_header: str, diff --git a/src/autoskillit/core/types/_type_results.py b/src/autoskillit/core/types/_type_results.py index 2f4984dcc0..b7125273b3 100644 --- a/src/autoskillit/core/types/_type_results.py +++ b/src/autoskillit/core/types/_type_results.py @@ -30,6 +30,8 @@ "LoadReport", "LoadResult", "ModelIdentity", + "SpilledOutput", + "SpillSpec", "TestResult", "ValidatedAddDir", "ValidatedWorktreePath", @@ -161,6 +163,47 @@ class WriteBehaviorSpec: expected_when: tuple[str, ...] = () +@dataclass(frozen=True, slots=True) +class SpillSpec: + """Character budgets for lossless artifact-backed output previews.""" + + inline_max_chars: int = 5000 + head_chars: int = 2500 + tail_chars: int = 2500 + + def __post_init__(self) -> None: + if self.inline_max_chars < 0 or self.head_chars < 0 or self.tail_chars < 0: + raise ValueError("spill character budgets must be non-negative") + + +@dataclass(frozen=True, slots=True) +class SpilledOutput: + """Lossless spill result with a bounded inline representation.""" + + spilled: bool + text: str + artifact_path: str | None + head: str = "" + tail: str = "" + sha256: str = "" + total_chars: int = 0 + total_utf8_bytes: int = 0 + total_lines: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "spilled": self.spilled, + "text": self.text, + "artifact_path": self.artifact_path, + "head": self.head, + "tail": self.tail, + "sha256": self.sha256, + "total_chars": self.total_chars, + "total_utf8_bytes": self.total_utf8_bytes, + "total_lines": self.total_lines, + } + + @dataclass(frozen=True, slots=True) class ClosureAuthoritySpec: """Caller-provided, hash-pinned authority file for closure-mode verification. diff --git a/src/autoskillit/execution/headless/_headless_result.py b/src/autoskillit/execution/headless/_headless_result.py index b9c1f54e9c..3a235cf64a 100644 --- a/src/autoskillit/execution/headless/_headless_result.py +++ b/src/autoskillit/execution/headless/_headless_result.py @@ -26,7 +26,6 @@ WriteEvidence, extract_skill_name, get_logger, - truncate_text, validate_worktree_path, ) from autoskillit.execution.headless._headless_evidence import ( @@ -71,7 +70,6 @@ from autoskillit.core import AuditLog, CodingAgentBackend, SubprocessResult logger = get_logger(__name__) -_truncate = truncate_text __all__ = [ "_build_skill_result", @@ -243,7 +241,7 @@ def _build_skill_result( result=result, session=stale_session, success=True, - result_text=_truncate(stale_session.agent_result), + result_text=stale_session.agent_result, subtype="recovered_from_stale", needs_retry=False, retry_reason=RetryReason.NONE, @@ -350,7 +348,7 @@ def _build_skill_result( result=result, session=idle_session, success=True, - result_text=_truncate(idle_session.agent_result), + result_text=idle_session.agent_result, subtype="recovered_from_idle_stall", needs_retry=False, retry_reason=RetryReason.NONE, @@ -698,7 +696,7 @@ def _build_skill_result( audit=audit, ) - result_text = _truncate(session.agent_result) + result_text = session.agent_result if completion_marker: result_text = result_text.replace(completion_marker, "").strip() @@ -790,7 +788,7 @@ def _build_skill_result( exit_code=returncode, needs_retry=needs_retry, retry_reason=retry_reason, - stderr=_truncate(result.stderr), + stderr=result.stderr, token_usage=session.token_usage, worktree_path=effective_worktree_path, branch_name=extracted_branch_name, diff --git a/src/autoskillit/hooks/formatters/AGENTS.md b/src/autoskillit/hooks/formatters/AGENTS.md index eb6b576cc6..c389ea6b35 100644 --- a/src/autoskillit/hooks/formatters/AGENTS.md +++ b/src/autoskillit/hooks/formatters/AGENTS.md @@ -10,7 +10,7 @@ PostToolUse output formatters — MCP JSON to Markdown-KV reformatter (30-77% to | `pretty_output_hook.py` | Dispatch entrypoint: intercepts MCP tool responses, routes to per-tool formatters | | `_fmt_primitives.py` | Shared primitives: `_CHECK_MARK`, `_CROSS_MARK`, payload dataclasses, token formatter | | `_fmt_execution.py` | Formatters for `run_skill`, `run_cmd`, `test_check`, `merge_worktree` | -| `_fmt_dispatch.py` | Formatter for `dispatch_food_truck` (nested structured data, must not be size-truncated) | +| `_fmt_dispatch.py` | Formatter for `dispatch_food_truck` artifact-backed nested data | | `_fmt_recipe.py` | Formatters for `load_recipe`, `open_kitchen`, `list_recipes` | | `_fmt_recipe_compact.py` | Deterministic, semantics-preserving recipe display compaction (field stripping, indentation halving, orchestration-rules message dedup) | | `_fmt_status.py` | Formatters for `get_token_summary`, `get_timing_summary`, `kitchen_status` | diff --git a/src/autoskillit/hooks/formatters/_fmt_dispatch.py b/src/autoskillit/hooks/formatters/_fmt_dispatch.py index 1c0d8bcfe2..11e00803bf 100644 --- a/src/autoskillit/hooks/formatters/_fmt_dispatch.py +++ b/src/autoskillit/hooks/formatters/_fmt_dispatch.py @@ -3,9 +3,8 @@ Hosts the per-tool formatter for ``dispatch_food_truck``. Stdlib-only at runtime. This module is split from ``_fmt_execution.py`` to keep the file size budget -(REQ-FILE-001) below the 329-line ceiling. The dispatch envelope contains -nested structured data (l3_payload with dispatch_plan, health_report, -resume_checkpoint) that must NOT be size-truncated. +(REQ-FILE-001) below the 329-line ceiling. Artifact-backed response projections +retain a bounded nested view while the central formatter exposes the full path. """ from __future__ import annotations @@ -24,9 +23,8 @@ def _fmt_dispatch_food_truck(data: dict, _pipeline: bool) -> str: Renders both success (DispatchCompleted) and error (DispatchRejected / fleet_error) response shapes — discriminates on ``data.get("kind")``. - Nested structured fields (l3_payload, health_report, token_usage, - resume_checkpoint) are rendered in full without size-based truncation - to preserve dispatch_plan visibility for downstream orchestrators. + Nested structured fields are rendered from the inline projection. The + central spill notice exposes the full response artifact when present. """ success = data.get("success", False) mark = _CHECK_MARK if success else _CROSS_MARK diff --git a/src/autoskillit/hooks/formatters/_fmt_execution.py b/src/autoskillit/hooks/formatters/_fmt_execution.py index fdee031c87..5509164aae 100644 --- a/src/autoskillit/hooks/formatters/_fmt_execution.py +++ b/src/autoskillit/hooks/formatters/_fmt_execution.py @@ -5,6 +5,7 @@ from _fmt_primitives import ( # type: ignore[import-not-found] _CHECK_MARK, _CROSS_MARK, + _filter_pytest_output, _fmt_tokens, ) @@ -122,9 +123,13 @@ def _fmt_run_cmd(data: dict, pipeline: bool) -> str: stdout = (data.get("stdout") or "").strip() if stdout: lines.extend(["", "### stdout", stdout]) + if data.get("stdout_artifact_path"): + lines.append(f"stdout_artifact_path: {data['stdout_artifact_path']}") stderr = (data.get("stderr") or "").strip() if stderr: lines.extend(["", "### stderr", stderr]) + if data.get("stderr_artifact_path"): + lines.append(f"stderr_artifact_path: {data['stderr_artifact_path']}") return "\n".join(lines) lines = [ @@ -136,36 +141,16 @@ def _fmt_run_cmd(data: dict, pipeline: bool) -> str: stdout = (data.get("stdout") or "").strip() if stdout: lines.extend(["", "### stdout", stdout]) + if data.get("stdout_artifact_path"): + lines.append(f"stdout_artifact_path: {data['stdout_artifact_path']}") stderr = (data.get("stderr") or "").strip() if stderr: lines.extend(["", "### stderr", stderr]) + if data.get("stderr_artifact_path"): + lines.append(f"stderr_artifact_path: {data['stderr_artifact_path']}") return "\n".join(lines) -def _filter_pytest_output(raw: str) -> str: - """Filter pytest boilerplate, keeping only failure-relevant lines.""" - boilerplate_prefixes = ( - "platform ", - "rootdir:", - "configfile:", - "plugins:", - "collecting ", - "collected ", - "cacheprovider", - ) - boilerplate_exact = {"", " "} - lines = raw.splitlines() - filtered = [] - for line in lines: - stripped = line.strip() - if stripped in boilerplate_exact: - continue - if any(stripped.startswith(p) for p in boilerplate_prefixes): - continue - filtered.append(line) - return "\n".join(filtered) - - def _fmt_test_check(data: dict, _pipeline: bool) -> str: """Format test_check result as Markdown-KV.""" passed = data.get("passed", False) @@ -200,6 +185,8 @@ def _fmt_test_check(data: dict, _pipeline: bool) -> str: error = data.get("error", "") if error: lines.extend(["", f"error: {error}"]) + if data.get("raw_output_artifact_path"): + lines.append(f"raw_output_artifact_path: {data['raw_output_artifact_path']}") return "\n".join(lines) @@ -247,7 +234,16 @@ def _fmt_test_check(data: dict, _pipeline: bool) -> str: } ) -_FMT_RUN_CMD_RENDERED: frozenset[str] = frozenset({"success", "exit_code", "stdout", "stderr"}) +_FMT_RUN_CMD_RENDERED: frozenset[str] = frozenset( + { + "success", + "exit_code", + "stdout", + "stderr", + "stdout_artifact_path", + "stderr_artifact_path", + } +) _FMT_RUN_CMD_SUPPRESSED: frozenset[str] = frozenset({"error"}) _FMT_TEST_CHECK_RENDERED: frozenset[str] = frozenset( @@ -262,6 +258,7 @@ def _fmt_test_check(data: dict, _pipeline: bool) -> str: "stderr", "error", "infrastructure_missing", + "raw_output_artifact_path", } ) _FMT_TEST_CHECK_SUPPRESSED: frozenset[str] = frozenset() @@ -324,6 +321,7 @@ def _fmt_merge_worktree(data: dict, _pipeline: bool) -> str: "local_sha", "remote_sha", "remote_is_ancestor", + "raw_output_artifact_path", } ) _FMT_MERGE_WORKTREE_SUPPRESSED: frozenset[str] = frozenset() diff --git a/src/autoskillit/hooks/formatters/_fmt_primitives.py b/src/autoskillit/hooks/formatters/_fmt_primitives.py index ef0867dcd9..d3805e22f7 100644 --- a/src/autoskillit/hooks/formatters/_fmt_primitives.py +++ b/src/autoskillit/hooks/formatters/_fmt_primitives.py @@ -7,6 +7,7 @@ from __future__ import annotations +import json from dataclasses import dataclass from pathlib import Path from typing import Any @@ -58,3 +59,64 @@ def _extract_tool_short_name(tool_name: str) -> str: Falls back to the full tool_name if no __ separator found. """ return tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name + + +def _filter_pytest_output(raw: str) -> str: + """Filter pytest boilerplate, keeping only failure-relevant lines.""" + prefixes = ( + "platform ", + "rootdir:", + "configfile:", + "plugins:", + "collecting ", + "collected ", + "cacheprovider", + ) + return "\n".join( + line + for line in raw.splitlines() + if line.strip() and not any(line.strip().startswith(prefix) for prefix in prefixes) + ) + + +def _fmt_generic(short_name: str, data: dict, _pipeline: bool) -> str: + """Format tools without a dedicated response renderer.""" + lines = [f"## {short_name}", ""] + for key, val in data.items(): + if isinstance(val, list): + val = list(val) + if not val: + lines.append(f"{key}: []") + elif all(isinstance(item, str) for item in val): + lines.append(f"{key}:") + lines.extend(f" - {item}" for item in val[:20]) + else: + lines.append(f"{key}:") + for item in val[:20]: + if isinstance(item, dict): + kvs = [ + f"{k}: {(s := str(v))[:120] + ('...' if len(s) > 120 else '')}" + for k, v in item.items() + ] + lines.append(f" - {', '.join(kvs)}") + else: + compact = json.dumps(item, separators=(",", ":")) + rendered = compact[:2000] + "..." if len(compact) > 2000 else compact + lines.append(f" - {rendered}") + if len(val) > 20: + lines.append(f" ... and {len(val) - 20} more") + elif isinstance(val, dict): + if not val: + lines.append(f"{key}: {{}}") + else: + lines.append(f"{key}:") + for nested_key, nested_value in val.items(): + if isinstance(nested_value, (dict, list)): + compact = json.dumps(nested_value, separators=(",", ":")) + rendered = compact[:2000] + "..." if len(compact) > 2000 else compact + lines.append(f" {nested_key}: {rendered}") + else: + lines.append(f" {nested_key}: {nested_value}") + else: + lines.append(f"{key}: {val}") + return "\n".join(lines) diff --git a/src/autoskillit/hooks/formatters/pretty_output_hook.py b/src/autoskillit/hooks/formatters/pretty_output_hook.py index 8bd602564f..9d7d6c639c 100644 --- a/src/autoskillit/hooks/formatters/pretty_output_hook.py +++ b/src/autoskillit/hooks/formatters/pretty_output_hook.py @@ -64,6 +64,7 @@ _HOOK_CONFIG_PATH_COMPONENTS, _DictPayload, _extract_tool_short_name, + _fmt_generic, _is_pipeline_mode, _Payload, _PlainTextPayload, @@ -118,48 +119,13 @@ def _fmt_gate_error(data: dict, _pipeline: bool) -> str: return "\n".join(lines) -_GENERIC_NESTED_VALUE_MAX_CHARS = 2000 - - -def _fmt_generic(short_name: str, data: dict, _pipeline: bool) -> str: - lines = [f"## {short_name}", ""] - for key, val in data.items(): - if isinstance(val, list): - val = list(val) - if not val: - lines.append(f"{key}: []") - elif all(isinstance(item, str) for item in val): - lines.append(f"{key}:") - for item in val[:20]: - lines.append(f" - {item}") - else: - lines.append(f"{key}:") - for item in val[:20]: - if isinstance(item, dict): - kvs = [ - f"{k}: {(s := str(v))[:120] + ('...' if len(s) > 120 else '')}" - for k, v in item.items() - ] - lines.append(f" - {', '.join(kvs)}") - else: - c = json.dumps(item, separators=(",", ":")) - lines.append(f" - {c[:2000] + '...' if len(c) > 2000 else c}") - if len(val) > 20: - lines.append(f" ... and {len(val) - 20} more") - elif isinstance(val, dict): - if not val: - lines.append(f"{key}: {{}}") - else: - lines.append(f"{key}:") - for k, v in val.items(): - if isinstance(v, (dict, list)): - c = json.dumps(v, separators=(",", ":")) - lines.append(f" {k}: {c[:2000] + '...' if len(c) > 2000 else c}") - else: - lines.append(f" {k}: {v}") - else: - lines.append(f"{key}: {val}") - return "\n".join(lines) +_RESPONSE_SPILL_METADATA_KEY = "_autoskillit_response_spill" + + +def _response_spill_notice(metadata: dict) -> str: + path = metadata.get("artifact_path", "unavailable") + original_bytes = metadata.get("original_utf8_bytes", "unknown") + return f"response_spilled: {original_bytes} bytes\nartifact_path: {path}" # Dispatch table: short tool name → formatter function @@ -295,21 +261,31 @@ def _format_response(tool_name: str, tool_response: str, pipeline: bool) -> str return handler(payload.text, pipeline) if handler is not None else payload.text # DictPayload path — envelope was successfully unwrapped (or was never an envelope). - data = payload.data + data = dict(payload.data) + spill_metadata = data.pop(_RESPONSE_SPILL_METADATA_KEY, None) + + def with_spill(formatted: str) -> str: + if not isinstance(spill_metadata, dict): + return formatted + notice = _response_spill_notice(spill_metadata) + return f"{notice}\n\n{formatted}" if formatted else notice + + if isinstance(spill_metadata, dict) and set(data) == {"preview"}: + return with_spill(str(data["preview"])) if data.get("subtype") == "gate_error": - return _fmt_gate_error(data, pipeline) + return with_spill(_fmt_gate_error(data, pipeline)) if data.get("subtype") == "tool_exception": - return _fmt_tool_exception(data, pipeline) + return with_spill(_fmt_tool_exception(data, pipeline)) if short_name in _UNFORMATTED_TOOLS: - return _fmt_generic(short_name, data, pipeline) + return with_spill(_fmt_generic(short_name, data, pipeline)) formatter = _FORMATTERS.get(short_name) if formatter is not None: - return formatter(data, pipeline) + return with_spill(formatter(data, pipeline)) - return _fmt_generic(short_name, data, pipeline) + return with_spill(_fmt_generic(short_name, data, pipeline)) def main() -> None: diff --git a/src/autoskillit/server/AGENTS.md b/src/autoskillit/server/AGENTS.md index d77e8fd5f7..302ca8f354 100644 --- a/src/autoskillit/server/AGENTS.md +++ b/src/autoskillit/server/AGENTS.md @@ -14,6 +14,7 @@ Sub-package: tools/ (see tools/AGENTS.md). | `_lifespan.py` | FastMCP lifespan context manager — deferred startup (recovery, audit loading, stale cleanup, drift check) | | `_misc.py` | Quota, hook-config, triage, and miscellaneous server utilities; re-exports selected execution/workspace symbols for tools | | `_notify.py` | MCP notification dispatch and response-size tracking | +| `_response_budget.py` | Lossless response spill and shape-preserving output-budget enforcement | | `_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/_notify.py b/src/autoskillit/server/_notify.py index a3973a26ad..92f8a5ccc9 100644 --- a/src/autoskillit/server/_notify.py +++ b/src/autoskillit/server/_notify.py @@ -5,12 +5,15 @@ import functools import json from collections.abc import Awaitable, Callable +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal from anyio import BrokenResourceError as _BrokenResource from anyio import ClosedResourceError as _ClosedResource +from autoskillit.config import OutputBudgetConfig from autoskillit.core import RESERVED_LOG_RECORD_KEYS, get_logger +from autoskillit.server._response_budget import enforce_response_budget if TYPE_CHECKING: from fastmcp import Context @@ -95,38 +98,85 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: } ) logger.exception("Unhandled exception in tool %s", tool_name) + ctx = _get_ctx_or_none() + try: + response_str = result if isinstance(result, str) else json.dumps(result) + except (TypeError, ValueError): + logger.warning( + "track_response_size_nonserializable_result", + tool_name=tool_name, + result_type=type(result).__name__, + ) + return result + exceeded = False try: - ctx = _get_ctx_or_none() if ctx is not None: - response_str = result if isinstance(result, str) else json.dumps(result) threshold = ctx.config.mcp_response.alert_threshold_tokens exceeded = ctx.response_log.record( tool_name, response_str, alert_threshold_tokens=threshold ) - if exceeded: - from fastmcp import Context as FmcpContext - - mcp_ctx = next( - (a for a in args if isinstance(a, FmcpContext)), - next( - (v for v in kwargs.values() if isinstance(v, FmcpContext)), - None, - ), - ) - if mcp_ctx is not None: - await _notify( - mcp_ctx, - "info", - f"MCP tool '{tool_name}' response exceeded " - f"{threshold} estimated token threshold", - logger_name="autoskillit.server.response_size", - ) except Exception: logger.warning( - "track_response_size_failed", + "track_response_size_telemetry_failed", tool_name=tool_name, exc_info=True, ) + + configured_budget = ( + getattr(ctx.config, "output_budget", None) if ctx is not None else None + ) + budget = ( + configured_budget + if isinstance(configured_budget, OutputBudgetConfig) + else OutputBudgetConfig() + ) + temp_dir = getattr(ctx, "temp_dir", None) if ctx is not None else None + artifact_dir = ( + temp_dir / "responses" / tool_name if isinstance(temp_dir, Path) else None + ) + try: + result = enforce_response_budget( + result, + tool_name=tool_name, + artifact_dir=artifact_dir, + config=budget, + ) + except Exception: + logger.exception("track_response_size_enforcement_failed", tool_name=tool_name) + result = json.dumps( + { + "success": False, + "error": "response_budget_enforcement_failed", + "tool_name": tool_name, + }, + separators=(",", ":"), + ) + + if exceeded: + try: + from fastmcp import Context as FmcpContext + + mcp_ctx = next( + (a for a in args if isinstance(a, FmcpContext)), + next( + (v for v in kwargs.values() if isinstance(v, FmcpContext)), + None, + ), + ) + if mcp_ctx is not None: + await _notify( + mcp_ctx, + "info", + f"MCP tool '{tool_name}' response exceeded " + f"{threshold} estimated token threshold", + logger_name="autoskillit.server.response_size", + ) + except Exception: + logger.warning( + "track_response_size_notification_failed", + tool_name=tool_name, + exc_info=True, + ) return result return wrapper diff --git a/src/autoskillit/server/_response_budget.py b/src/autoskillit/server/_response_budget.py new file mode 100644 index 0000000000..c9f8836fb1 --- /dev/null +++ b/src/autoskillit/server/_response_budget.py @@ -0,0 +1,310 @@ +"""Lossless, shape-preserving enforcement for MCP handler responses.""" + +from __future__ import annotations + +import hashlib +import json +import uuid +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from autoskillit.core import atomic_write, get_logger + +if TYPE_CHECKING: + from autoskillit.config import OutputBudgetConfig + +logger = get_logger(__name__) + +RESPONSE_SPILL_METADATA_KEY = "_autoskillit_response_spill" +RESPONSE_BACKSTOP_EXEMPT_TOOLS = frozenset({"open_kitchen", "load_recipe"}) + + +def _serialized(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + + +def _preview_string(value: str, limit: int) -> tuple[str, int]: + if len(value) <= limit: + return value, 0 + if limit <= 16: + return value[:limit], len(value) - limit + marker = "…[omitted]…" + available = max(0, limit - len(marker)) + head = available // 2 + tail = available - head + return value[:head] + marker + (value[-tail:] if tail else ""), len(value) - available + + +def _project_value(value: Any, limit: int) -> tuple[Any, int, int]: + """Return a bounded same-category value and aggregate omission counts.""" + if isinstance(value, str): + projected, omitted = _preview_string(value, limit) + return projected, omitted, 0 + if value is None or isinstance(value, (bool, int, float)): + return value, 0, 0 + if isinstance(value, list): + if not value: + return [], 0, 0 + item_limit = max(16, limit // min(len(value), 4)) + candidates = value if len(value) <= 4 else [value[0], value[1], value[-2], value[-1]] + projected_items: list[Any] = [] + omitted_chars = 0 + omitted_items = max(0, len(value) - len(candidates)) + for item in candidates: + projected, chars, items = _project_value(item, item_limit) + projected_items.append(projected) + omitted_chars += chars + omitted_items += items + return projected_items, omitted_chars, omitted_items + if isinstance(value, dict): + if not value: + return {}, 0, 0 + item_limit = max(16, limit // max(1, min(len(value), 8))) + projected_dict: dict[str, Any] = {} + omitted_chars = 0 + omitted_items = 0 + for key, item in value.items(): + projected, chars, items = _project_value(item, item_limit) + projected_dict[str(key)] = projected + omitted_chars += chars + omitted_items += items + return projected_dict, omitted_chars, omitted_items + text, omitted = _preview_string(str(value), limit) + return text, omitted, 0 + + +def _minimal_same_type(value: Any) -> Any: + if isinstance(value, str): + return "" + if isinstance(value, list): + return [] + if isinstance(value, dict): + return {} + if value is None or isinstance(value, (bool, int, float)): + return value + return "" + + +def _bounded_failure( + *, + reason: str, + tool_name: str, + max_bytes: int, + artifact_path: str | None = None, +) -> str: + payload: dict[str, Any] = { + "success": False, + "error": reason, + "tool_name": tool_name, + } + if artifact_path is not None: + payload["artifact_path"] = artifact_path + rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + if len(rendered.encode("utf-8")) <= max_bytes: + return rendered + return '{"success":false,"error":"response_budget_failure"}' + + +def _artifact_path(artifact_dir: Path, tool_name: str) -> Path: + safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in tool_name) + return artifact_dir / f"{safe_name or 'response'}_{uuid.uuid4().hex[:8]}.log" + + +def _project_json_object( + parsed: dict[str, Any], + *, + metadata: dict[str, Any], + max_bytes: int, + inline_chars: int, +) -> str | None: + if RESPONSE_SPILL_METADATA_KEY in parsed: + return None + + key_count = max(1, len(parsed)) + value_limit = max(16, min(inline_chars, max_bytes // (key_count + 1))) + while value_limit >= 16: + projected: dict[str, Any] = {} + omitted_chars = 0 + omitted_items = 0 + for key, value in parsed.items(): + item, chars, items = _project_value(value, value_limit) + projected[key] = item + omitted_chars += chars + omitted_items += items + projected[RESPONSE_SPILL_METADATA_KEY] = { + **metadata, + "omitted_chars": omitted_chars, + "omitted_items": omitted_items, + "reason": "oversized_values", + } + rendered = json.dumps(projected, ensure_ascii=False, separators=(",", ":")) + projected[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len( + rendered.encode("utf-8") + ) + rendered = json.dumps(projected, ensure_ascii=False, separators=(",", ":")) + if len(rendered.encode("utf-8")) <= max_bytes: + return rendered + value_limit //= 2 + + minimal = {key: _minimal_same_type(value) for key, value in parsed.items()} + minimal[RESPONSE_SPILL_METADATA_KEY] = { + **metadata, + "omitted_chars": 0, + "omitted_items": 0, + "reason": "minimal_projection", + } + rendered = json.dumps(minimal, ensure_ascii=False, separators=(",", ":")) + minimal[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len(rendered.encode("utf-8")) + rendered = json.dumps(minimal, ensure_ascii=False, separators=(",", ":")) + return rendered if len(rendered.encode("utf-8")) <= max_bytes else None + + +def _plain_spill_envelope( + original: str, + *, + metadata: dict[str, Any], + max_bytes: int, + inline_chars: int, +) -> str: + preview, _ = _preview_string(original, min(inline_chars, max_bytes // 3)) + envelope: dict[str, Any] = { + RESPONSE_SPILL_METADATA_KEY: { + **metadata, + "projected_utf8_bytes": 0, + "reason": "plain_text", + }, + "preview": preview, + } + rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + envelope[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len(rendered.encode("utf-8")) + rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + while len(rendered.encode("utf-8")) > max_bytes and preview: + preview = preview[: len(preview) // 2] + envelope["preview"] = preview + rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) + return rendered + + +def enforce_response_budget( + result: Any, + *, + tool_name: str, + artifact_dir: Path | None, + config: OutputBudgetConfig, + force_spill: bool = False, +) -> Any: + """Return a bounded response of the same handler type. + + Oversized content is atomically persisted before a projection is returned. + Artifact failure and missing-context cases fail closed without echoing the + original payload. + """ + if tool_name in RESPONSE_BACKSTOP_EXEMPT_TOOLS: + return result + + original = _serialized(result) + original_bytes = original.encode("utf-8") + if not force_spill and len(original_bytes) <= config.response_max_bytes: + return result + if artifact_dir is None: + failure = _bounded_failure( + reason="response_budget_context_unavailable", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + ) + return failure if isinstance(result, str) else {"success": False, "error": failure} + + path = _artifact_path(artifact_dir, tool_name) + try: + atomic_write(path, original) + except Exception: + logger.error("response_budget_artifact_write_failed", tool_name=tool_name, exc_info=True) + failure = _bounded_failure( + reason="response_budget_artifact_write_failed", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + ) + return failure if isinstance(result, str) else {"success": False, "error": failure} + + published = str(path.resolve()) + metadata = { + "artifact_path": published, + "sha256": hashlib.sha256(original_bytes).hexdigest(), + "original_utf8_bytes": len(original_bytes), + } + + parsed: Any = None + if isinstance(result, str): + try: + parsed = json.loads(result) + except (TypeError, ValueError): + parsed = None + else: + parsed = result + + rendered: str | None = None + if isinstance(parsed, dict): + rendered = _project_json_object( + parsed, + metadata=metadata, + max_bytes=config.response_max_bytes, + inline_chars=config.inline_max_chars, + ) + if rendered is None and not isinstance(parsed, dict): + rendered = _plain_spill_envelope( + original, + metadata=metadata, + max_bytes=config.response_max_bytes, + inline_chars=config.inline_max_chars, + ) + if rendered is None: + rendered = _bounded_failure( + reason="response_budget_irreducible_shape", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + artifact_path=published, + ) + + logger.info( + "response_budget_spill", + tool_name=tool_name, + original_utf8_bytes=len(original_bytes), + artifact_utf8_bytes=len(original_bytes), + ) + if isinstance(result, str): + return rendered + try: + return json.loads(rendered) + except ValueError: + return {"success": False, "error": "response_budget_projection_invalid"} + + +def shape_json_response( + payload: dict[str, Any], + *, + tool_name: str, + artifact_dir: Path, + config: OutputBudgetConfig, +) -> str: + """Serialize a tool dict, spilling once it crosses the source threshold.""" + rendered = json.dumps(payload) + if len(rendered) <= config.inline_max_chars: + return rendered + shaped = enforce_response_budget( + rendered, + tool_name=tool_name, + artifact_dir=artifact_dir, + config=config, + force_spill=True, + ) + return shaped if isinstance(shaped, str) else json.dumps(shaped) + + +__all__ = [ + "RESPONSE_BACKSTOP_EXEMPT_TOOLS", + "RESPONSE_SPILL_METADATA_KEY", + "enforce_response_budget", + "shape_json_response", +] diff --git a/src/autoskillit/server/git.py b/src/autoskillit/server/git.py index 9d390d00c2..e34e94c19c 100644 --- a/src/autoskillit/server/git.py +++ b/src/autoskillit/server/git.py @@ -12,6 +12,7 @@ import asyncio import dataclasses +import json import os from pathlib import Path from typing import TYPE_CHECKING, Any @@ -20,11 +21,14 @@ GENERATED_FILES, MergeFailedStep, MergeState, + SpillSpec, SubprocessRunner, get_logger, is_generated_path, is_protected_branch, resolve_main_worktree, + resolve_temp_dir, + spill_output, truncate_text, ) from autoskillit.server._editable_guard import scan_editable_installs_for_worktree @@ -34,11 +38,46 @@ if TYPE_CHECKING: from autoskillit.config import AutomationConfig - from autoskillit.core import TestRunner + from autoskillit.core import TestResult, TestRunner logger = get_logger(__name__) +def _shape_failed_test_output( + test_result: TestResult, + worktree_path: str, + config: AutomationConfig, +) -> dict[str, str]: + spec = SpillSpec( + inline_max_chars=config.output_budget.inline_max_chars, + head_chars=config.output_budget.head_chars, + tail_chars=config.output_budget.tail_chars, + ) + raw = json.dumps({"stdout": test_result.stdout, "stderr": test_result.stderr}) + spilled = spill_output( + raw, + resolve_temp_dir(Path(worktree_path), config.workspace.temp_dir) / "merge_worktree", + "raw_test_output", + spec, + ) + condensed_stdout, condensed_stderr = condense_test_output(test_result) + + def preview(text: str) -> str: + if len(text) <= spec.inline_max_chars: + return text + marker = f"[raw test output spilled -> {spilled.artifact_path}]" + tail = text[-spec.tail_chars :] if spec.tail_chars else "" + return "\n".join(part for part in (text[: spec.head_chars], marker, tail) if part) + + shaped = { + "test_stdout": preview(condensed_stdout), + "test_stderr": preview(condensed_stderr), + } + if spilled.artifact_path is not None: + shaped["raw_output_artifact_path"] = spilled.artifact_path + return shaped + + def _filter_changed_files(stdout: str, prefixes: list[str]) -> tuple[list[str], list[str]]: """Parse git diff stdout into (changed_files, critical_files). @@ -298,14 +337,12 @@ async def perform_merge( } test_result = await tester.run(Path(worktree_path)) if not test_result.passed: - condensed_stdout, condensed_stderr = condense_test_output(test_result) return { "error": "Tests failed in worktree — merge blocked", "failed_step": MergeFailedStep.TEST_GATE, "state": MergeState.WORKTREE_INTACT, "worktree_path": worktree_path, - "test_stdout": truncate_text(condensed_stdout), - "test_stderr": truncate_text(condensed_stderr), + **_shape_failed_test_output(test_result, worktree_path, config), } # 5. Fetch @@ -399,7 +436,6 @@ async def perform_merge( test_result = await tester.run(Path(worktree_path)) passed = test_result.passed if not passed: - condensed_stdout, condensed_stderr = condense_test_output(test_result) return { "error": ( "Tests failed after rebase. The worktree is intact; " @@ -408,8 +444,7 @@ async def perform_merge( "failed_step": MergeFailedStep.POST_REBASE_TEST_GATE, "state": MergeState.WORKTREE_INTACT, "worktree_path": worktree_path, - "test_stdout": truncate_text(condensed_stdout), - "test_stderr": truncate_text(condensed_stderr), + **_shape_failed_test_output(test_result, worktree_path, config), } # 7. Discover main repo path diff --git a/src/autoskillit/server/tools/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index 8d92e62173..ec783286cd 100644 --- a/src/autoskillit/server/tools/_execution_helpers.py +++ b/src/autoskillit/server/tools/_execution_helpers.py @@ -9,15 +9,97 @@ import types import typing from pathlib import Path - -from autoskillit.core import RUN_PYTHON_SENTINEL_KEYS, get_logger +from typing import TYPE_CHECKING + +from autoskillit.core import ( + RUN_PYTHON_SENTINEL_KEYS, + SpillSpec, + get_logger, + resolve_temp_dir, + spill_output, +) from autoskillit.server._misc import _hook_config_overlay_path +from autoskillit.server._response_budget import shape_json_response logger = get_logger(__name__) +if TYPE_CHECKING: + from autoskillit.core import SkillResult + from autoskillit.pipeline import ToolContext + _PATH_LIKE_ARGS: frozenset[str] = frozenset({"output_dir", "workspace", "diagnostics_log_dir"}) +def persist_run_skill_state(skill_result: SkillResult, project_dir: Path) -> None: + from autoskillit.server._misc import persist_run_skill_state as persist # circular-break + + persist(skill_result, project_dir) + + +def clear_run_skill_state(project_dir: Path) -> None: + from autoskillit.server._misc import clear_run_skill_state as clear # circular-break + + clear(project_dir) + + +def _spill_spec(tool_ctx: ToolContext) -> SpillSpec: + budget = tool_ctx.config.output_budget + return SpillSpec( + inline_max_chars=budget.inline_max_chars, + head_chars=budget.head_chars, + tail_chars=budget.tail_chars, + ) + + +def spill_run_cmd_result( + tool_ctx: ToolContext, + *, + cwd: str, + returncode: int, + stdout: str, + stderr: str, +) -> dict[str, object]: + artifact_root = ( + resolve_temp_dir(Path(cwd), tool_ctx.config.workspace.temp_dir) / "run_cmd" + if cwd and Path(cwd).is_absolute() + else tool_ctx.temp_dir / "run_cmd" + ) + spec = _spill_spec(tool_ctx) + shaped_stdout = spill_output(stdout, artifact_root, "stdout", spec) + shaped_stderr = spill_output(stderr, artifact_root, "stderr", spec) + result: dict[str, object] = { + "success": returncode == 0, + "exit_code": returncode, + "stdout": shaped_stdout.text, + "stderr": shaped_stderr.text, + } + if shaped_stdout.artifact_path is not None: + result["stdout_artifact_path"] = shaped_stdout.artifact_path + if shaped_stderr.artifact_path is not None: + result["stderr_artifact_path"] = shaped_stderr.artifact_path + return result + + +def shape_execution_response( + tool_ctx: ToolContext, + payload: dict[str, typing.Any], + *, + tool_name: str, + work_dir: str, +) -> str: + artifact_root = ( + resolve_temp_dir(Path(work_dir), tool_ctx.config.workspace.temp_dir) / tool_name + if work_dir and Path(work_dir).is_absolute() + else tool_ctx.temp_dir / tool_name + ) + return shape_json_response( + payload, + tool_name=tool_name, + artifact_dir=artifact_root, + config=tool_ctx.config.output_budget, + ) + + def validate_path_arg_anchoring(args: dict[str, object] | None, work_dir: str) -> str | None: """Return error message if args contain relative path-like values without work_dir.""" if not args: diff --git a/src/autoskillit/server/tools/_types.py b/src/autoskillit/server/tools/_types.py index 62d3fe2109..719afb6739 100644 --- a/src/autoskillit/server/tools/_types.py +++ b/src/autoskillit/server/tools/_types.py @@ -85,6 +85,8 @@ class RunCmdResult(_RunCmdResultBase, total=False): stdout: str stderr: str + stdout_artifact_path: str + stderr_artifact_path: str error: str @@ -99,6 +101,7 @@ class TestCheckResult(_TestCheckResultBase, total=False): stdout: str stderr: str + raw_output_artifact_path: str duration_seconds: float filter_mode: str tests_selected: int @@ -127,6 +130,7 @@ class MergeWorktreeResult(TypedDict, total=False): merge_commits: list[str] test_stdout: str test_stderr: str + raw_output_artifact_path: str abort_failed: bool abort_stderr: str poisoned_installs: list[str] @@ -177,9 +181,8 @@ class DispatchEnvelopeResult(TypedDict, total=False): Covers DispatchCompleted.to_envelope(), DispatchRejected.to_envelope(), and fleet_error() output. The error paths do NOT carry a ``subtype`` key so they reach this formatter rather than the gate_error/tool_exception - guards. Nested structured fields (l3_payload, health_report, token_usage, - resume_checkpoint) must be rendered without size-based truncation to - preserve dispatch_plan visibility for downstream orchestrators. + guards. Nested structured fields may be projected when the complete + response is available through response-spill artifact metadata. """ success: bool diff --git a/src/autoskillit/server/tools/tools_execution.py b/src/autoskillit/server/tools/tools_execution.py index d5fd212599..c96ac93127 100644 --- a/src/autoskillit/server/tools/tools_execution.py +++ b/src/autoskillit/server/tools/tools_execution.py @@ -41,7 +41,6 @@ is_git_worktree, parse_plan_paths, resolve_target_skill, - truncate_text, ) from autoskillit.core import current_order_id as _current_order_id from autoskillit.core import current_step_name as _current_step_name @@ -77,9 +76,13 @@ from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._execution_helpers import ( _import_and_call, + clear_run_skill_state, maybe_promote_work_dir, + persist_run_skill_state, propagate_session_deadline, resolve_relative_path_args, + shape_execution_response, + spill_run_cmd_result, validate_path_arg_anchoring, ) from autoskillit.server.tools._preflight import ( @@ -401,12 +404,13 @@ async def run_cmd( timeout=float(timeout), env=_env, ) - result = { - "success": returncode == 0, - "exit_code": returncode, - "stdout": truncate_text(stdout), - "stderr": truncate_text(stderr), - } + result = spill_run_cmd_result( + tool_ctx, + cwd=cwd, + returncode=returncode, + stdout=stdout, + stderr=stderr, + ) if not result["success"]: await _notify( ctx, @@ -467,6 +471,9 @@ async def run_python( if (gate := _check_recipe_read_prohibition(callable_name=callable)) is not None: return gate try: + from autoskillit.server import _get_ctx # circular-break + + tool_ctx = _get_ctx() with structlog.contextvars.bound_contextvars(tool="run_python"): logger.info("run_python", callable=callable, timeout=timeout) await _notify( @@ -506,24 +513,17 @@ async def run_python( "autoskillit.run_python", extra={"callable": callable}, ) - return json.dumps(result) + return shape_execution_response( + tool_ctx, + result, + tool_name="run_python", + work_dir=work_dir, + ) except Exception as exc: logger.error("run_python unhandled exception", exc_info=True) return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) -def _persist_run_skill_state(skill_result: SkillResult, project_dir: Path) -> None: - from autoskillit.server._misc import persist_run_skill_state # circular-break - - persist_run_skill_state(skill_result, project_dir) - - -def _clear_run_skill_state(project_dir: Path) -> None: - from autoskillit.server._misc import clear_run_skill_state # circular-break - - clear_run_skill_state(project_dir) - - def _compute_write_prefixes( write_watch_dirs: list[Path], cwd: str, @@ -1389,7 +1389,7 @@ async def run_skill( ).to_json() if skill_result.success: tool_ctx.audit.record_success(skill_command) - _clear_run_skill_state(tool_ctx.project_dir) + clear_run_skill_state(tool_ctx.project_dir) else: await _notify( ctx, @@ -1401,7 +1401,7 @@ async def run_skill( "subtype": skill_result.subtype, }, ) - _persist_run_skill_state(skill_result, tool_ctx.project_dir) + persist_run_skill_state(skill_result, tool_ctx.project_dir) if effective_order_id: skill_result.order_id = effective_order_id from autoskillit.server._misc import ( # circular-break @@ -1440,7 +1440,12 @@ async def run_skill( retriable=True, ) ) - return _json_str + return shape_execution_response( + tool_ctx, + _parsed, + tool_name="run_skill", + work_dir=cwd, + ) except Exception as exc: logger.error("run_skill executor raised unexpectedly", exc_info=True) return SkillResult.crashed( diff --git a/src/autoskillit/server/tools/tools_workspace.py b/src/autoskillit/server/tools/tools_workspace.py index a14d2e00a1..97a73ba842 100644 --- a/src/autoskillit/server/tools/tools_workspace.py +++ b/src/autoskillit/server/tools/tools_workspace.py @@ -11,7 +11,7 @@ from fastmcp import Context from fastmcp.dependencies import CurrentContext -from autoskillit.core import get_logger, truncate_text +from autoskillit.core import SpillSpec, get_logger, resolve_temp_dir, spill_output, truncate_text from autoskillit.server import mcp from autoskillit.server._guards import _require_enabled from autoskillit.server._misc import condense_test_output @@ -22,6 +22,15 @@ logger = get_logger(__name__) +def _bounded_test_stream(text: str, spec: SpillSpec, artifact_path: str | None) -> str: + if len(text) <= spec.inline_max_chars: + return text + head = text[: spec.head_chars] + tail = text[-spec.tail_chars :] if spec.tail_chars else "" + marker = f"[raw test output spilled -> {artifact_path}]" + return "\n".join(part for part in (head, marker, tail) if part) + + @mcp.tool( tags={"autoskillit", "kitchen", "kitchen-core", "headless"}, annotations={"readOnlyHint": True} ) @@ -103,12 +112,33 @@ async def test_check( extra={"worktree": worktree_path}, ) + raw_artifact_path: str | None = None + spec = SpillSpec( + inline_max_chars=tool_ctx.config.output_budget.inline_max_chars, + head_chars=tool_ctx.config.output_budget.head_chars, + tail_chars=tool_ctx.config.output_budget.tail_chars, + ) + if not test_result.passed: + raw_output = json.dumps( + {"stdout": test_result.stdout, "stderr": test_result.stderr} + ) + raw_spill = spill_output( + raw_output, + resolve_temp_dir(Path(resolved), tool_ctx.config.workspace.temp_dir) + / "test_check", + "raw_output", + spec, + ) + raw_artifact_path = raw_spill.artifact_path + condensed_stdout, condensed_stderr = condense_test_output(test_result) response = { "passed": test_result.passed, - "stdout": truncate_text(condensed_stdout), - "stderr": truncate_text(condensed_stderr), + "stdout": _bounded_test_stream(condensed_stdout, spec, raw_artifact_path), + "stderr": _bounded_test_stream(condensed_stderr, spec, raw_artifact_path), } + if raw_artifact_path is not None: + response["raw_output_artifact_path"] = raw_artifact_path if test_result.duration_seconds is not None: response["duration_seconds"] = round(test_result.duration_seconds, 2) if test_result.filter_mode is not None: diff --git a/tests/core/AGENTS.md b/tests/core/AGENTS.md index 615bbe2e3b..f1ce2570d1 100644 --- a/tests/core/AGENTS.md +++ b/tests/core/AGENTS.md @@ -23,6 +23,7 @@ Core layer (IL-0) unit tests — paths, IO, types, feature flags. | `test_import_isolation.py` | Fleet package must not be imported at server startup via lazy-import structure | | `test_install_detect.py` | Tests for core/_install_detect.py — install-type detection | | `test_io.py` | Extended YAML I/O tests for core/io.py consolidation | +| `test_io_spill.py` | Lossless artifact-backed output spill tests | | `test_json.py` | Tests for autoskillit.core._json — fast_loads and fast_dumps | | `test_kitchen_state.py` | Tests for KitchenMarker hash field support | | `test_label_lifecycle.py` | Tests for IssueLabelState, LabelDef, LABEL_LIFECYCLE_REGISTRY, LABEL_TRANSITIONS, and validate_label_transition | diff --git a/tests/core/test_io_spill.py b/tests/core/test_io_spill.py new file mode 100644 index 0000000000..c809ab3db9 --- /dev/null +++ b/tests/core/test_io_spill.py @@ -0,0 +1,59 @@ +"""Tests for lossless artifact-backed output spilling.""" + +from __future__ import annotations + +import hashlib +import os +import re + +import pytest + +from autoskillit.core import SpillSpec, spill_output + +pytestmark = [pytest.mark.layer("core"), pytest.mark.small] + + +def test_small_output_stays_inline_without_artifact(tmp_path): + artifact_dir = tmp_path / "artifacts" + result = spill_output("small", artifact_dir, "stdout", SpillSpec(inline_max_chars=5)) + + assert result.spilled is False + assert result.text == "small" + assert result.artifact_path is None + assert not artifact_dir.exists() + + +def test_large_output_is_published_losslessly_with_metadata(tmp_path): + text = "αβγ\n" + ("middle" * 100) + "\ntail" + result = spill_output( + text, + tmp_path, + "command output", + SpillSpec(inline_max_chars=20, head_chars=8, tail_chars=7), + ) + + assert result.spilled is True + assert result.artifact_path is not None + artifact = tmp_path / os.path.basename(result.artifact_path) + assert artifact.read_text() == text + assert re.fullmatch(r"command_output_[0-9a-f]{8}\.log", artifact.name) + assert result.sha256 == hashlib.sha256(text.encode()).hexdigest() + assert result.total_chars == len(text) + assert result.total_utf8_bytes == len(text.encode()) + assert result.total_lines == len(text.splitlines()) + assert result.head == text[:8] + assert result.tail == text[-7:] + if os.name == "posix": + assert artifact.stat().st_mode & 0o777 == 0o600 + + +def test_publication_failure_returns_no_path_or_partial_file(tmp_path, monkeypatch): + from autoskillit.core import io + + def fail_write(*_args, **_kwargs): + raise OSError("ENOSPC") + + monkeypatch.setattr(io, "atomic_write", fail_write) + with pytest.raises(OSError, match="ENOSPC"): + spill_output("x" * 100, tmp_path, "stdout", SpillSpec(inline_max_chars=10)) + assert list(tmp_path.iterdir()) == [] diff --git a/tests/docs/test_claude_md_structure.py b/tests/docs/test_claude_md_structure.py index 92654bac57..be2ec08b10 100644 --- a/tests/docs/test_claude_md_structure.py +++ b/tests/docs/test_claude_md_structure.py @@ -87,8 +87,8 @@ def test_agents_md_architecture_tree_has_subpackages() -> None: def test_agents_md_dataclass_count_is_28() -> None: assert AGENTS_MD.exists(), f"AGENTS.md not found at {AGENTS_MD}" content = AGENTS_MD.read_text() - assert "28 leaf dataclasses" in content - assert "27 leaf dataclasses" not in content + assert "29 leaf dataclasses" in content + assert "28 leaf dataclasses" not in content def test_claude_md_has_lsp_section() -> None: diff --git a/tests/execution/test_headless_core.py b/tests/execution/test_headless_core.py index 49d380bae7..b4ae090fa4 100644 --- a/tests/execution/test_headless_core.py +++ b/tests/execution/test_headless_core.py @@ -1250,8 +1250,8 @@ def test_stderr_included_in_response(self): ) assert response["stderr"] == "queue contention" - def test_stderr_truncated(self): - """Stderr exceeding 5000 chars is truncated.""" + def test_stderr_reaches_tool_boundary_losslessly(self): + """Headless results retain stderr until the MCP response boundary.""" valid_json = json.dumps( { "type": "result", @@ -1272,8 +1272,7 @@ def test_stderr_truncated(self): response = json.loads( _build_skill_result(result_obj, backend=ClaudeCodeBackend()).to_json() ) - assert len(response["stderr"]) < len(long_stderr) - assert "truncated" in response["stderr"] + assert response["stderr"] == long_stderr def test_empty_stderr_is_empty_string(self): """Empty stderr produces empty string, not omitted.""" diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index adb8c8b802..e2a777032f 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -33,6 +33,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_misc_module.py` | Contract tests: server._misc module | | `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 and projection tests | | `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 | @@ -81,6 +82,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_execution_provider.py` | Tests for provider_extras/profile_name forwarding through run_skill() | | `test_tools_execution_response.py` | Contract tests: MCP tool response fields use correct enum types | | `test_tools_execution_results.py` | Tests for run_skill result shapes, failure paths, timing, flush telemetry, and gate checks | +| `test_tools_execution_spill.py` | Lossless spill contracts for run_cmd and run_python | | `test_tools_execution_routing.py` | Tests for run_skill routing, executor delegation, and session skill management | | `test_tools_execution_step_resolution.py` | Tests for server-side recipe step parameter resolution in run_skill (output_dir, stale_threshold, idle_output_timeout, step_provider auto-filled from cached recipe step definitions) | | `test_tools_execution_backend_mixing.py` | Integration tests for per-step backend mixing in run_skill() — provider-profile and skill-requirement-driven backend_override derivation + structured log emission | @@ -148,6 +150,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_types_module.py` | Tests for server/tools/_types.py TypedDict imports and failure envelope factory functions | | `test_tools_types_validate.py` | Tests for _validate_result helper in server/tools/_types.py — shape invariant enforcement and fail-closed envelope validation | | `test_tools_workspace.py` | Tests for autoskillit server workspace tools | +| `test_tools_workspace_spill.py` | Lossless raw-output spill contracts for test_check | | `test_tools_agents.py` | Tests for agent pack registry, MCP resources, and `unlock_agent_pack` | | `test_track_response_size.py` | Tests for the track_response_size decorator in autoskillit.server._notify | | `test_wire_compat.py` | Wire compatibility tests | diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py new file mode 100644 index 0000000000..f1671dff11 --- /dev/null +++ b/tests/server/test_response_backstop.py @@ -0,0 +1,105 @@ +"""Tests for the universal MCP response output-budget backstop.""" + +from __future__ import annotations + +import json + +import pytest + +from autoskillit.config import OutputBudgetConfig +from autoskillit.server._response_budget import ( + RESPONSE_BACKSTOP_EXEMPT_TOOLS, + RESPONSE_SPILL_METADATA_KEY, + enforce_response_budget, +) + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + + +def _config() -> OutputBudgetConfig: + return OutputBudgetConfig( + inline_max_chars=180, + head_chars=90, + tail_chars=90, + response_max_bytes=1400, + ) + + +def test_small_response_is_byte_identical(tmp_path): + original = json.dumps({"success": True, "result": "small"}) + assert ( + enforce_response_budget( + original, + tool_name="run_skill", + artifact_dir=tmp_path, + config=_config(), + ) + == original + ) + assert list(tmp_path.iterdir()) == [] + + +def test_oversized_json_preserves_routing_shape_and_full_artifact(tmp_path): + original = json.dumps( + { + "success": False, + "needs_retry": True, + "session_id": "session-1", + "result": "middle-sentinel" + ("x" * 10_000), + } + ) + shaped = enforce_response_budget( + original, + tool_name="run_skill", + artifact_dir=tmp_path, + config=_config(), + ) + + assert isinstance(shaped, str) + assert len(shaped.encode()) <= _config().response_max_bytes + data = json.loads(shaped) + assert data["success"] is False + assert data["needs_retry"] is True + assert data["session_id"] == "session-1" + metadata = data[RESPONSE_SPILL_METADATA_KEY] + assert open(metadata["artifact_path"], encoding="utf-8").read() == original + assert metadata["original_utf8_bytes"] == len(original.encode()) + + +def test_plain_text_response_uses_same_type_envelope(tmp_path): + original = "head" + ("x" * 10_000) + "tail" + shaped = enforce_response_budget( + original, + tool_name="plain_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + assert isinstance(shaped, str) + data = json.loads(shaped) + artifact_path = data[RESPONSE_SPILL_METADATA_KEY]["artifact_path"] + assert open(artifact_path, encoding="utf-8").read() == original + assert len(shaped.encode()) <= _config().response_max_bytes + + +def test_artifact_failure_is_fail_closed(tmp_path, monkeypatch): + from autoskillit.server import _response_budget + + monkeypatch.setattr( + _response_budget, + "atomic_write", + lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("ENOSPC")), + ) + secret = "never-inline-when-spill-fails" * 1000 + shaped = enforce_response_budget( + secret, + tool_name="run_skill", + artifact_dir=tmp_path, + config=_config(), + ) + assert secret not in shaped + assert "artifact_write_failed" in shaped + + +def test_exemption_registry_is_closed(): + assert RESPONSE_BACKSTOP_EXEMPT_TOOLS == frozenset({"open_kitchen", "load_recipe"}) diff --git a/tests/server/test_tools_execution_spill.py b/tests/server/test_tools_execution_spill.py new file mode 100644 index 0000000000..34ac7754d5 --- /dev/null +++ b/tests/server/test_tools_execution_spill.py @@ -0,0 +1,69 @@ +"""Lossless output-spill contracts for execution MCP tools.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from autoskillit.server._response_budget import RESPONSE_SPILL_METADATA_KEY +from autoskillit.server.tools.tools_execution import run_cmd, run_python + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] + + +@pytest.mark.anyio +async def test_run_cmd_spills_large_stdout_under_calling_project(tool_ctx_kitchen_open, tmp_path): + stdout = "head-sentinel\n" + ("x" * 10_000) + "\ntail-sentinel" + with patch( + "autoskillit.server.tools.tools_execution._run_subprocess", + new=AsyncMock(return_value=(0, stdout, "")), + ): + data = json.loads(await run_cmd("bounded-command", str(tmp_path))) + + artifact = data["stdout_artifact_path"] + assert artifact.startswith(str(tmp_path / ".autoskillit" / "temp" / "run_cmd")) + assert open(artifact, encoding="utf-8").read() == stdout + assert "head-sentinel" in data["stdout"] + assert "tail-sentinel" in data["stdout"] + assert data["success"] is True + assert data["exit_code"] == 0 + + +@pytest.mark.anyio +async def test_run_cmd_small_output_shape_is_unchanged(tool_ctx_kitchen_open, tmp_path): + with patch( + "autoskillit.server.tools.tools_execution._run_subprocess", + new=AsyncMock(return_value=(0, "small", "")), + ): + raw = await run_cmd("bounded-command", str(tmp_path)) + + assert raw == json.dumps({"success": True, "exit_code": 0, "stdout": "small", "stderr": ""}) + + +@pytest.mark.anyio +async def test_run_python_preserves_routing_keys_and_full_json(tool_ctx_kitchen_open, tmp_path): + result = {"success": True, "verdict": "GO", "payload": "x" * 10_000} + with patch( + "autoskillit.server.tools.tools_execution._import_and_call", + new=AsyncMock(return_value=result), + ): + data = json.loads(await run_python("package.module.callable", work_dir=str(tmp_path))) + + assert data["success"] is True + assert data["verdict"] == "GO" + metadata = data[RESPONSE_SPILL_METADATA_KEY] + assert json.loads(open(metadata["artifact_path"], encoding="utf-8").read()) == result + + +@pytest.mark.anyio +async def test_run_python_small_dict_is_byte_identical(tool_ctx_kitchen_open, tmp_path): + result = {"success": True, "verdict": "GO"} + with patch( + "autoskillit.server.tools.tools_execution._import_and_call", + new=AsyncMock(return_value=result), + ): + raw = await run_python("package.module.callable", work_dir=str(tmp_path)) + + assert raw == json.dumps(result) diff --git a/tests/server/test_tools_git.py b/tests/server/test_tools_git.py index 0ae998fc69..dc4a194ebd 100644 --- a/tests/server/test_tools_git.py +++ b/tests/server/test_tools_git.py @@ -215,8 +215,10 @@ async def test_gate_failure_does_not_expose_summary(self, tool_ctx_kitchen_open, assert "test_summary" not in result @pytest.mark.anyio - async def test_gate_failure_truncates_large_output(self, tool_ctx_kitchen_open, tmp_path): - """merge_worktree truncates test_stdout and test_stderr in failure response.""" + async def test_gate_failure_spills_large_output_losslessly( + self, tool_ctx_kitchen_open, tmp_path + ): + """merge_worktree bounds previews and preserves exact raw output in an artifact.""" wt = tmp_path / "worktree" wt.mkdir() (wt / ".git").write_text("gitdir: /repo/.git/worktrees/wt") @@ -234,5 +236,8 @@ async def test_gate_failure_truncates_large_output(self, tool_ctx_kitchen_open, ) # test-check result = json.loads(await merge_worktree(str(wt), "dev")) assert result["failed_step"] == MergeFailedStep.TEST_GATE - assert len(result["test_stdout"]) <= 5100 - assert len(result["test_stderr"]) <= 5100 + artifact = Path(result["raw_output_artifact_path"]) + raw = json.loads(artifact.read_text()) + assert raw == {"stdout": large_stdout, "stderr": large_stderr} + assert "raw test output spilled" in result["test_stdout"] + assert "raw test output spilled" in result["test_stderr"] diff --git a/tests/server/test_tools_workspace_spill.py b/tests/server/test_tools_workspace_spill.py new file mode 100644 index 0000000000..04151ff5e7 --- /dev/null +++ b/tests/server/test_tools_workspace_spill.py @@ -0,0 +1,33 @@ +"""Lossless output-spill contracts for the test_check MCP tool.""" + +from __future__ import annotations + +import json + +import pytest + +from autoskillit.server.tools.tools_workspace import test_check +from tests.conftest import _make_result + +pytestmark = [pytest.mark.layer("server"), pytest.mark.small] +test_check.__test__ = False # type: ignore[attr-defined] + + +@pytest.mark.anyio +async def test_failed_test_check_spills_exact_raw_streams(tool_ctx, tmp_path): + worktree = tmp_path / "worktree" + worktree.mkdir() + (worktree / "Taskfile.yml").write_text("version: '3'\n") + stdout = "F" * 10_000 + "\n= 1 failed =" + stderr = "E" * 10_000 + tool_ctx.runner.push(_make_result(1, stdout, stderr)) + + data = json.loads(await test_check(str(worktree))) + + artifact = data["raw_output_artifact_path"] + assert artifact.startswith(str(worktree / ".autoskillit" / "temp" / "test_check")) + assert json.loads(open(artifact, encoding="utf-8").read()) == { + "stdout": stdout, + "stderr": stderr, + } + assert data["passed"] is False From e2523b76261e2fb0a7a10bd0abdb0dceb1713c5d Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 15 Jul 2026 17:48:49 -0700 Subject: [PATCH 02/30] feat: guard unbounded command output --- .autoskillit/test-filter-manifest.yaml | 3 + docs/safety/README.md | 2 +- docs/safety/hooks.md | 16 +- src/autoskillit/hook_registry.py | 11 + src/autoskillit/hooks/AGENTS.md | 2 +- .../hooks/_command_classification.py | 384 +++++++++++++++++- src/autoskillit/hooks/_hook_settings.py | 18 +- src/autoskillit/hooks/guards/AGENTS.md | 1 + .../hooks/guards/output_budget_guard.py | 308 ++++++++++++++ src/autoskillit/hooks/registry.sha256 | 2 +- src/autoskillit/server/_notify.py | 4 +- .../server/tools/_execution_helpers.py | 2 +- src/autoskillit/server/tools/tools_kitchen.py | 33 +- .../skills_extended/audit-friction/SKILL.md | 23 +- tests/arch/test_python_no_hardcoded_temp.py | 1 + tests/arch/test_subpackage_isolation.py | 22 +- tests/docs/test_doc_counts.py | 10 +- tests/execution/AGENTS.md | 2 +- .../hook_event_format_snapshot.json | 7 +- .../backends/test_cli_conformance_probes.py | 302 ++++++++++++-- .../execution/backends/test_codex_fixtures.py | 13 +- .../backends/test_hook_deny_efficacy_probe.py | 56 +++ tests/fixtures/codex/__init__.py | 4 +- .../codex/large_embedded_payload_v1.jsonl | 1 + tests/hooks/AGENTS.md | 3 +- .../hooks/test_codex_hooks_format_contract.py | 18 + tests/hooks/test_command_classification.py | 127 ++++++ tests/hooks/test_hook_config_bridge.py | 67 ++- tests/hooks/test_output_budget_guard.py | 168 ++++++++ tests/infra/test_schema_version_convention.py | 8 +- tests/server/AGENTS.md | 2 +- tests/server/conftest.py | 3 + .../test_tools_kitchen_gate_hook_config.py | 35 +- tests/skills_extended/AGENTS.md | 1 + .../test_audit_friction_output_budget.py | 95 +++++ 35 files changed, 1658 insertions(+), 96 deletions(-) create mode 100644 src/autoskillit/hooks/guards/output_budget_guard.py create mode 100644 tests/fixtures/codex/large_embedded_payload_v1.jsonl create mode 100644 tests/hooks/test_output_budget_guard.py create mode 100644 tests/skills_extended/test_audit_friction_output_budget.py diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index c41085e319..0a8588b4fe 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -153,6 +153,9 @@ tests/recipe/fixtures/*: tests/fixtures/codex/*.ndjson: - execution/ +tests/fixtures/codex/*.jsonl: + - hooks/ + tests/execution/backends/fixtures/codex_ndjson/*.json: - execution/ diff --git a/docs/safety/README.md b/docs/safety/README.md index 2b8e00eff7..ad241551b8 100644 --- a/docs/safety/README.md +++ b/docs/safety/README.md @@ -2,5 +2,5 @@ Guardrails that prevent destructive or out-of-scope actions. -- [hooks.md](hooks.md) — the 26 PreToolUse / PostToolUse / SessionStart hooks +- [hooks.md](hooks.md) — 43 hook scripts (32 PreToolUse, 10 PostToolUse, 1 SessionStart) - [workspace.md](workspace.md) — clone-based isolation, merge_worktree pipeline, registry diff --git a/docs/safety/hooks.md b/docs/safety/hooks.md index a1e536c312..3143eaca2b 100644 --- a/docs/safety/hooks.md +++ b/docs/safety/hooks.md @@ -1,13 +1,13 @@ # Hooks -AutoSkillit registers 26 Claude Code hook scripts: 18 PreToolUse, 7 PostToolUse, +AutoSkillit registers 43 Claude Code hook scripts: 32 PreToolUse, 10 PostToolUse, and 1 SessionStart. Every script is stdlib-only Python so it can run before the project virtualenv is on the path. Scripts live in `src/autoskillit/hooks/` and are bound to event types in `src/autoskillit/hook_registry.py` via the `HOOK_REGISTRY` list of `HookDef` entries; `generate_hooks_json()` then materializes the canonical `hooks.json` that Claude Code reads. -## PreToolUse hooks (18) +## PreToolUse hooks (32) ### `branch_protection_guard.py` **Guarded tools:** `merge_worktree`, `push_to_remote` @@ -84,6 +84,16 @@ and nested-shell forms. Session scope: headless only. Orchestrator sessions are exempt. Per-subcommand allow-overrides are available via `git_ops_policy` in `.hook_config.json` for future recipes that legitimately need these operations. +### `output_budget_guard.py` +**Guarded tools:** `Bash`, `run_cmd` +Denies high-confidence unbounded recursive searches, JSONL reads, and `find` +operations before execution. Commands can prove bounded output by routing both +stdout and stderr through a same-pipeline byte cap, redirecting both descriptors +under `.autoskillit/temp/`, or using a narrowly defined output-free/small-file +exception. The guard applies to interactive and headless sessions. Set +`output_budget.guard_enabled: false` to disable it; byte caps are constrained by +`output_budget.shell_max_inline_bytes`. + ### `generated_file_write_guard.py` **Guarded tools:** `Write`, `Edit` Denies writes to generated files (`hooks.json`, `settings.json`). The hooks @@ -135,7 +145,7 @@ dependencies. Permission is always `allow` — the server-side `_check_pipeline_ in `run_skill` is the primary enforcer. Fails open on missing tracker or malformed input. -## PostToolUse hooks (7) +## PostToolUse hooks (10) ### `pretty_output_hook.py` **Guarded tools:** all AutoSkillit MCP tools diff --git a/src/autoskillit/hook_registry.py b/src/autoskillit/hook_registry.py index e3fd2e75a2..532efdfcf9 100644 --- a/src/autoskillit/hook_registry.py +++ b/src/autoskillit/hook_registry.py @@ -64,6 +64,7 @@ def __post_init__(self) -> None: # artifact_download_guard | works-as-is # git_ops_guard | works-as-is # test_runner_guard | works-as-is +# output_budget_guard | works-as-is # generated_file_write_guard | works-as-is # write_guard | works-as-is # planner_result_naming_guard | works-as-is @@ -200,6 +201,15 @@ def __post_init__(self) -> None: mechanism="deny", enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), + HookDef( + matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", + scripts=["guards/output_budget_guard.py"], + session_scope="any", + exempt_skills=frozenset(), + codex_status="works-as-is", + mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, + ), HookDef( matcher=r"Write|Edit", scripts=["guards/generated_file_write_guard.py"], @@ -413,6 +423,7 @@ def __post_init__(self) -> None: "git_ops_guard.py", "compose_pr_body_guard.py", "test_runner_guard.py", + "output_budget_guard.py", "fleet_claim_guard.py", "reset_resume_gate.py", "recipe_read_guard.py", diff --git a/src/autoskillit/hooks/AGENTS.md b/src/autoskillit/hooks/AGENTS.md index 8047d8ff5f..71a788d3ae 100644 --- a/src/autoskillit/hooks/AGENTS.md +++ b/src/autoskillit/hooks/AGENTS.md @@ -9,7 +9,7 @@ Sub-packages: guards/ (see guards/AGENTS.md), formatters/ (see formatters/AGENTS |------|---------| | `__init__.py` | Package marker (no imports) | | `_dispatch.py` | Stable hook dispatcher — resolves logical hook names to scripts (stdlib-only, NEVER RENAME) | -| `_hook_settings.py` | Shared stdlib-only settings resolver for quota guard hooks | +| `_hook_settings.py` | Shared stdlib-only settings bridge for quota and output-budget guard hooks | | `lint_after_edit_hook.py` | `PostToolUse`: runs ruff format+check on `.py` files after Edit/Write | | `quota_post_hook.py` | Appends quota warning to `run_skill` output | | `review_gate_post_hook.py` | `PostToolUse`: writes/clears `review_gate_state.json` | diff --git a/src/autoskillit/hooks/_command_classification.py b/src/autoskillit/hooks/_command_classification.py index 8e32bf662e..d563e6130b 100644 --- a/src/autoskillit/hooks/_command_classification.py +++ b/src/autoskillit/hooks/_command_classification.py @@ -11,7 +11,8 @@ import os import re import shlex -from collections.abc import Sequence +from collections.abc import Callable, Sequence +from enum import Enum from typing import Protocol _INTERPRETER_RE = re.compile( @@ -224,6 +225,21 @@ _PROTECTED_READ_SHELL_OPS: frozenset[str] = frozenset({"&&", "||", ";", "|", "&"}) _WC_FLAG_RE = re.compile(r"-l+|--lines$") +OUTPUT_BUDGET_MAX_COMMAND_CHARS = 65_536 +OUTPUT_BUDGET_MAX_NESTING_DEPTH = 8 + + +class CommandBudgetDisposition(Enum): + BOUNDED = "bounded" + HAZARDOUS = "hazardous" + UNKNOWN = "unknown" + + +# A producer classifier returns whether the producer's stdout and stderr are +# intrinsically bounded, respectively. Returning None means the segment is +# outside the caller's risky-producer policy. +OutputBudgetProducerClassifier = Callable[[list[str]], tuple[bool, bool] | None] + class SearchPattern(Protocol): def search(self, string: str, /): ... @@ -312,6 +328,369 @@ def tokenize_command_segments(command: str) -> list[list[str]]: return segments +def tokenize_command_structure(command: str) -> list[tuple[str, list[str]]] | None: + """Tokenize shell segments while retaining the connector before each segment.""" + if len(command) > OUTPUT_BUDGET_MAX_COMMAND_CHARS: + return None + try: + stripped = _HEREDOC_MARKER_RE.sub(r"\2", strip_heredoc_bodies(command)) + lexer = shlex.shlex( + _normalize_newlines_for_tokenize(stripped), posix=True, punctuation_chars=";&|" + ) + lexer.whitespace_split = True + raw_tokens = list(lexer) + except (ValueError, TypeError): + return None + tokens: list[str] = [] + i = 0 + while i < len(raw_tokens): + token = raw_tokens[i] + # shlex splits the ampersand in descriptor duplication (2>&1) because + # ampersand is also shell punctuation. Recombine only when the token + # before it is a redirect operator and the target is a literal fd. + if ( + re.fullmatch(r"\d*>{1,2}", token) + and i + 2 < len(raw_tokens) + and raw_tokens[i + 1] == "&" + and re.fullmatch(r"\d+|-", raw_tokens[i + 2]) + ): + tokens.append(f"{token}&{raw_tokens[i + 2]}") + i += 3 + continue + tokens.append(token) + i += 1 + result: list[tuple[str, list[str]]] = [] + current: list[str] = [] + connector = "" + for token in tokens: + if token in {"&&", "||", ";", "|", "|&", "&"}: + if current: + result.append((connector, current)) + current = [] + connector = token + else: + current.append(token) + if current: + result.append((connector, current)) + return result + + +_OUTPUT_REDIRECT_RE = re.compile(r"^(?P[012]?)(?P>{1,2})(?P.*)$") +_OUTPUT_FD_DUP_RE = re.compile(r"^(?P[012]?)(?P>{1,2})&(?P\d+|-)$") +_OUTPUT_DYNAMIC_TARGET_RE = re.compile(r"[$`*?\[\]{}]") +_OUTPUT_UNSUPPORTED_SYNTAX_RE = re.compile(r"[<>]\(|<<<|(?:[3-9]\d*)>&|>&-") +_OUTPUT_BYTE_CAP_VERBS: frozenset[str] = frozenset({"head", "tail"}) + +_DEST_MODEL = "model" +_DEST_PIPE = "pipe" +_DEST_SAFE = "safe" +_DEST_UNSAFE = "unsafe" +_DEST_UNKNOWN = "unknown" + + +def _is_project_temp_target(target: str, cwd: str) -> bool: + if target == "/dev/null": + return True + if not target or _OUTPUT_DYNAMIC_TARGET_RE.search(target): + return False + root = os.path.realpath(cwd or os.getcwd()) + temp_root = os.path.join(root, ".autoskillit", "temp") + resolved = os.path.realpath(target if os.path.isabs(target) else os.path.join(root, target)) + try: + return os.path.commonpath((temp_root, resolved)) == temp_root + except ValueError: + return False + + +def _redirect_target_destination(target: str, cwd: str) -> str: + if not target or _OUTPUT_DYNAMIC_TARGET_RE.search(target): + return _DEST_UNKNOWN + return _DEST_SAFE if _is_project_temp_target(target, cwd) else _DEST_UNSAFE + + +def _segment_output_destinations( + tokens: list[str], *, stdout_base: str, stderr_base: str, cwd: str +) -> tuple[str, str, list[str], bool]: + """Apply ordered stdout/stderr redirects and return remaining argv. + + The bool result reports unsupported or ambiguous descriptor syntax. The + destination copy for ``2>&1`` is intentionally immediate, matching shell + left-to-right redirection semantics. + """ + stdout = stdout_base + stderr = stderr_base + argv: list[str] = [] + unknown = False + i = 0 + while i < len(tokens): + token = tokens[i] + fd_dup = _OUTPUT_FD_DUP_RE.fullmatch(token) + if fd_dup: + fd = int(fd_dup.group("fd") or "1") + target = fd_dup.group("target") + if fd not in {1, 2} or target not in {"1", "2"}: + unknown = True + elif fd == 1: + stdout = stdout if target == "1" else stderr + else: + stderr = stdout if target == "1" else stderr + i += 1 + continue + + redirect = _OUTPUT_REDIRECT_RE.fullmatch(token) + if redirect: + fd = int(redirect.group("fd") or "1") + target = redirect.group("target") + if not target: + if i + 1 >= len(tokens): + unknown = True + i += 1 + continue + target = tokens[i + 1] + i += 1 + destination = _redirect_target_destination(target, cwd) + if fd == 1: + stdout = destination + elif fd == 2: + stderr = destination + else: + unknown = True + i += 1 + continue + + if token.startswith("<") or re.match(r"\d+<", token): + # Ordinary literal stdin redirection does not affect model-visible + # output. Dynamic/process forms were rejected before tokenization. + if _OUTPUT_DYNAMIC_TARGET_RE.search(token): + unknown = True + argv.append(token) + i += 1 + continue + argv.append(token) + i += 1 + return (stdout, stderr, argv, unknown) + + +def command_tokens_without_output_redirections(tokens: list[str]) -> list[str] | None: + """Return command argv with stdout/stderr redirects removed. + + ``None`` means descriptor syntax was ambiguous or unsupported. This is + primarily exposed for stdlib-only guards that need to classify arguments + without accidentally treating redirect targets as input paths. + """ + _stdout, _stderr, argv, unknown = _segment_output_destinations( + tokens, stdout_base=_DEST_MODEL, stderr_base=_DEST_MODEL, cwd=os.getcwd() + ) + return None if unknown else argv + + +def _byte_cap_value(argv: list[str], max_inline_bytes: int) -> int | None: + verb, args = command_verb_and_args(argv) + if os.path.basename(verb) not in _OUTPUT_BYTE_CAP_VERBS: + return None + value: str | None = None + for index, arg in enumerate(args): + if arg in {"-c", "--bytes"} and index + 1 < len(args): + value = args[index + 1] + break + if arg.startswith("-c") and len(arg) > 2: + value = arg[2:] + break + if arg.startswith("--bytes="): + value = arg.partition("=")[2] + break + if value is None or not value.isdigit(): + return None + parsed = int(value) + return parsed if 0 < parsed <= max_inline_bytes else None + + +def _combine_budget_dispositions( + left: CommandBudgetDisposition, right: CommandBudgetDisposition +) -> CommandBudgetDisposition: + if CommandBudgetDisposition.UNKNOWN in {left, right}: + return CommandBudgetDisposition.UNKNOWN + if CommandBudgetDisposition.HAZARDOUS in {left, right}: + return CommandBudgetDisposition.HAZARDOUS + return CommandBudgetDisposition.BOUNDED + + +def _trace_pipeline_destination( + parsed: list[tuple[str, list[str]]], + producer_index: int, + destination: str, + *, + max_inline_bytes: int, + cwd: str, + input_already_bounded: bool = False, +) -> CommandBudgetDisposition: + if destination == _DEST_SAFE: + return CommandBudgetDisposition.BOUNDED + if destination in {_DEST_MODEL, _DEST_UNSAFE}: + return CommandBudgetDisposition.HAZARDOUS + if destination == _DEST_UNKNOWN: + return CommandBudgetDisposition.UNKNOWN + + # The content is on a pipeline. It remains unbounded until a terminal + # byte cap; every intermediate stderr must stay in that pipeline or go to + # an approved sink, otherwise that branch remains model-visible. + index = producer_index + 1 + while index < len(parsed) and parsed[index][0] in {"|", "|&"}: + connector, tokens = parsed[index] + next_connector = parsed[index + 1][0] if index + 1 < len(parsed) else "" + has_next_pipe = next_connector in {"|", "|&"} + stdout_base = _DEST_PIPE if has_next_pipe else _DEST_MODEL + stderr_base = _DEST_PIPE if has_next_pipe and next_connector == "|&" else _DEST_MODEL + stdout, stderr, argv, unknown = _segment_output_destinations( + tokens, stdout_base=stdout_base, stderr_base=stderr_base, cwd=cwd + ) + if unknown or os.path.basename(command_verb(argv)) == "tee": + return CommandBudgetDisposition.UNKNOWN + cap = _byte_cap_value(argv, max_inline_bytes) + if cap is not None: + # A downstream transform can amplify a capped stream, so only a + # terminal cap (or one writing directly to a safe sink) proves the + # model-visible response bound. + if has_next_pipe: + return CommandBudgetDisposition.UNKNOWN + if stdout == _DEST_UNKNOWN: + return CommandBudgetDisposition.UNKNOWN + return ( + CommandBudgetDisposition.BOUNDED + if stdout in {_DEST_MODEL, _DEST_SAFE} + else CommandBudgetDisposition.HAZARDOUS + ) + if input_already_bounded: + # An arbitrary transform may amplify even a small source record. + # Without a terminal byte cap or safe-file sink its output bound is + # not statically knowable. + return ( + CommandBudgetDisposition.BOUNDED + if stdout == _DEST_SAFE + else CommandBudgetDisposition.UNKNOWN + ) + if stdout == _DEST_SAFE: + return CommandBudgetDisposition.BOUNDED + if stdout != _DEST_PIPE: + return ( + CommandBudgetDisposition.UNKNOWN + if stdout == _DEST_UNKNOWN + else CommandBudgetDisposition.HAZARDOUS + ) + if stderr not in {_DEST_PIPE, _DEST_SAFE}: + return ( + CommandBudgetDisposition.UNKNOWN + if stderr == _DEST_UNKNOWN + else CommandBudgetDisposition.HAZARDOUS + ) + index += 1 + return CommandBudgetDisposition.HAZARDOUS + + +def _classify_parsed_output_budget( + parsed: list[tuple[str, list[str]]], + producer_classifier: OutputBudgetProducerClassifier, + *, + max_inline_bytes: int, + cwd: str, +) -> CommandBudgetDisposition: + profiles = [producer_classifier(tokens) for _connector, tokens in parsed] + if not any(profile is not None for profile in profiles): + return CommandBudgetDisposition.BOUNDED + + disposition = CommandBudgetDisposition.BOUNDED + for index, profile in enumerate(profiles): + if profile is None: + continue + if any(os.path.basename(command_verb(tokens)) == "tee" for _c, tokens in parsed): + return CommandBudgetDisposition.UNKNOWN + connector_after = parsed[index + 1][0] if index + 1 < len(parsed) else "" + stdout_base = _DEST_PIPE if connector_after in {"|", "|&"} else _DEST_MODEL + stderr_base = _DEST_PIPE if connector_after == "|&" else _DEST_MODEL + stdout, stderr, _argv, unknown = _segment_output_destinations( + parsed[index][1], stdout_base=stdout_base, stderr_base=stderr_base, cwd=cwd + ) + if unknown: + return CommandBudgetDisposition.UNKNOWN + stdout_intrinsic, stderr_intrinsic = profile + for stream_intrinsic, destination in ( + (stdout_intrinsic, stdout), + (stderr_intrinsic, stderr), + ): + if stream_intrinsic: + stream_disposition = ( + _trace_pipeline_destination( + parsed, + index, + destination, + max_inline_bytes=max_inline_bytes, + cwd=cwd, + input_already_bounded=True, + ) + if destination == _DEST_PIPE + else CommandBudgetDisposition.BOUNDED + ) + else: + stream_disposition = _trace_pipeline_destination( + parsed, + index, + destination, + max_inline_bytes=max_inline_bytes, + cwd=cwd, + ) + disposition = _combine_budget_dispositions(disposition, stream_disposition) + return disposition + + +def classify_command_output_budget( + command: str, + producer_classifier: OutputBudgetProducerClassifier, + *, + max_inline_bytes: int, + cwd: str = "", + _depth: int = 0, +) -> CommandBudgetDisposition: + """Classify whether every risky producer has a proven bounded output path. + + The caller supplies the producer policy so this shared parser remains + independent of any one guard. Commands outside that policy are BOUNDED; + malformed, over-limit, deeply nested, or unsupported shell structures are + UNKNOWN and therefore cannot establish a guard exception. + """ + if ( + _depth > OUTPUT_BUDGET_MAX_NESTING_DEPTH + or len(command) > OUTPUT_BUDGET_MAX_COMMAND_CHARS + or max_inline_bytes <= 0 + ): + return CommandBudgetDisposition.UNKNOWN + if _OUTPUT_UNSUPPORTED_SYNTAX_RE.search(command): + return CommandBudgetDisposition.UNKNOWN + parsed = tokenize_command_structure(command) + if parsed is None or (not parsed and command.strip()): + return CommandBudgetDisposition.UNKNOWN + if any(connector == "&" for connector, _tokens in parsed): + return CommandBudgetDisposition.UNKNOWN + + root = cwd or os.getcwd() + disposition = _classify_parsed_output_budget( + parsed, producer_classifier, max_inline_bytes=max_inline_bytes, cwd=root + ) + seen: set[str] = set() + for payload in extract_shell_command_payloads(command): + if payload in seen: + continue + seen.add(payload) + child = classify_command_output_budget( + payload, + producer_classifier, + max_inline_bytes=max_inline_bytes, + cwd=root, + _depth=_depth + 1, + ) + disposition = _combine_budget_dispositions(disposition, child) + return disposition + + def extract_redirect_targets(tokens: list[str], cwd: str = "") -> list[str]: """Extract redirect target paths from shlex-tokenized command tokens. @@ -499,6 +878,9 @@ def command_verb_and_args(segment: list[str]) -> tuple[str, list[str]]: start = 0 while start < len(segment): token = segment[start] + if token in {"do", "then", "elif", "else"}: + start += 1 + continue if _is_posix_assignment(token): start += 1 continue diff --git a/src/autoskillit/hooks/_hook_settings.py b/src/autoskillit/hooks/_hook_settings.py index 6d60233952..f09bf18465 100644 --- a/src/autoskillit/hooks/_hook_settings.py +++ b/src/autoskillit/hooks/_hook_settings.py @@ -1,6 +1,6 @@ -"""Shared stdlib-only settings resolver for quota guard hooks. +"""Shared stdlib-only settings bridge for hook subprocesses. -Resolves hook settings from a layered hierarchy that mirrors the +Resolves quota settings from a layered hierarchy that mirrors the dynaconf-backed settings system without importing third-party packages: 1. Function parameter (``cache_path_override`` — for tests/DI) @@ -9,9 +9,10 @@ resolved settings 4. Module default (matches ``config/defaults.yaml``) — lowest -This module is stdlib-only: no third-party imports, no ``autoskillit.*`` -imports. It runs unchanged under the bare Python interpreter used by -Claude Code hook subprocesses. +Other hook policies consume the base/overlay snapshot through +``read_merged_hook_config``. This module is stdlib-only: no third-party +imports, no ``autoskillit.*`` imports. It runs unchanged under the bare +Python interpreter used by Claude Code hook subprocesses. """ import json @@ -44,6 +45,13 @@ {"cache_path", "cache_max_age", "buffer_seconds", "disabled"} ) +# The exact keys the output-budget guard reads from +# hook_config["output_budget_policy"]. Keep this stdlib-only declaration in +# sync with _output_budget_policy_hook_payload() in server/tools/tools_kitchen.py. +OUTPUT_BUDGET_POLICY_HOOK_PAYLOAD_KEYS: frozenset[str] = frozenset( + {"disabled", "small_file_max_bytes", "shell_max_inline_bytes"} +) + # The exact keys that appear in token_usage.json files written by flush_session_log. # The bridge contract test asserts equality between this set and TokenUsageFileEntry # annotations — update both together. diff --git a/src/autoskillit/hooks/guards/AGENTS.md b/src/autoskillit/hooks/guards/AGENTS.md index f75501e0b8..aac88626d8 100644 --- a/src/autoskillit/hooks/guards/AGENTS.md +++ b/src/autoskillit/hooks/guards/AGENTS.md @@ -18,6 +18,7 @@ PreToolUse guard scripts — standalone Python processes enforcing tool-call pol | `skill_orchestration_guard.py` | Blocks `run_skill`/`run_cmd`/`run_python` from L1 skill sessions | | `mcp_health_advisor.py` | Detects MCP server disconnection (dead PID); non-blocking advisory | | `open_kitchen_guard.py` | Blocks `open_kitchen` from headless sessions; writes kitchen marker | +| `output_budget_guard.py` | Blocks high-confidence unbounded recursive searches, JSONL reads, and find operations before output is spent | | `recipe_read_guard.py` | Blocks `run_cmd`/`Bash`/`run_python` from reading recipe/skill/agent files in headless sessions — defense-in-depth against Codex auto-compaction losing recipe content | | `pipeline_step_guard.py` | Advisory unmet-dependency warning for `run_skill`; server-side `_check_pipeline_deps` is primary enforcer | | `planner_result_naming_guard.py` | Blocks Write/Edit with non-canonical planner result filenames (e.g. `P1-A1-WP2a_result.json`); denies with correction hint | diff --git a/src/autoskillit/hooks/guards/output_budget_guard.py b/src/autoskillit/hooks/guards/output_budget_guard.py new file mode 100644 index 0000000000..e0c02ce9ba --- /dev/null +++ b/src/autoskillit/hooks/guards/output_budget_guard.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""PreToolUse guard for high-confidence unbounded shell output shapes. + +The guard is deliberately enumerated: recursive search (R1), JSONL producers +(R2), and directory traversal with find (R3). Shell syntax and descriptor +flow are delegated to the shared stdlib-only command classifier. + +stdlib-only; no autoskillit imports. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +_HOOKS_DIR = str(Path(__file__).resolve().parent.parent) +if _HOOKS_DIR not in sys.path: + sys.path.insert(0, _HOOKS_DIR) + +from _command_classification import ( # type: ignore[import-not-found] # noqa: E402 + CommandBudgetDisposition, + classify_command_output_budget, + command_tokens_without_output_redirections, + command_verb_and_args, +) +from _hook_settings import read_merged_hook_config # type: ignore[import-not-found] # noqa: E402 + +OUTPUT_BUDGET_DENY_TRIGGER: str = "Unbounded command output is prohibited" + +# Must stay in sync with the output_budget_guard HookDef in hook_registry.py. +_EXEMPT_SKILLS: frozenset[str] = frozenset() + +_DEFAULT_SMALL_FILE_MAX_BYTES = 5_000 +_DEFAULT_SHELL_MAX_INLINE_BYTES = 12_000 +_JSONL_PRODUCERS: frozenset[str] = frozenset({"cat", "rg", "grep", "sed", "awk", "jq"}) +_GLOB_CHARS = frozenset("*?[]{}") +_RISKY_SURFACE_RE = re.compile(r"\b(?:rg|grep|cat|sed|awk|jq|find)\b") + +_RG_FLAGS_WITH_VALUE: frozenset[str] = frozenset( + { + "-e", + "--regexp", + "-f", + "--file", + "-g", + "--glob", + "--iglob", + "-t", + "--type", + "-T", + "--type-not", + "-M", + "--max-columns", + "-m", + "--max-count", + "-A", + "--after-context", + "-B", + "--before-context", + "-C", + "--context", + "--encoding", + "--engine", + "--max-depth", + "--path-separator", + "--sort", + "--sortr", + "--threads", + "--type-add", + "--type-clear", + } +) + + +def _positive_int(value: object, default: int) -> int: + return ( + value if isinstance(value, int) and not isinstance(value, bool) and value > 0 else default + ) + + +def _read_policy() -> tuple[bool, int, int]: + try: + config = read_merged_hook_config() + section = config.get("output_budget_policy", {}) + if not isinstance(section, dict): + section = {} + except (OSError, AttributeError, TypeError, json.JSONDecodeError): + section = {} + return ( + section.get("disabled") is True, + _positive_int(section.get("small_file_max_bytes"), _DEFAULT_SMALL_FILE_MAX_BYTES), + _positive_int(section.get("shell_max_inline_bytes"), _DEFAULT_SHELL_MAX_INLINE_BYTES), + ) + + +def _has_flag(args: list[str], *names: str) -> bool: + for arg in args: + if arg in names: + return True + if any(arg.startswith(f"{name}=") for name in names if name.startswith("--")): + return True + if "-q" in names and arg.startswith("-") and not arg.startswith("--") and "q" in arg[1:]: + return True + return False + + +def _rg_targets(args: list[str]) -> list[str]: + """Return literal rg search targets after skipping patterns and option values.""" + positionals: list[str] = [] + explicit_pattern = False + i = 0 + while i < len(args): + arg = args[i] + if arg == "--": + positionals.extend(args[i + 1 :]) + break + if arg in _RG_FLAGS_WITH_VALUE: + if arg in {"-e", "--regexp"}: + explicit_pattern = True + i += 2 + continue + if arg.startswith("--regexp=") or (arg.startswith("-e") and len(arg) > 2): + explicit_pattern = True + i += 1 + continue + if arg.startswith("-"): + i += 1 + continue + positionals.append(arg) + i += 1 + return positionals if explicit_pattern else positionals[1:] + + +def _looks_like_directory(path: str, cwd: Path) -> bool: + if path in {".", "..", "/"} or path.endswith(("/", os.sep)): + return True + if "$" in path or any(char in path for char in _GLOB_CHARS): + return True + candidate = Path(path) + if not candidate.is_absolute(): + candidate = cwd / candidate + try: + if candidate.is_dir(): + return True + except OSError: + return True + # A path with no filename suffix is conservatively directory-shaped. This + # catches not-yet-existing search roots while preserving the explicit + # non-JSONL single-file surface accepted by the protocol. + return not Path(path).suffix + + +def _is_recursive_search(verb: str, args: list[str], cwd: Path) -> bool: + if verb == "grep": + return _has_flag(args, "-r", "-R", "--recursive", "--dereference-recursive") + if verb != "rg": + return False + targets = _rg_targets(args) + return not targets or any(_looks_like_directory(target, cwd) for target in targets) + + +def _jsonl_targets(args: list[str]) -> list[str]: + return [arg for arg in args if ".jsonl" in arg.lower()] + + +def _literal_small_jsonl_files(targets: list[str], cwd: Path, max_bytes: int) -> bool: + if not targets: + return False + total = 0 + root = cwd.resolve() + for raw in targets: + if raw == "-" or "$" in raw or any(char in raw for char in _GLOB_CHARS): + return False + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = root / candidate + try: + if candidate.is_symlink() or not candidate.is_file(): + return False + resolved = candidate.resolve(strict=True) + if os.path.commonpath((str(root), str(resolved))) != str(root): + return False + total += resolved.stat().st_size + except (OSError, RuntimeError, ValueError): + return False + if total > max_bytes: + return False + return True + + +def _is_plain_cat(args: list[str], targets: list[str]) -> bool: + remaining = [arg for arg in args if arg != "--"] + return bool(targets) and remaining == targets + + +def _is_wc_lines(args: list[str], targets: list[str]) -> bool: + return bool(targets) and all(arg in {"-l", "--lines"} or arg in targets for arg in args) + + +def _producer_classifier( + tokens: list[str], *, cwd: Path, small_file_max_bytes: int +) -> tuple[bool, bool] | None: + argv = command_tokens_without_output_redirections(tokens) + if argv is None: + # The shared classifier will separately turn ambiguous descriptor + # syntax into UNKNOWN. Preserve the risky match when possible. + argv = tokens + raw_verb, args = command_verb_and_args(argv) + verb = os.path.basename(raw_verb) + if not verb: + return None + + profiles: list[tuple[bool, bool]] = [] + if _is_recursive_search(verb, args, cwd): + profiles.append((_has_flag(args, "-q", "--quiet"), False)) + + targets = _jsonl_targets(args) + if verb in _JSONL_PRODUCERS and targets: + if verb == "rg" and _has_flag(args, "-q", "--quiet"): + profiles.append((True, False)) + elif ( + verb == "cat" + and _is_plain_cat(args, targets) + and _literal_small_jsonl_files(targets, cwd, small_file_max_bytes) + ): + profiles.append((True, True)) + else: + profiles.append((False, False)) + + if verb == "wc" and targets and _is_wc_lines(args, targets): + profiles.append((True, True)) + + if verb == "find" and args and _looks_like_directory(args[0], cwd): + profiles.append(("-quit" in args, False)) + + if not profiles: + return None + return (all(profile[0] for profile in profiles), all(profile[1] for profile in profiles)) + + +def _classify_command( + command: str, *, cwd: Path, small_file_max_bytes: int, shell_max_inline_bytes: int +) -> CommandBudgetDisposition: + if not _RISKY_SURFACE_RE.search(command): + return CommandBudgetDisposition.BOUNDED + + def classify(tokens: list[str]) -> tuple[bool, bool] | None: + return _producer_classifier(tokens, cwd=cwd, small_file_max_bytes=small_file_max_bytes) + + return classify_command_output_budget( + command, + classify, + max_inline_bytes=shell_max_inline_bytes, + cwd=str(cwd), + ) + + +def main() -> None: + skill_name = os.environ.get("AUTOSKILLIT_SKILL_NAME", "") + if skill_name in _EXEMPT_SKILLS: + sys.exit(0) + + try: + data = json.loads(sys.stdin.read()) + tool_input = data.get("tool_input", {}) + command = tool_input.get("command", "") or tool_input.get("cmd", "") + except (json.JSONDecodeError, AttributeError, OSError, TypeError): + sys.exit(0) + if not isinstance(command, str) or not command: + sys.exit(0) + + disabled, small_file_max_bytes, shell_max_inline_bytes = _read_policy() + if disabled: + sys.exit(0) + + disposition = _classify_command( + command, + cwd=Path.cwd(), + small_file_max_bytes=small_file_max_bytes, + shell_max_inline_bytes=shell_max_inline_bytes, + ) + if disposition is CommandBudgetDisposition.BOUNDED: + sys.exit(0) + + reason = ( + f"{OUTPUT_BUDGET_DENY_TRIGGER}: this R1-R3 producer has no proven byte-bounded " + "stdout/stderr path. Run `rg -l ... 2>&1 | head -c 4000` for bounded " + "discovery, or redirect both descriptors under `.autoskillit/temp/` then read " + "slices." + ) + payload = json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": reason, + } + } + ) + sys.stdout.write(payload + "\n") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/src/autoskillit/hooks/registry.sha256 b/src/autoskillit/hooks/registry.sha256 index b423070953..5557c9de24 100644 --- a/src/autoskillit/hooks/registry.sha256 +++ b/src/autoskillit/hooks/registry.sha256 @@ -1 +1 @@ -098b3c2938aefd3ed36b0a204b698a10b7ca75ac8ae45d05a5b0053a963969b4 +8d81e614712cca43c4ceb2ed0635ace210a5055687a19437806504115b93d46c diff --git a/src/autoskillit/server/_notify.py b/src/autoskillit/server/_notify.py index 92f8a5ccc9..436682be42 100644 --- a/src/autoskillit/server/_notify.py +++ b/src/autoskillit/server/_notify.py @@ -18,10 +18,12 @@ if TYPE_CHECKING: from fastmcp import Context + from autoskillit.pipeline import ToolContext + logger = get_logger(__name__) -def _get_ctx_or_none(): # type: ignore[return] +def _get_ctx_or_none() -> ToolContext | None: from autoskillit.server._state import _get_ctx_or_none as _ctx_none_fn # circular-break return _ctx_none_fn() diff --git a/src/autoskillit/server/tools/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index ec783286cd..fcf591e3f9 100644 --- a/src/autoskillit/server/tools/_execution_helpers.py +++ b/src/autoskillit/server/tools/_execution_helpers.py @@ -162,7 +162,7 @@ def _coerce_scalar(val: object, annotation: object) -> object: actual = non_none[0] # Unwrap Optional[X] / Union[X, None] (typing.Union with __origin__) elif hasattr(annotation, "__origin__") and hasattr(annotation, "__args__"): - ann: typing.Any = annotation # type: ignore[name-defined] + ann: typing.Any = annotation origin = ann.__origin__ args = ann.__args__ if origin is typing.Union: diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index 8fa497d450..f500d90f31 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, TypedDict if TYPE_CHECKING: - from autoskillit.config.settings import QuotaGuardConfig + from autoskillit.config.settings import OutputBudgetConfig, QuotaGuardConfig from autoskillit.pipeline import ToolContext from fastmcp import Context @@ -202,6 +202,12 @@ class QuotaGuardHookPayload(TypedDict): disabled: bool +class OutputBudgetPolicyHookPayload(TypedDict): + disabled: bool + small_file_max_bytes: int + shell_max_inline_bytes: int + + def _quota_guard_hook_payload(cfg: QuotaGuardConfig) -> QuotaGuardHookPayload: """Return the quota_guard section of .hook_config.json for a given config. @@ -219,18 +225,33 @@ def _quota_guard_hook_payload(cfg: QuotaGuardConfig) -> QuotaGuardHookPayload: } +def _output_budget_policy_hook_payload( + cfg: OutputBudgetConfig, +) -> OutputBudgetPolicyHookPayload: + """Return the output-budget guard section of ``.hook_config.json``. + + Keep these keys in sync with ``OUTPUT_BUDGET_POLICY_HOOK_PAYLOAD_KEYS`` + in the stdlib-only hook settings bridge. + """ + return { + "disabled": not cfg.guard_enabled, + "small_file_max_bytes": cfg.small_file_max_bytes, + "shell_max_inline_bytes": cfg.shell_max_inline_bytes, + } + + def _write_hook_config() -> None: - """Write user-configured quota values to .autoskillit/temp/.hook_config.json. + """Write hook policy snapshots to .autoskillit/temp/.hook_config.json. - The hook subprocess (quota_guard.py) reads this file to apply user settings - without importing the autoskillit package. + Hook subprocesses read this file to apply user settings without importing + the autoskillit package. """ from autoskillit.server import _get_ctx, logger # circular-break ctx = _get_ctx() - cfg = ctx.config.quota_guard payload = { - "quota_guard": _quota_guard_hook_payload(cfg), + "quota_guard": _quota_guard_hook_payload(ctx.config.quota_guard), + "output_budget_policy": _output_budget_policy_hook_payload(ctx.config.output_budget), "kitchen_id": ctx.kitchen_id, "git_ops_policy": {}, } diff --git a/src/autoskillit/skills_extended/audit-friction/SKILL.md b/src/autoskillit/skills_extended/audit-friction/SKILL.md index 3b6d74bc71..70720c92ee 100644 --- a/src/autoskillit/skills_extended/audit-friction/SKILL.md +++ b/src/autoskillit/skills_extended/audit-friction/SKILL.md @@ -60,32 +60,35 @@ Friction is any pattern where repeated effort yields no progress: ## Friction Signal Patterns -Logs are too large to read in full. All analysis uses targeted grep commands to surface signal lines, then reads only the surrounding context (a few lines via `-A`/`-B`) to confirm the event. Use this keyword battery against each file: +Logs are too large to read in full. All analysis uses targeted search commands to surface signal lines, then reads only the surrounding context (a few lines via `-A`/`-B`) to confirm the event. Use this keyword battery against each file: ```bash # Tool errors — direct flag -grep -n '"is_error".*true' FILE | head -100 +rg -n -M 500 '"is_error".*true' FILE 2>&1 | head -c 12000 # Error keywords in content -rg -i '"not found|permission denied|no such file|command not found|ENOENT|"failed|"cannot|"error"|exit code [1-9]|Traceback|AssertionError' FILE | head -200 +rg -i -M 500 '"not found|permission denied|no such file|command not found|ENOENT|"failed|"cannot|"error"|exit code [1-9]|Traceback|AssertionError' FILE 2>&1 | head -c 12000 # Human correction language (look inside "type":"human" lines) -rg -n '"type":"human"' FILE | rg -i 'wrong|"no,|try again|you already|that.s not|incorrect|revert|undo|stop' | head -50 +rg -n -M 500 '"type":"human"' FILE 2>&1 | rg -i 'wrong|"no,|try again|you already|that.s not|incorrect|revert|undo|stop' 2>&1 | head -c 12000 # Test / build failures -rg -i 'FAILED|test.*failed|build.*failed|compile.*error|syntax error' FILE | head -100 +rg -i -M 500 'FAILED|test.*failed|build.*failed|compile.*error|syntax error' FILE 2>&1 | head -c 12000 # Consecutive tool call loops — extract tool names in document order, show runs of 2+ back-to-back -grep -o '"name":"[^"]*"' FILE | awk -F'"' '{print $4}' | uniq -c | awk '$1>=2' | head -30 +rg -o -M 500 '"name":"[^"]*"' FILE 2>&1 | awk -F'"' '{print $4}' 2>&1 | uniq -c 2>&1 | awk '$1>=2' 2>&1 | head -c 12000 # Once you know which tool is looping, get its line numbers to find the range: -grep -n '"name":"TOOL_NAME"' FILE | head -30 +rg -n -M 500 '"name":"TOOL_NAME"' FILE 2>&1 | head -c 12000 # Permission / access blockers -rg -i 'permission denied|access denied|not allowed|forbidden' FILE | head -50 +rg -i -M 500 'permission denied|access denied|not allowed|forbidden' FILE 2>&1 | head -c 12000 + +# Confirm a match with bounded surrounding context +rg -n -M 500 -A 10 -B 10 'CONFIRMING_PATTERN' FILE 2>&1 | head -c 12000 ``` -For each match batch, pull context (`-A 10 -B 10`) to confirm it is a real friction event rather than incidental text, then record the line range and category. +For each match batch, use the bounded `-A 10 -B 10` command above to confirm it is a real friction event rather than incidental text, then record the line range and category. ## Workflow @@ -163,7 +166,7 @@ After all Haiku agents return, group all indicators by category. Dispatch **Sonn Each Sonnet subagent should: - Receive the list of `(file, line_start, line_end)` pointers for its assigned category -- Read those specific line ranges to confirm or reclassify each indicator (Haiku may misfire on edge cases) +- Read those specific line ranges with the bounded `rg -n -M 500 -A 10 -B 10 ... 2>&1 | head -c 12000` form to confirm or reclassify each indicator (Haiku may misfire on edge cases) - Expand the line range further if context is insufficient to understand the event - For confirmed instances: extract session date, the specific sequence of events, and what was blocking progress - Count confirmed occurrences and distinct sessions affected diff --git a/tests/arch/test_python_no_hardcoded_temp.py b/tests/arch/test_python_no_hardcoded_temp.py index 247373bda2..5580913fd4 100644 --- a/tests/arch/test_python_no_hardcoded_temp.py +++ b/tests/arch/test_python_no_hardcoded_temp.py @@ -67,6 +67,7 @@ "recipe/_cmd_rpc_issues.py": "ensure_results default temp_subdir matches canonical default", "hooks/skill_load_post_hook.py": "stdlib-only hook; cannot use resolve_temp_dir()", "hooks/guards/skill_load_guard.py": "stdlib-only guard; cannot use resolve_temp_dir()", + "hooks/guards/output_budget_guard.py": ("stdlib-only guard; cannot use resolve_temp_dir()"), "core/runtime/session_provenance.py": "IL-0 stdlib-only module; cannot use resolve_temp_dir()", "core/runtime/kitchen_state.py": "IL-0 stdlib-only; reads hook config from canonical path", "workspace/skill_format.py": "write_paths validation accepts resolved canonical temp prefix", diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 994cac4ec3..328490ec20 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -859,6 +859,9 @@ def test_no_subpackage_exceeds_10_files() -> None: (interpreter/wrapper detection) for all command-classifying guards. quota_guard_state_post_hook.py is a stdlib-only PostToolUse script that maintains the per-session quota-disable marker. Exempt at 15 files. + output_budget_guard.py is a standalone stdlib-only PreToolUse script that + enforces the output-budget protocol without importing package runtime code. + Exempt at 33 files. pipeline/ — REQ-CNST-003-E7: pipeline/ added github_api_log.py for session-scoped GitHub API request tracking (DefaultGitHubApiLog accumulator + GitHubApiEntry). Exempt at 12 files. @@ -885,7 +888,7 @@ def test_no_subpackage_exceeds_10_files() -> None: # auto-init dependency tracker + REVIEW_BEFORE_PLAN ordering telemetry) # +_backend_compat.py (shared target-resolution + fail-closed compatibility gate # for direct headless executor callers — report_bug, prepare_issue, enrich_issues) - "hooks/guards": 32, # +fleet_claim_guard, +reset_resume_gate, +recipe_read_guard + "hooks/guards": 33, # +output_budget_guard (REQ-CNST-003-E6) "execution/backends": 12, # +_composite_locator.py, +_probe_cache.py } violations: list[str] = [] @@ -951,13 +954,13 @@ def test_data_directories_are_not_python_packages() -> None: "REQ-CNST-010-E1: canonical type registry — wide surface required to prevent " "circular imports; all enums/protocols/constants consolidated here", ), - "_command_classification.py": ( - 1100, + "hooks/_command_classification.py": ( + 1500, "REQ-CNST-010-E10: shared command-classification primitive consumed by all " "command-inspecting guards — tokenization, shell-payload extraction, " - "interpreter-write detection, protected-path reads, and recursive " - "payload segmentation for nested-shell verb-position enforcement; " - "stdlib-only stdlib boundary prevents splitting across modules.", + "interpreter-write detection, protected-path reads, recursive payload " + "segmentation, and descriptor-flow output-budget analysis; the stdlib-only " + "hook boundary and shared parser prevent policy drift across guard processes.", ), "session.py": ( 1060, @@ -981,7 +984,7 @@ def test_data_directories_are_not_python_packages() -> None: "closure-scoped _spawn_error, and _write_pid fail-closed contract add ~33 lines", ), "tools_kitchen.py": ( - 1540, + 1560, "REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require " "inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) " "for ingredient key validation; splitting would cross import-layer boundaries; " @@ -1013,9 +1016,12 @@ def test_data_directories_are_not_python_packages() -> None: "; _auto_init_pipeline_tracker tool_ctx param typed as ToolContext under " "TYPE_CHECKING instead of Any, matching _active_order_ids_for_kitchen's " "established pattern (+1 net line)" +<<<<<<< HEAD "; serve_recipe backend_capabilities_map threading at get_recipe + deferred-recall + " "normal open_kitchen paths with safe _distinct_backends extraction from " - "_effective_backend_map and tool_ctx.backend.name (+28 net lines)", + "_effective_backend_map and tool_ctx.backend.name (+28 net lines)" + "; output-budget hook payload type, serializer, and hook-config bridge " + "(+21 net lines)", ), "tools_execution.py": ( 1600, diff --git a/tests/docs/test_doc_counts.py b/tests/docs/test_doc_counts.py index 8f284cb2ae..b5fa8e0eb7 100644 --- a/tests/docs/test_doc_counts.py +++ b/tests/docs/test_doc_counts.py @@ -319,8 +319,14 @@ def test_skill_visibility_states_142_skills() -> None: _assert_doc_states_number(DOCS_DIR / "skills" / "visibility.md", "skills total", 142) -def test_safety_hooks_states_24_hooks() -> None: - _assert_doc_states_number(DOCS_DIR / "safety" / "hooks.md", "hooks total", 26) +def test_safety_hook_counts_match_registry() -> None: + counts = _count_hooks_by_event() + text = _read(DOCS_DIR / "safety" / "hooks.md") + + assert f"AutoSkillit registers {sum(counts.values())} Claude Code hook scripts" in text + assert f"## PreToolUse hooks ({counts['PreToolUse']})" in text + assert f"## PostToolUse hooks ({counts['PostToolUse']})" in text + assert f"## SessionStart hook ({counts['SessionStart']})" in text def test_configuration_states_quota_thresholds() -> None: diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index f1c46574dd..e1080f06b0 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -158,4 +158,4 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_codex_deterministic_conformance.py` | Sealed-enum vocabulary, hook event format, and config.toml schema template conformance tests with --update-fixtures review gate | | `test_cmd_builder.py` | CmdBuilder ordering invariant and CmdSpec origin tests | | `test_coding_agent_backend_conformance.py` | Parametrized conformance tests for all CodingAgentBackend implementations via BackendContractBase | -| `test_cli_conformance_probes.py` | Live Codex CLI conformance probes: CODEX_SMOKE_TEST-gated probe class wiring real codex exec through shared assertion functions with ProbeCache and CanaryIssueUpdater | +| `test_cli_conformance_probes.py` | Live backend CLI conformance probes: schema checks plus isolated output-budget deny round trips with ProbeCache and shared error discrimination | diff --git a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json index d4b5081d47..e4b2bf0c39 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json +++ b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json @@ -1,5 +1,5 @@ { - "_registry_hash": "098b3c2938aefd3ed36b0a204b698a10b7ca75ac8ae45d05a5b0053a963969b4", + "_registry_hash": "8d81e614712cca43c4ceb2ed0635ace210a5055687a19437806504115b93d46c", "hooks": { "PostToolUse": [ { @@ -186,6 +186,11 @@ "command": "python3 SANITIZED_HOOKS_DIR/_dispatch.py guards/test_runner_guard", "trusted_hash": "SANITIZED_FOR_DETERMINISM", "type": "command" + }, + { + "command": "python3 SANITIZED_HOOKS_DIR/_dispatch.py guards/output_budget_guard", + "trusted_hash": "SANITIZED_FOR_DETERMINISM", + "type": "command" } ], "matcher": "Bash|mcp__.*autoskillit.*__run_cmd" diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index 648d3333d6..662433a38e 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -1,10 +1,9 @@ -"""Live Codex CLI conformance probes — CODEX_SMOKE_TEST-gated. +"""Live backend CLI conformance probes — backend smoke-test gated. -Wires real ``codex exec --json`` output through the shared -``_conformance_assertions.py`` assertion helpers. Each probe checks -``ProbeCache`` before invoking the CLI, delegates to assertion functions, -discriminates OSError/TimeoutExpired (network) from AssertionError (schema), -and records results via ``CanaryState`` + ``CanaryIssueUpdater``. +Wires real CLI output through shared assertion helpers. Each probe checks +``ProbeCache`` before invoking the CLI, delegates to assertion functions, and +discriminates OSError/TimeoutExpired (network) from AssertionError (schema). +The original Codex schema probes also record ``CanaryState`` issue updates. """ from __future__ import annotations @@ -13,9 +12,10 @@ import os import shutil import subprocess +from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path -from typing import NamedTuple +from typing import NamedTuple, Protocol, TypeVar import pytest @@ -24,11 +24,13 @@ CanaryState, ErrorKind, ) +from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config from autoskillit.execution.backends._probe_cache import ( ProbeResult, read_probe_cache, write_probe_cache, ) +from autoskillit.hook_registry import generate_hooks_json from tests.execution.backends._conformance_assertions import ( assert_config_schema, assert_hook_event_format, @@ -74,6 +76,37 @@ class _CodexProbeOutput(NamedTuple): cli_version: str +class _VersionedProbeOutput(Protocol): + cli_version: str + + +_ProbeOutputT = TypeVar("_ProbeOutputT", bound=_VersionedProbeOutput) + + +def _run_probe_with_discrimination( + probe_name: str, + cli_version: str, + probe_fn: Callable[[], _ProbeOutputT], + assertion_fn: Callable[[_ProbeOutputT], None], + *, + record_success: Callable[[str], None], + record_failure: Callable[[ErrorKind, str, str, str], None], +) -> None: + """Run one live probe and distinguish transport failures from contract drift.""" + try: + probe_output = probe_fn() + except (OSError, subprocess.TimeoutExpired) as exc: + record_failure(ErrorKind.NETWORK, probe_name, cli_version, str(exc)) + raise + + try: + assertion_fn(probe_output) + except AssertionError as exc: + record_failure(ErrorKind.SCHEMA, probe_name, probe_output.cli_version, str(exc)) + raise + record_success(probe_output.cli_version) + + def _get_codex_version() -> str: try: result = subprocess.run( @@ -200,16 +233,6 @@ def _record_failure( ), ) - def _run_probe_with_discrimination( - self, probe_name: str, probe_output: _CodexProbeOutput, assertion_fn - ) -> None: - try: - assertion_fn(probe_output) - self._record_success(probe_output.cli_version) - except AssertionError as exc: - self._record_failure(ErrorKind.SCHEMA, probe_name, probe_output.cli_version, str(exc)) - raise - _cls_probe_output: _CodexProbeOutput | None = None _cls_probe_exc: BaseException | None = None @@ -227,13 +250,6 @@ def _get_probe_output(cls) -> _CodexProbeOutput: def test_ndjson_event_vocabulary_conforms(self) -> None: self._check_cache() - try: - probe_output = self._get_probe_output() - except (OSError, subprocess.TimeoutExpired) as exc: - self._record_failure( - ErrorKind.NETWORK, "ndjson_event_vocabulary", _get_codex_version(), str(exc) - ) - raise def _assert(output: _CodexProbeOutput) -> None: assert_no_unknown_event_types(output.events) @@ -241,41 +257,245 @@ def _assert(output: _CodexProbeOutput) -> None: assert_turn_completed_usage_nonzero(output.events) assert_vocabulary_coverage(output.events, {"thread.started", "turn.completed"}) - self._run_probe_with_discrimination("ndjson_event_vocabulary", probe_output, _assert) + _run_probe_with_discrimination( + "ndjson_event_vocabulary", + _get_codex_version(), + self._get_probe_output, + _assert, + record_success=self._record_success, + record_failure=self._record_failure, + ) def test_hook_firing_codex_status(self) -> None: self._check_cache() - try: - probe_output = self._get_probe_output() - except (OSError, subprocess.TimeoutExpired) as exc: - self._record_failure( - ErrorKind.NETWORK, "hook_firing_codex_status", _get_codex_version(), str(exc) - ) - raise def _assert(output: _CodexProbeOutput) -> None: if not output.config_dict: pytest.skip("No session_configuration event in NDJSON output") assert_hook_event_format(output.config_dict) - self._run_probe_with_discrimination("hook_firing_codex_status", probe_output, _assert) + _run_probe_with_discrimination( + "hook_firing_codex_status", + _get_codex_version(), + self._get_probe_output, + _assert, + record_success=self._record_success, + record_failure=self._record_failure, + ) def test_config_acceptance(self) -> None: self._check_cache() - try: - probe_output = self._get_probe_output() - except (OSError, subprocess.TimeoutExpired) as exc: - self._record_failure( - ErrorKind.NETWORK, "config_acceptance", _get_codex_version(), str(exc) - ) - raise def _assert(output: _CodexProbeOutput) -> None: if not output.config_dict: pytest.skip("No session_configuration event in NDJSON output") assert_config_schema(output.config_dict, output.cli_version) - self._run_probe_with_discrimination("config_acceptance", probe_output, _assert) + _run_probe_with_discrimination( + "config_acceptance", + _get_codex_version(), + self._get_probe_output, + _assert, + record_success=self._record_success, + record_failure=self._record_failure, + ) + + +_OUTPUT_BUDGET_CANARY_COMMAND = "rg -n output_budget_probe ." +_OUTPUT_BUDGET_CANARY_PROMPT = ( + "This is a hook conformance probe. Use the shell tool exactly once to run " + f"`{_OUTPUT_BUDGET_CANARY_COMMAND}` without changing or bounding it. " + "After the tool is denied, repeat the complete denial reason verbatim and stop. " + "Do not run any other tool." +) + +_skip_unless_codex_output_budget_smoke = pytest.mark.skipif( + not os.environ.get("CODEX_SMOKE_TEST") + or not shutil.which("codex") + or (not os.environ.get("CODEX_API_KEY") and not os.environ.get("OPENAI_API_KEY")), + reason=( + "Set CODEX_SMOKE_TEST=1 and CODEX_API_KEY or OPENAI_API_KEY to run the " + "isolated Codex output-budget hook probe" + ), +) + +_skip_unless_claude_output_budget_smoke = pytest.mark.skipif( + not os.environ.get("CLAUDE_CODE_SMOKE_TEST") + or not shutil.which("claude") + or (not os.environ.get("ANTHROPIC_API_KEY") and not os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")), + reason=( + "Set CLAUDE_CODE_SMOKE_TEST=1 and an environment-provided Claude credential " + "to run the isolated Claude Code output-budget hook probe" + ), +) + + +class _DenyRoundTripOutput(NamedTuple): + transcript: str + cli_version: str + + +def _isolated_cli_env(tmp_path: Path, workspace: Path) -> tuple[dict[str, str], Path, Path]: + home = tmp_path / "home" + codex_home = tmp_path / "codex-home" + claude_config = tmp_path / "claude-config" + for directory in (home, codex_home, claude_config): + directory.mkdir(parents=True) + + env = dict(os.environ) + env.update( + { + "HOME": str(home), + "CODEX_HOME": str(codex_home), + "CLAUDE_CONFIG_DIR": str(claude_config), + "XDG_CONFIG_HOME": str(home / ".config"), + "XDG_DATA_HOME": str(home / ".local" / "share"), + "AUTOSKILLIT_CWD": str(workspace), + "AUTOSKILLIT_ALLOWED_WRITE_PREFIXES": str(workspace), + } + ) + return env, codex_home, claude_config + + +def _cli_version(binary: str, env: dict[str, str]) -> str: + try: + result = subprocess.run( # noqa: S603 + [binary, "--version"], + capture_output=True, + text=True, + timeout=10, + env=env, + ) + return result.stdout.strip() or result.stderr.strip() or "unknown" + except (OSError, subprocess.TimeoutExpired): + return "unknown" + + +def _run_output_budget_deny_probe(backend: str, tmp_path: Path) -> _DenyRoundTripOutput: + tmp_path.mkdir(parents=True, exist_ok=True) + workspace = tmp_path / "workspace" + workspace.mkdir() + env, codex_home, claude_config = _isolated_cli_env(tmp_path, workspace) + + if backend == "codex": + config_path = codex_home / "config.toml" + sync_hooks_to_codex_config(config_path=config_path) + init_result = subprocess.run( # noqa: S603 + ["git", "init", "-q"], + cwd=workspace, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + if init_result.returncode != 0: + raise OSError(f"isolated git init failed: {init_result.stderr}") + command = [ + "codex", + "exec", + "--json", + "--sandbox", + "workspace-write", + _OUTPUT_BUDGET_CANARY_PROMPT, + ] + elif backend == "claude-code": + settings_path = claude_config / "settings.json" + settings_path.write_text( + json.dumps(generate_hooks_json(), indent=2) + "\n", + encoding="utf-8", + ) + command = [ + "claude", + "-p", + _OUTPUT_BUDGET_CANARY_PROMPT, + "--output-format", + "stream-json", + "--verbose", + "--dangerously-skip-permissions", + "--tools", + "Bash", + ] + else: # pragma: no cover - callers pass a sealed backend literal + raise ValueError(f"unsupported probe backend: {backend}") + + timeout = int(os.environ.get("OUTPUT_BUDGET_HOOK_SMOKE_TIMEOUT", "120")) + result = subprocess.run( # noqa: S603 + command, + cwd=workspace, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + transcript = result.stdout + "\n" + result.stderr + if result.returncode != 0: + raise OSError(f"{backend} deny probe failed with rc={result.returncode}: {transcript}") + return _DenyRoundTripOutput( + transcript=transcript, + cli_version=_cli_version(command[0], env), + ) + + +def _assert_output_budget_deny_round_trip(output: _DenyRoundTripOutput) -> None: + assert _OUTPUT_BUDGET_CANARY_COMMAND in output.transcript + assert "rg -l" in output.transcript + assert "head -c 4000" in output.transcript + assert ".autoskillit/temp/" in output.transcript + + +def _exercise_output_budget_deny_probe(backend: str, tmp_path: Path) -> None: + workspace = tmp_path / "version-workspace" + workspace.mkdir() + version_env, _, _ = _isolated_cli_env(tmp_path / "version-env", workspace) + binary = "codex" if backend == "codex" else "claude" + cli_version = _cli_version(binary, version_env) + cache_path = tmp_path / f"{backend}-output-budget-probe-cache.json" + cached = read_probe_cache(cache_path, cli_version) + if cached is not None and cached.passed: + pytest.skip(f"Output-budget deny probe cached as passed for {cli_version}") + + def _record(passed: bool, version: str, detail: str | None) -> None: + write_probe_cache( + cache_path, + ProbeResult( + cli_version=version, + passed=passed, + failure_detail=detail, + probe_timestamp=datetime.now(UTC).isoformat(), + ), + ) + + def _record_success(version: str) -> None: + _record(True, version, None) + + def _record_failure( + kind: ErrorKind, + probe_name: str, + version: str, + detail: str, + ) -> None: + _record(False, version, f"{kind.value}:{probe_name}:{detail}") + + _run_probe_with_discrimination( + f"output_budget_deny_round_trip_{backend}", + cli_version, + lambda: _run_output_budget_deny_probe(backend, tmp_path / "round-trip"), + _assert_output_budget_deny_round_trip, + record_success=_record_success, + record_failure=_record_failure, + ) + + +@_skip_unless_codex_output_budget_smoke +class TestCodexOutputBudgetDenyRoundTrip: + def test_hook_fires_and_reason_reaches_model(self, tmp_path: Path) -> None: + _exercise_output_budget_deny_probe("codex", tmp_path) + + +@_skip_unless_claude_output_budget_smoke +class TestClaudeCodeOutputBudgetDenyRoundTrip: + def test_hook_fires_and_reason_reaches_model(self, tmp_path: Path) -> None: + _exercise_output_budget_deny_probe("claude-code", tmp_path) @_skip_unless_claude_code_smoke diff --git a/tests/execution/backends/test_codex_fixtures.py b/tests/execution/backends/test_codex_fixtures.py index 4d0032bdf5..c0c39458e7 100644 --- a/tests/execution/backends/test_codex_fixtures.py +++ b/tests/execution/backends/test_codex_fixtures.py @@ -15,6 +15,7 @@ CODEX_SCHEMA_VERSION, HAPPY_PATH_SINGLE_TURN, HAPPY_PATH_V0136, + LARGE_EMBEDDED_PAYLOAD_V1, MARKER_DETECTION_V0136, MULTI_TURN_WITH_COMPACTION, SESSION_WITH_MCP_TOOL_CALL, @@ -58,7 +59,17 @@ def test_fixture_path_returns_existing_file(self) -> None: assert fixture_path(name).is_file() def test_all_exports_count(self) -> None: - assert len(CODEX_ALL) == 10 + assert len(CODEX_ALL) == 11 + + def test_large_embedded_payload_is_realistic_jsonl_data(self) -> None: + path = fixture_path(LARGE_EMBEDDED_PAYLOAD_V1) + assert path.stat().st_size >= 120_000 + lines = path.read_text().splitlines() + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["kind"] == "large_embedded_payload" + assert isinstance(record["payload"], str) + assert len(record["payload"]) >= 120_000 class TestCodexFixtureValidity: diff --git a/tests/execution/backends/test_hook_deny_efficacy_probe.py b/tests/execution/backends/test_hook_deny_efficacy_probe.py index 1519566cde..7b3b3258a7 100644 --- a/tests/execution/backends/test_hook_deny_efficacy_probe.py +++ b/tests/execution/backends/test_hook_deny_efficacy_probe.py @@ -195,6 +195,9 @@ def _invoke_guard( return json.loads(stdout) +_OUTPUT_BUDGET_PROBE_COMMAND = "rg -n output_budget_probe ." + + def _build_payload(tool_class: str, session_mode: str) -> dict: """Deep-copy the deny-triggering payload for a tool class, plus subagent fields.""" payload = copy.deepcopy(TOOL_CLASS_PAYLOADS[tool_class]) @@ -314,3 +317,56 @@ def test_probe_collection_count() -> None: deny-mechanism hook automatically fails this test until the matrix adapts. """ assert len(_collect_probe_params()) == EXPECTED_TOTAL_PROBE_COUNT + + +def test_output_budget_guard_has_non_inert_bash_and_run_cmd_rows(tmp_path: Path) -> None: + script = "guards/output_budget_guard.py" + hookdef_idx, hookdef = next( + (idx, hookdef) for idx, hookdef in enumerate(HOOK_REGISTRY) if script in hookdef.scripts + ) + expected_rows = { + (tool_class, session_mode, backend) + for tool_class in ("Bash", "mcp_run_cmd") + for session_mode in SESSION_MODES + for backend in BACKENDS + } + actual_rows = { + (values[2], values[3], values[4]) + for parameter in _collect_probe_params() + if (values := parameter.values)[0] == hookdef_idx and values[1] == script + } + assert actual_rows == expected_rows + + codex_entries = generate_codex_hooks_config()["PreToolUse"] + codex_entry = next(entry for entry in codex_entries if entry["matcher"] == hookdef.matcher) + codex_hook = next( + hook for hook in codex_entry["hooks"] if "guards/output_budget_guard" in hook["command"] + ) + assert codex_hook["trusted_hash"] + + env = { + **BACKEND_ENV["codex"], + "AUTOSKILLIT_ALLOWED_WRITE_PREFIXES": str(tmp_path), + "AUTOSKILLIT_CWD": str(tmp_path), + } + (tmp_path / ".autoskillit" / "temp").mkdir(parents=True) + for tool_class in ("Bash", "mcp_run_cmd"): + payload = _build_payload(tool_class, "interactive") + tool_input = payload["tool_input"] + if tool_class == "Bash": + tool_input["command"] = _OUTPUT_BUDGET_PROBE_COMMAND + else: + tool_input.pop("command") + tool_input["cmd"] = _OUTPUT_BUDGET_PROBE_COMMAND + result = _invoke_guard( + HOOKS_DIR / script, + payload, + env, + tmp_path, + ) + hook_output = result["hookSpecificOutput"] + assert hook_output["permissionDecision"] == "deny" + reason = hook_output["permissionDecisionReason"] + assert "rg -l" in reason + assert "head -c 4000" in reason + assert ".autoskillit/temp/" in reason diff --git a/tests/fixtures/codex/__init__.py b/tests/fixtures/codex/__init__.py index ce88f386c0..c8fa5eb36c 100644 --- a/tests/fixtures/codex/__init__.py +++ b/tests/fixtures/codex/__init__.py @@ -1,4 +1,4 @@ -"""Codex CLI NDJSON test fixtures.""" +"""Codex CLI event and data-payload test fixtures.""" from __future__ import annotations @@ -10,6 +10,7 @@ HAPPY_PATH_SINGLE_TURN: str = "happy_path_single_turn_v0133.ndjson" HAPPY_PATH_V0136: str = "happy_path_v0136.ndjson" MARKER_DETECTION_V0136: str = "marker_detection_v0136.ndjson" +LARGE_EMBEDDED_PAYLOAD_V1: str = "large_embedded_payload_v1.jsonl" MULTI_TURN_WITH_COMPACTION: str = "multi_turn_with_compaction_v0133.ndjson" TURN_FAILED_ERROR: str = "turn_failed_error_v0133.ndjson" SESSION_WITH_REASONING: str = "session_with_reasoning_v0133.ndjson" @@ -26,6 +27,7 @@ def fixture_path(name: str) -> Path: "CODEX_SCHEMA_VERSION", "HAPPY_PATH_SINGLE_TURN", "HAPPY_PATH_V0136", + "LARGE_EMBEDDED_PAYLOAD_V1", "MARKER_DETECTION_V0136", "MULTI_TURN_WITH_COMPACTION", "SESSION_WITH_MCP_TOOL_CALL", diff --git a/tests/fixtures/codex/large_embedded_payload_v1.jsonl b/tests/fixtures/codex/large_embedded_payload_v1.jsonl new file mode 100644 index 0000000000..6133452967 --- /dev/null +++ b/tests/fixtures/codex/large_embedded_payload_v1.jsonl @@ -0,0 +1 @@ +{"kind":"large_embedded_payload","payload":"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"} diff --git a/tests/hooks/AGENTS.md b/tests/hooks/AGENTS.md index a91e744478..2e82eb93eb 100644 --- a/tests/hooks/AGENTS.md +++ b/tests/hooks/AGENTS.md @@ -10,7 +10,7 @@ Hook script behavior, registration, and bridge tests. | `conftest.py` | Per-test project-root CWD isolation for every hook test (autouse `_isolate_hook_cwd`) | | `test_fmt_status.py` | Tests for autoskillit.hooks.formatters._fmt_status | | `test_hook_dispatch.py` | Tests for the stable hook dispatcher (_dispatch.py) — resolution, retired mapping, graceful degrade | -| `test_hook_config_bridge.py` | Regression tests for the quota_guard.py → .hook_config.json bridge | +| `test_hook_config_bridge.py` | Regression tests for quota and output-budget policy serialization into the stdlib hook config bridge | | `test_hook_executability.py` | Tests for hook command executability — validates invocation path via subprocess | | `test_hook_registration_coverage.py` | Structural test: every hook script is registered in HOOK_REGISTRY | | `test_hook_registry.py` | Tests for hook_registry.py — L0 hook identity model | @@ -36,6 +36,7 @@ Hook script behavior, registration, and bridge tests. | `test_compose_pr_body_guard.py` | Tests for compose_pr_body_guard.py PreToolUse hook — body file Closes #N validation | | `test_command_classification.py` | Tests for the shared _command_classification.py command classification primitives | | `test_command_classification_git.py` | Tests for the git command classification primitives (is_git_command, extract_git_subcommand_and_flags) | +| `test_output_budget_guard.py` | Tests for output_budget_guard.py PreToolUse classification, bounded exceptions, config bridge, and registration | | `test_pr_create_guard.py` | Tests for pr_create_guard.py interpreter bypass detection | | `test_planner_gh_discovery_guard.py` | Tests for planner_gh_discovery_guard.py interpreter bypass detection | | `test_ingredient_lock_guard.py` | Tests for ingredient_lock_guard.py PreToolUse hook: deny/allow, fail-open, pipeline scoping | diff --git a/tests/hooks/test_codex_hooks_format_contract.py b/tests/hooks/test_codex_hooks_format_contract.py index 80ffff98bb..cdc9a0c14c 100644 --- a/tests/hooks/test_codex_hooks_format_contract.py +++ b/tests/hooks/test_codex_hooks_format_contract.py @@ -8,6 +8,7 @@ from autoskillit.execution import _serialize_toml from autoskillit.execution.backends._codex_hooks import generate_codex_hooks_config +from autoskillit.hook_registry import HOOK_REGISTRY, generate_hooks_json pytestmark = [pytest.mark.layer("hooks"), pytest.mark.medium] @@ -53,3 +54,20 @@ def test_no_event_scalar_in_toml_entries(self): assert "event" not in entry, ( f"Entry under hooks.{event_type} has redundant 'event' scalar" ) + + def test_output_budget_guard_is_wired_with_identical_matchers(self): + expected = next( + hook for hook in HOOK_REGISTRY if "guards/output_budget_guard.py" in hook.scripts + ) + codex_entries = generate_codex_hooks_config()["PreToolUse"] + codex_entry = next( + entry for entry in codex_entries if entry["matcher"] == expected.matcher + ) + claude_entries = generate_hooks_json()["hooks"]["PreToolUse"] + claude_entry = next( + entry for entry in claude_entries if entry["matcher"] == expected.matcher + ) + + assert any("output_budget_guard" in hook["command"] for hook in codex_entry["hooks"]) + assert any("output_budget_guard" in hook["command"] for hook in claude_entry["hooks"]) + assert codex_entry["matcher"].encode() == claude_entry["matcher"].encode() diff --git a/tests/hooks/test_command_classification.py b/tests/hooks/test_command_classification.py index 437e09ac5c..bd049f08e6 100644 --- a/tests/hooks/test_command_classification.py +++ b/tests/hooks/test_command_classification.py @@ -860,3 +860,130 @@ def test_strip_heredoc_bodies(command: str, expected_stripped: str) -> None: from autoskillit.hooks._command_classification import strip_heredoc_bodies assert strip_heredoc_bodies(command) == expected_stripped + + +class TestOutputBudgetStructure: + def test_preserves_pipeline_and_sequence_connectors(self): + from autoskillit.hooks._command_classification import tokenize_command_structure + + parsed = tokenize_command_structure("risky | one |& two && three; four") + assert parsed is not None + assert [connector for connector, _tokens in parsed] == ["", "|", "|&", "&&", ";"] + + @pytest.mark.parametrize("prefix", ["do", "then", "elif", "else"]) + def test_compound_control_prefix_is_not_the_verb(self, prefix): + from autoskillit.hooks._command_classification import command_verb_and_args + + assert command_verb_and_args([prefix, "rg", "pat", "src/"])[0] == "rg" + + def test_descriptor_duplication_remains_one_ordered_token(self): + from autoskillit.hooks._command_classification import tokenize_command_structure + + parsed = tokenize_command_structure("risky 2>&1 | head -c 4") + assert parsed == [("", ["risky", "2>&1"]), ("|", ["head", "-c", "4"])] + + +class TestOutputBudgetDisposition: + @staticmethod + def _classify(command: str, *, cwd: str = "/workspace", max_bytes: int = 12_000): + from autoskillit.hooks._command_classification import ( + classify_command_output_budget, + command_verb, + ) + + def producer(tokens): + return (False, False) if command_verb(tokens) == "risky" else None + + return classify_command_output_budget( + command, producer, max_inline_bytes=max_bytes, cwd=cwd + ) + + @pytest.mark.parametrize( + "command", + [ + "risky 2>&1 | head -c 4000", + "risky |& head -c 4000", + "risky >.autoskillit/temp/out 2>&1", + ], + ) + def test_proven_bounds(self, command): + from autoskillit.hooks._command_classification import CommandBudgetDisposition + + assert self._classify(command) is CommandBudgetDisposition.BOUNDED + + @pytest.mark.parametrize( + "command", + [ + "risky | head -c 4000", + "risky && head -c 4000", + "risky 2>err.txt", + "risky 2>&1 >.autoskillit/temp/out", + ], + ) + def test_unbounded_descriptor_paths(self, command): + from autoskillit.hooks._command_classification import CommandBudgetDisposition + + assert self._classify(command) is CommandBudgetDisposition.HAZARDOUS + + @pytest.mark.parametrize( + "command", + [ + "risky 2>&1 | tee copy | head -c 4000", + "risky >$DYNAMIC 2>&1", + "cat <(risky)", + "risky 3>&1 2>&1 | head -c 4000", + ], + ) + def test_unsupported_shell_shapes_are_unknown(self, command): + from autoskillit.hooks._command_classification import CommandBudgetDisposition + + assert self._classify(command) is CommandBudgetDisposition.UNKNOWN + + def test_command_length_boundary(self): + from autoskillit.hooks._command_classification import ( + OUTPUT_BUDGET_MAX_COMMAND_CHARS, + CommandBudgetDisposition, + ) + + at_limit = "risky" + (" " * (OUTPUT_BUDGET_MAX_COMMAND_CHARS - len("risky"))) + over_limit = at_limit + " " + assert self._classify(at_limit) is CommandBudgetDisposition.HAZARDOUS + assert self._classify(over_limit) is CommandBudgetDisposition.UNKNOWN + + def test_nested_shell_depth_boundary(self): + import shlex + + from autoskillit.hooks._command_classification import ( + OUTPUT_BUDGET_MAX_NESTING_DEPTH, + CommandBudgetDisposition, + ) + + def nest(depth: int) -> str: + command = "risky" + for _ in range(depth): + command = f"bash -c {shlex.quote(command)}" + return command + + assert self._classify(nest(OUTPUT_BUDGET_MAX_NESTING_DEPTH)) is ( + CommandBudgetDisposition.HAZARDOUS + ) + assert self._classify(nest(OUTPUT_BUDGET_MAX_NESTING_DEPTH + 1)) is ( + CommandBudgetDisposition.UNKNOWN + ) + + def test_bounded_small_input_followed_by_transform_is_unknown(self): + from autoskillit.hooks._command_classification import ( + CommandBudgetDisposition, + classify_command_output_budget, + command_verb, + ) + + def small_producer(tokens): + return (True, True) if command_verb(tokens) == "small" else None + + disposition = classify_command_output_budget( + "small | awk '{while (1) print}'", + small_producer, + max_inline_bytes=12_000, + ) + assert disposition is CommandBudgetDisposition.UNKNOWN diff --git a/tests/hooks/test_hook_config_bridge.py b/tests/hooks/test_hook_config_bridge.py index 758caf45fd..6f5dbb32cc 100644 --- a/tests/hooks/test_hook_config_bridge.py +++ b/tests/hooks/test_hook_config_bridge.py @@ -1,8 +1,8 @@ -"""Regression tests for the quota_guard.py → .hook_config.json bridge. +"""Regression tests for hook policy snapshots in ``.hook_config.json``. -Tests the end-to-end path from _quota_guard_hook_payload serialization through -.hook_config.json to resolve_quota_settings() consumption by quota_guard.py. -No pytestmark — hooks/ is out of scope for layer markers. +Tests quota and output-budget serialization across the server-to-stdlib hook +boundary, including layered overlay behavior. No pytestmark — hooks/ is out of +scope for layer markers. """ from __future__ import annotations @@ -207,6 +207,65 @@ def test_hook_config_quota_guard_keys_match_payload_keys(tmp_path): assert set(data["quota_guard"].keys()) == QUOTA_GUARD_HOOK_PAYLOAD_KEYS +def test_output_budget_policy_serializer_matches_stdlib_bridge_keys(): + """The server snapshot and stdlib consumer declare the same exact keys.""" + from autoskillit.config import OutputBudgetConfig + from autoskillit.hooks._hook_settings import OUTPUT_BUDGET_POLICY_HOOK_PAYLOAD_KEYS + from autoskillit.server.tools.tools_kitchen import _output_budget_policy_hook_payload + + payload = _output_budget_policy_hook_payload( + OutputBudgetConfig( + guard_enabled=False, + small_file_max_bytes=321, + shell_max_inline_bytes=654, + ) + ) + + assert set(payload) == OUTPUT_BUDGET_POLICY_HOOK_PAYLOAD_KEYS + assert payload == { + "disabled": True, + "small_file_max_bytes": 321, + "shell_max_inline_bytes": 654, + } + + +def test_output_budget_policy_overlay_overrides_snapshot(tmp_path): + """The project overlay is the highest-priority output-budget policy layer.""" + from autoskillit.hooks._hook_settings import read_merged_hook_config + + config_dir = tmp_path / ".autoskillit" / "temp" + config_dir.mkdir(parents=True) + (config_dir / ".hook_config.json").write_text( + json.dumps( + { + "output_budget_policy": { + "disabled": False, + "small_file_max_bytes": 5000, + "shell_max_inline_bytes": 12000, + } + } + ) + ) + (config_dir / ".hook_config_overlay.json").write_text( + json.dumps( + { + "output_budget_policy": { + "disabled": True, + "small_file_max_bytes": 17, + } + } + ) + ) + + policy = read_merged_hook_config(tmp_path)["output_budget_policy"] + + assert policy == { + "disabled": True, + "small_file_max_bytes": 17, + "shell_max_inline_bytes": 12000, + } + + # T-BRIDGE-7 def test_write_hook_config_round_trip_via_resolve_quota_settings(tmp_path, monkeypatch): """_write_hook_config round-trip: payload written by _quota_guard_hook_payload diff --git a/tests/hooks/test_output_budget_guard.py b/tests/hooks/test_output_budget_guard.py new file mode 100644 index 0000000000..b0e6788366 --- /dev/null +++ b/tests/hooks/test_output_budget_guard.py @@ -0,0 +1,168 @@ +"""Focused behavior tests for the output-budget PreToolUse guard.""" + +from __future__ import annotations + +import io +import json +from contextlib import redirect_stdout + +import pytest + +pytestmark = [pytest.mark.layer("hooks"), pytest.mark.small] + + +def _build_event(command: str, *, run_cmd: bool = False) -> dict: + key = "cmd" if run_cmd else "command" + tool = "mcp__autoskillit__local__autoskillit__run_cmd" if run_cmd else "Bash" + return {"tool_name": tool, "tool_input": {key: command}} + + +def _run_hook(event: dict | None, monkeypatch, *, raw_stdin: str | None = None) -> str: + from autoskillit.hooks.guards.output_budget_guard import main # noqa: PLC0415 + + stdin_text = raw_stdin if raw_stdin is not None else json.dumps(event) + monkeypatch.setattr("sys.stdin", io.StringIO(stdin_text)) + output = io.StringIO() + with pytest.raises(SystemExit), redirect_stdout(output): + main() + return output.getvalue() + + +def _is_denied(output: str) -> bool: + if not output: + return False + payload = json.loads(output) + return payload["hookSpecificOutput"]["permissionDecision"] == "deny" + + +@pytest.mark.parametrize( + "command", + [ + 'rg -n "pat" src/ tests/', + 'grep -r "pat" .', + "rg -l pat src/", + "rg --count pat src/", + "rg -m 5 pat src/", + "cat sessions.jsonl", + "sed -n '1,300p' sessions.jsonl", + "rg pat sessions.jsonl | head -n 200", + "jq -r '.payload' sessions.jsonl", + "find / -maxdepth 2 -name '*.py'", + 'bash -c "rg -n pat src/"', + ], +) +def test_denies_unbounded_r1_r2_r3_shapes(command, monkeypatch, tmp_path): + (tmp_path / "src").mkdir(exist_ok=True) + (tmp_path / "tests").mkdir(exist_ok=True) + assert _is_denied(_run_hook(_build_event(command), monkeypatch)) + + +@pytest.mark.parametrize("run_cmd", [False, True]) +def test_reads_both_command_input_keys(run_cmd, monkeypatch, tmp_path): + (tmp_path / "src").mkdir() + assert _is_denied(_run_hook(_build_event("rg pat src/", run_cmd=run_cmd), monkeypatch)) + + +@pytest.mark.parametrize( + "command", + [ + "rg pat src/ 2>&1 | head -c 4000", + "rg pat src/ |& head -c 4000", + "jq -r .payload sessions.jsonl 2>&1 | head -c 12000", + "rg -M 500 pat sessions.jsonl 2>&1 | tail -c 12000", + "rg pat src/ >.autoskillit/temp/search.out 2>&1", + "rg pat src/ 1>.autoskillit/temp/search.out 2>.autoskillit/temp/search.err", + "find / -quit 2>.autoskillit/temp/find.err", + "find / -quit 2>&1 | head -c 4000", + "rg -n pat single_file.py", + "git log --oneline -20", + ], +) +def test_allows_proven_bounded_cases(command, monkeypatch, tmp_path): + (tmp_path / "src").mkdir(exist_ok=True) + (tmp_path / ".autoskillit" / "temp").mkdir(parents=True, exist_ok=True) + assert _run_hook(_build_event(command), monkeypatch) == "" + + +@pytest.mark.parametrize( + "command", + [ + "rg pat src/ | head -c 4000", + "rg pat src/ && head -c 4000", + "rg pat src/ 2>err.txt", + "rg pat src/ 2>&1 >.autoskillit/temp/out", + "rg pat src/ 2>&1 | head -c 12001", + "rg pat src/ 2>&1 | head -c 0", + "rg -q pat src/", + "find / -quit", + "rg pat src/ 2>&1 | tee .autoskillit/temp/copy | head -c 4000", + "cat <(rg pat src/)", + "rg pat src/ >$OUTPUT 2>&1", + ], +) +def test_denies_false_bounds_and_unknown_shell_shapes(command, monkeypatch, tmp_path): + (tmp_path / "src").mkdir(exist_ok=True) + (tmp_path / ".autoskillit" / "temp").mkdir(parents=True, exist_ok=True) + assert _is_denied(_run_hook(_build_event(command), monkeypatch)) + + +def test_small_jsonl_exception_is_narrow(monkeypatch, tmp_path): + path = tmp_path / "small.jsonl" + path.write_text('{"value": "small"}\n') + + assert _run_hook(_build_event(f"cat {path.name}"), monkeypatch) == "" + assert _run_hook(_build_event(f"wc -l {path.name}"), monkeypatch) == "" + assert _is_denied(_run_hook(_build_event(f"jq -r .value {path.name}"), monkeypatch)) + assert _is_denied( + _run_hook(_build_event(f"cat {path.name} | awk '{{while (1) print}}'"), monkeypatch) + ) + + +def test_large_jsonl_is_denied(monkeypatch, tmp_path): + path = tmp_path / "large.jsonl" + path.write_text('{"value": "' + ("x" * 5_100) + '"}\n') + assert _is_denied(_run_hook(_build_event(f"cat {path.name}"), monkeypatch)) + + +def test_symlink_jsonl_cannot_use_small_file_exception(monkeypatch, tmp_path): + target = tmp_path / "target.jsonl" + target.write_text("{}\n") + link = tmp_path / "link.jsonl" + link.symlink_to(target) + assert _is_denied(_run_hook(_build_event(f"cat {link.name}"), monkeypatch)) + + +def test_config_disable_and_overlay_priority(monkeypatch, tmp_path): + config_dir = tmp_path / ".autoskillit" / "temp" + config_dir.mkdir(parents=True) + base = config_dir / ".hook_config.json" + overlay = config_dir / ".hook_config_overlay.json" + base.write_text(json.dumps({"output_budget_policy": {"disabled": True}})) + + assert _run_hook(_build_event("rg pat src/"), monkeypatch) == "" + + overlay.write_text(json.dumps({"output_budget_policy": {"disabled": False}})) + assert _is_denied(_run_hook(_build_event("rg pat src/"), monkeypatch)) + + +def test_guard_fires_without_headless_gate(monkeypatch, tmp_path): + monkeypatch.delenv("AUTOSKILLIT_HEADLESS", raising=False) + (tmp_path / "src").mkdir() + assert _is_denied(_run_hook(_build_event("rg pat src/"), monkeypatch)) + + +def test_malformed_json_fails_open(monkeypatch): + assert _run_hook(None, monkeypatch, raw_stdin="not json {{{") == "" + + +def test_deny_reason_has_concrete_rewrite(monkeypatch, tmp_path): + from autoskillit.hooks.guards.output_budget_guard import ( # noqa: PLC0415 + OUTPUT_BUDGET_DENY_TRIGGER, + ) + + (tmp_path / "src").mkdir() + output = _run_hook(_build_event("rg pat src/"), monkeypatch) + reason = json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] + assert OUTPUT_BUDGET_DENY_TRIGGER in reason + assert "2>&1 | head -c 4000" in reason + assert ".autoskillit/temp/" in reason diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 995c10a20f..62c1e17e03 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -119,10 +119,10 @@ def _is_yaml_dump(node: ast.expr) -> bool: # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay - ("src/autoskillit/server/tools/tools_kitchen.py", 240), - ("src/autoskillit/server/tools/tools_kitchen.py", 259), - ("src/autoskillit/server/tools/tools_kitchen.py", 293), - ("src/autoskillit/server/tools/tools_kitchen.py", 1275), + ("src/autoskillit/server/tools/tools_kitchen.py", 258), + ("src/autoskillit/server/tools/tools_kitchen.py", 277), + ("src/autoskillit/server/tools/tools_kitchen.py", 311), + ("src/autoskillit/server/tools/tools_kitchen.py", 1282), # tools_pipeline_tracker.py — tracker_data dict ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166), # tools_status.py — mcp_data dict diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index e2a777032f..46bf34c7c2 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -110,7 +110,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_error_count_surfacing.py` | Tests for the +N more errors indicator in validation error responses (R4) | | `test_tools_kitchen_gate.py` | Tests for tools_kitchen.py: gate toggle, review gate cleanup, kitchen_id, misc | | `test_tools_kitchen_gate_features.py` | Tests for tools_kitchen.py: recipe packs, quota refresh, ingredients_only, project_dir | -| `test_tools_kitchen_gate_hook_config.py` | Tests for tools_kitchen.py: hook config lifecycle, overlay, and quota guard tool | +| `test_tools_kitchen_gate_hook_config.py` | Tests for tools_kitchen.py: hook config lifecycle, quota/output-budget policy snapshots, overlay, and quota guard tool | | `test_tools_kitchen_gate_split.py` | Kitchen gate split structural guard | | `test_tools_kitchen_visibility.py` | Tests for tools_kitchen.py: visibility, component management, sous-chef, redisable_subsets | | `test_lock_ingredients.py` | Tests for the lock_ingredients MCP tool and _write_ingredient_locks helper | diff --git a/tests/server/conftest.py b/tests/server/conftest.py index 9402bb9e5a..e241940f01 100644 --- a/tests/server/conftest.py +++ b/tests/server/conftest.py @@ -115,11 +115,14 @@ def assert_no_timing(timing_log: DefaultTimingLog) -> None: def _make_mock_ctx() -> MagicMock: """Return a minimal mock ToolContext with a gate.""" + from autoskillit.config import OutputBudgetConfig + gate = MagicMock() gate.enabled = False ctx = MagicMock() ctx.gate = gate ctx.project_dir = Path("/fake/project") + ctx.config.output_budget = OutputBudgetConfig() ctx.config.subsets.disabled = [] # REQ-VIS-008: no subsets disabled by default ctx.active_recipe_ingredients = None ctx.gate_infrastructure_ready = False diff --git a/tests/server/test_tools_kitchen_gate_hook_config.py b/tests/server/test_tools_kitchen_gate_hook_config.py index 7e755f419d..6e84bf2f16 100644 --- a/tests/server/test_tools_kitchen_gate_hook_config.py +++ b/tests/server/test_tools_kitchen_gate_hook_config.py @@ -7,7 +7,7 @@ import pytest -from autoskillit.config.settings import QuotaGuardConfig +from autoskillit.config.settings import OutputBudgetConfig, QuotaGuardConfig from autoskillit.hooks.formatters._fmt_primitives import _HOOK_CONFIG_PATH_COMPONENTS from tests.server._helpers import _HOOK_CONFIG_OVERLAY_RELPATH from tests.server.conftest import _make_mock_ctx @@ -137,6 +137,39 @@ async def test_open_kitchen_bridges_enabled_flag_as_disabled( ) +@pytest.mark.parametrize("guard_enabled,expected_disabled", [(True, False), (False, True)]) +@pytest.mark.anyio +async def test_open_kitchen_bridges_output_budget_policy( + tmp_path, monkeypatch, guard_enabled, expected_disabled +): + """The stdlib guard receives the configured disable flag and numeric bounds.""" + monkeypatch.chdir(tmp_path) + mock_ctx = _make_mock_ctx() + mock_ctx.project_dir = tmp_path + mock_ctx.config.quota_guard = QuotaGuardConfig() + mock_ctx.config.output_budget = OutputBudgetConfig( + guard_enabled=guard_enabled, + small_file_max_bytes=4321, + shell_max_inline_bytes=8765, + ) + + with patch("autoskillit.server._get_ctx", return_value=mock_ctx): + with patch("autoskillit.server.logger"): + with patch( + "autoskillit.server.tools.tools_kitchen._prime_quota_cache", new=AsyncMock() + ): + from autoskillit.server.tools.tools_kitchen import _open_kitchen_handler + + await _open_kitchen_handler() + + data = json.loads(tmp_path.joinpath(*_HOOK_CONFIG_PATH_COMPONENTS).read_text()) + assert data["output_budget_policy"] == { + "disabled": expected_disabled, + "small_file_max_bytes": 4321, + "shell_max_inline_bytes": 8765, + } + + # T-CACHE-3 @pytest.mark.anyio async def test_close_kitchen_removes_hook_config_json(tmp_path, monkeypatch): diff --git a/tests/skills_extended/AGENTS.md b/tests/skills_extended/AGENTS.md index eef923d77b..0e59129b3b 100644 --- a/tests/skills_extended/AGENTS.md +++ b/tests/skills_extended/AGENTS.md @@ -10,3 +10,4 @@ Extended (Tier 2/3) skill tests. | `test_bundle_local_report.py` | Tests for the bundle-local-report skill renderer | | `test_classify_experiment_type_skill.py` | Structural SKILL.md tests for the classify-experiment-type skill | | `test_apply_review_dimensions_skill.py` | Structural SKILL.md tests for the apply-review-dimensions skill | +| `test_audit_friction_output_budget.py` | Source-parity checks for audit-friction's bounded JSONL command battery | diff --git a/tests/skills_extended/test_audit_friction_output_budget.py b/tests/skills_extended/test_audit_friction_output_budget.py new file mode 100644 index 0000000000..ac15f5cbe9 --- /dev/null +++ b/tests/skills_extended/test_audit_friction_output_budget.py @@ -0,0 +1,95 @@ +"""Source-parity coverage for audit-friction's bounded JSONL command battery.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.medium + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SKILL_PATH = ( + PROJECT_ROOT / "src" / "autoskillit" / "skills_extended" / "audit-friction" / "SKILL.md" +) +GUARD_PATH = PROJECT_ROOT / "src" / "autoskillit" / "hooks" / "guards" / "output_budget_guard.py" + + +def _extract_battery_commands() -> tuple[str, ...]: + source = SKILL_PATH.read_text(encoding="utf-8") + section = source.split("Use this keyword battery against each file:", 1)[1] + bash_block = section.split("```bash", 1)[1].split("```", 1)[0] + return tuple( + line.strip() + for line in bash_block.splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + + +BATTERY_COMMANDS = _extract_battery_commands() + + +def test_real_command_battery_is_structurally_byte_bounded() -> None: + assert len(BATTERY_COMMANDS) == 8 + for command in BATTERY_COMMANDS: + assert not re.search(r"(^|\s)grep(?:\s|$)", command) + assert command.endswith("head -c 12000") + + stages = command.split(" | ") + assert stages[-1] == "head -c 12000" + assert all(stage.endswith("2>&1") for stage in stages[:-1]) + + jsonl_reader = stages[0] + assert jsonl_reader.startswith("rg ") + assert re.search(r"(?:^|\s)-M\s+500(?:\s|$)", jsonl_reader) + + +@pytest.mark.parametrize("command", BATTERY_COMMANDS) +def test_real_command_battery_passes_output_budget_guard( + command: str, + tmp_path: Path, +) -> None: + log_path = tmp_path / "session.jsonl" + log_path.write_text(json.dumps({"payload": "x" * 6000}) + "\n", encoding="utf-8") + rendered = ( + command.replace("FILE", str(log_path)) + .replace("TOOL_NAME", "Read") + .replace("CONFIRMING_PATTERN", "payload") + ) + payload = { + "tool_name": "Bash", + "tool_input": {"command": rendered}, + "cwd": str(tmp_path), + } + isolated_home = tmp_path / "home" + isolated_home.mkdir() + env = {key: value for key, value in os.environ.items() if not key.startswith("AUTOSKILLIT_")} + env.update( + { + "AUTOSKILLIT_CWD": str(tmp_path), + "HOME": str(isolated_home), + "XDG_CONFIG_HOME": str(isolated_home / ".config"), + } + ) + + result = subprocess.run( # noqa: S603 + [sys.executable, str(GUARD_PATH)], + input=json.dumps(payload), + capture_output=True, + text=True, + cwd=tmp_path, + env=env, + timeout=5, + ) + + assert result.returncode == 0, result.stderr + if result.stdout.strip(): + hook_output = json.loads(result.stdout)["hookSpecificOutput"] + assert hook_output.get("permissionDecision") != "deny", ( + f"real audit-friction command was denied: {rendered}\n{result.stdout}" + ) From a13c6ff970266ba71b948c3bb7ed22b8ea373025 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 15 Jul 2026 18:04:56 -0700 Subject: [PATCH 03/30] feat: deliver output discipline policy --- .github/workflows/conformance-probes.yml | 22 +++- src/autoskillit/core/__init__.pyi | 5 + src/autoskillit/core/types/_type_constants.py | 105 ++++++++++++++++++ .../execution/backends/_claude_prompt.py | 11 ++ .../execution/backends/_probe_cache.py | 28 ++++- src/autoskillit/execution/backends/claude.py | 2 + src/autoskillit/execution/backends/codex.py | 21 +++- .../skills_extended/audit-bugs/SKILL.md | 15 +++ .../skills_extended/audit-friction/SKILL.md | 15 +++ .../skills_extended/investigate/SKILL.md | 16 +++ .../skills_extended/rectify/SKILL.md | 17 ++- tests/arch/test_subpackage_isolation.py | 9 +- tests/arch/test_subpackage_structure.py | 4 +- tests/contracts/AGENTS.md | 1 + .../test_output_budget_discipline.py | 80 +++++++++++++ tests/execution/AGENTS.md | 1 + .../execution/backends/test_claude_backend.py | 19 ++++ .../backends/test_cli_conformance_probes.py | 39 ++++++- .../execution/backends/test_codex_backend.py | 44 +++++++- .../backends/test_codex_interactive.py | 41 ++++++- tests/execution/backends/test_probe_cache.py | 96 ++++++++++++++-- tests/execution/test_headless_core.py | 19 ++++ .../infra/test_conformance_probes_workflow.py | 27 +++++ .../test_investigate_deep_mode_contracts.py | 15 +++ 24 files changed, 612 insertions(+), 40 deletions(-) create mode 100644 tests/contracts/test_output_budget_discipline.py diff --git a/.github/workflows/conformance-probes.yml b/.github/workflows/conformance-probes.yml index 94fbcd1e2b..4d36884e1e 100644 --- a/.github/workflows/conformance-probes.yml +++ b/.github/workflows/conformance-probes.yml @@ -67,12 +67,18 @@ jobs: fi echo "cli_version=${CLI_VERSION}" >> "$GITHUB_OUTPUT" + - name: Export output discipline policy identity + id: resolve-policy + run: | + POLICY_IDENTITY=$(.venv/bin/python -c 'from autoskillit.execution.backends._probe_cache import PROBE_POLICY_IDENTITY; print(PROBE_POLICY_IDENTITY)') + echo "policy_identity=${POLICY_IDENTITY}" >> "$GITHUB_OUTPUT" + - name: Restore probe cache id: restore-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .autoskillit/temp/probe-cache/codex - key: probe-codex-${{ steps.resolve-version.outputs.cli_version }} + key: probe-codex-${{ steps.resolve-version.outputs.cli_version }}-${{ steps.resolve-policy.outputs.policy_identity }} - name: Run Codex conformance probes if: steps.restore-cache.outputs.cache-hit != 'true' @@ -99,7 +105,7 @@ jobs: uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .autoskillit/temp/probe-cache/codex - key: probe-codex-${{ steps.resolve-version.outputs.cli_version }} + key: probe-codex-${{ steps.resolve-version.outputs.cli_version }}-${{ steps.resolve-policy.outputs.policy_identity }} - name: Post probe failure if: failure() @@ -165,12 +171,18 @@ jobs: fi echo "cli_version=${CLI_VERSION}" >> "$GITHUB_OUTPUT" + - name: Export output discipline policy identity + id: resolve-policy + run: | + POLICY_IDENTITY=$(.venv/bin/python -c 'from autoskillit.execution.backends._probe_cache import PROBE_POLICY_IDENTITY; print(PROBE_POLICY_IDENTITY)') + echo "policy_identity=${POLICY_IDENTITY}" >> "$GITHUB_OUTPUT" + - name: Restore probe cache id: restore-cache uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .autoskillit/temp/probe-cache/claude - key: probe-claude-${{ steps.resolve-version.outputs.cli_version }} + key: probe-claude-${{ steps.resolve-version.outputs.cli_version }}-${{ steps.resolve-policy.outputs.policy_identity }} - name: Run Claude Code conformance probes if: steps.restore-cache.outputs.cache-hit != 'true' @@ -188,7 +200,7 @@ jobs: uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: .autoskillit/temp/probe-cache/claude - key: probe-claude-${{ steps.resolve-version.outputs.cli_version }} + key: probe-claude-${{ steps.resolve-version.outputs.cli_version }}-${{ steps.resolve-policy.outputs.policy_identity }} - name: Post probe failure if: failure() @@ -199,4 +211,4 @@ jobs: scripts/post-probe-failure.sh claude \ "$CLI_VERSION" \ schema \ - "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ No newline at end of file + "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 23974f7029..4795aa2ab1 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -186,6 +186,11 @@ from .types import MCP_CLIENT_BACKEND_ENV_VAR as MCP_CLIENT_BACKEND_ENV_VAR from .types import NON_VARIADIC_CLAUDE_FLAGS as NON_VARIADIC_CLAUDE_FLAGS from .types import ORCHESTRATOR_SESSION_REQUIRED_ENV as ORCHESTRATOR_SESSION_REQUIRED_ENV from .types import ORDER_INTERACTIVE_REQUIRED_ENV as ORDER_INTERACTIVE_REQUIRED_ENV +from .types import OUTPUT_DISCIPLINE_BLOCK as OUTPUT_DISCIPLINE_BLOCK +from .types import OUTPUT_DISCIPLINE_BLOCK_SHA256 as OUTPUT_DISCIPLINE_BLOCK_SHA256 +from .types import OUTPUT_DISCIPLINE_DIGEST as OUTPUT_DISCIPLINE_DIGEST +from .types import OUTPUT_DISCIPLINE_POLICY_VERSION as OUTPUT_DISCIPLINE_POLICY_VERSION +from .types import OUTPUT_DISCIPLINE_REQUIRED_SKILLS as OUTPUT_DISCIPLINE_REQUIRED_SKILLS from .types import PACK_REGISTRY as PACK_REGISTRY from .types import PIPELINE_FORBIDDEN_TOOLS as PIPELINE_FORBIDDEN_TOOLS from .types import PR_TELEMETRY_SECTIONS as PR_TELEMETRY_SECTIONS diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index eaee8108b3..443ac5efbf 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -5,11 +5,17 @@ from __future__ import annotations +from hashlib import sha256 from typing import NamedTuple from ._type_constants_registries import SKILL_CAPABILITY_REGISTRY __all__ = [ + "OUTPUT_DISCIPLINE_POLICY_VERSION", + "OUTPUT_DISCIPLINE_BLOCK", + "OUTPUT_DISCIPLINE_BLOCK_SHA256", + "OUTPUT_DISCIPLINE_DIGEST", + "OUTPUT_DISCIPLINE_REQUIRED_SKILLS", "RETIRED_SKILL_NAMES", "RETIRED_AGENT_NAMES", "SKILL_COMMAND_PREFIX", @@ -44,6 +50,105 @@ "CODEX_SESSIONS_SUBDIR", ] +OUTPUT_DISCIPLINE_POLICY_VERSION = 1 + +OUTPUT_DISCIPLINE_BLOCK = "\n".join( + ( + "### Output Discipline Policy v1", + "", + ( + "- Treat shell and tool output as a bounded resource. Choose the smallest " + "useful producer and set a byte limit before running it." + ), + ( + "- Bound discovery itself: use forms such as " + "`rg -l PATTERN PATH 2>&1 | head -c N`, where `N` is within the configured " + "inline-output ceiling, or redirect both descriptors to a project-temp artifact." + ), + ( + "- For JSONL, use record-aware search with a per-record limit such as " + "`rg -M 500`; never rely on a bare line cap because one record may contain " + "an arbitrarily large payload." + ), + ( + "- Route stdout and stderr from every stage of an output-producing pipeline " + "into the terminal byte cap. Intermediate stderr must not bypass the cap." + ), + ( + "- Follow `jq` field extraction with a byte cap. Selecting one field does not " + "make its contents small." + ), + ( + "- Redirect potentially unbounded output to " + "`{{AUTOSKILLIT_TEMP}}//out.txt` with both descriptors captured, then " + "inspect only bounded searches or byte slices from that artifact." + ), + ( + "- Read complete files only when their size is known to be small. Otherwise " + "locate matches first and read only the bounded relevant region." + ), + ( + "- Give every subagent an explicit maximum size for its final report and " + "request only evidence needed by the parent synthesis." + ), + ( + "- Before authorizing each deep-mode batch, reserve enough context for " + "synthesis, report writing, and validation. Stop gathering and begin " + "synthesis when another batch would cross that reserve." + ), + ( + "- A command's success does not make oversized inline output safe. Preserve " + "full evidence in project temp and return a bounded summary plus the " + "artifact path." + ), + ) +) + +OUTPUT_DISCIPLINE_BLOCK_SHA256 = sha256(OUTPUT_DISCIPLINE_BLOCK.encode("utf-8")).hexdigest() + +OUTPUT_DISCIPLINE_DIGEST = "\n".join( + ( + "Output Discipline Policy v1:", + ( + "- Bound every shell/tool producer by bytes before execution; discovery " + "output is also bounded." + ), + ( + "- Merge stdout and stderr through the final byte cap so neither descriptor " + "bypasses it." + ), + ("- Use record-aware limits such as `rg -M 500` for JSONL; never trust bare line caps."), + ( + "- Put a byte cap after `jq` field extraction because a selected field may " + "still be huge." + ), + ( + "- Send potentially unbounded output to " + "`{{AUTOSKILLIT_TEMP}}//out.txt`, capturing both descriptors." + ), + ( + "- Inspect saved output only with bounded searches or byte slices; directly " + "read only known-small files." + ), + ( + "- Give subagents explicit final-report size targets and request only " + "synthesis-relevant evidence." + ), + ( + "- Before each deep-mode batch, reserve context for synthesis, report " + "writing, and validation." + ), + ( + "- Stop gathering when another batch would cross that reserve; preserve " + "evidence by artifact path." + ), + ) +) + +OUTPUT_DISCIPLINE_REQUIRED_SKILLS: frozenset[str] = frozenset( + {"investigate", "rectify", "audit-bugs", "audit-friction"} +) + RETIRED_SKILL_NAMES: frozenset[str] = frozenset( { # Skill directory names that have been renamed or removed. diff --git a/src/autoskillit/execution/backends/_claude_prompt.py b/src/autoskillit/execution/backends/_claude_prompt.py index 1eb09cc517..28c6b2bed7 100644 --- a/src/autoskillit/execution/backends/_claude_prompt.py +++ b/src/autoskillit/execution/backends/_claude_prompt.py @@ -7,6 +7,7 @@ from typing import Any, NamedTuple from autoskillit.core import ( + OUTPUT_DISCIPLINE_DIGEST, PROVIDER_PROFILE_ENV_VAR, ClaudeFlags, OutputFormat, @@ -182,6 +183,13 @@ def _inject_narration_suppression(skill_command: str, *, has_skill_prefix: bool return skill_command + directive +def _inject_output_discipline(prompt: str, *, include: bool = False) -> str: + """Append the shared output-discipline digest on supported delivery surfaces.""" + if not include: + return prompt + return f"{prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" + + def _inject_completion_reminder(prompt: str, marker: str) -> str: """Append a short completion marker reminder as the final prompt line. @@ -217,6 +225,7 @@ class PromptBuildContext: temp_dir_relpath: str | None = None has_skill_prefix: bool = False profile_name: str = "" + include_output_discipline: bool = False class InjectorDef(NamedTuple): @@ -229,6 +238,7 @@ class InjectorDef(NamedTuple): InjectorDef("completion-directive"), InjectorDef("cwd-anchor"), InjectorDef("narration-suppression"), + InjectorDef("output-discipline"), InjectorDef("output-format-reinforcement"), InjectorDef("completion-reminder"), ) @@ -243,6 +253,7 @@ def apply_prompt_injector_chain(prompt: str, ctx: PromptBuildContext) -> str: prompt = _inject_completion_directive(prompt, ctx.completion_marker) prompt = _inject_cwd_anchor(prompt, ctx.cwd, temp_dir_relpath=ctx.temp_dir_relpath) prompt = _inject_narration_suppression(prompt, has_skill_prefix=ctx.has_skill_prefix) + prompt = _inject_output_discipline(prompt, include=ctx.include_output_discipline) prompt = _inject_output_format_reinforcement(prompt, profile_name=ctx.profile_name) prompt = _inject_completion_reminder(prompt, ctx.completion_marker) return prompt diff --git a/src/autoskillit/execution/backends/_probe_cache.py b/src/autoskillit/execution/backends/_probe_cache.py index 43ad4fbd94..baa518188e 100644 --- a/src/autoskillit/execution/backends/_probe_cache.py +++ b/src/autoskillit/execution/backends/_probe_cache.py @@ -1,8 +1,8 @@ """Versioned probe result cache with 24-hour TTL. IL-1 module: imports only stdlib and `autoskillit.core`. Stores a per-cli-version -`ProbeResult` keyed by `cli_version`, surviving across runs while staying -under `PROBE_CACHE_TTL`. +`ProbeResult` keyed by `cli_version` and validated against the current output +discipline policy, surviving across runs while staying under `PROBE_CACHE_TTL`. """ from __future__ import annotations @@ -11,23 +11,37 @@ from datetime import UTC, datetime, timedelta from pathlib import Path -from autoskillit.core import get_logger, read_versioned_json, write_versioned_json +from autoskillit.core import ( + OUTPUT_DISCIPLINE_BLOCK_SHA256, + OUTPUT_DISCIPLINE_POLICY_VERSION, + get_logger, + read_versioned_json, + write_versioned_json, +) logger = get_logger(__name__) PROBE_CACHE_TTL: timedelta = timedelta(hours=24) -_SCHEMA_VERSION: int = 1 +PROBE_POLICY_IDENTITY: str = ( + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}" +) +_SCHEMA_VERSION: int = 2 @dataclass(frozen=True, slots=True) class ProbeResult: cli_version: str + policy_identity: str passed: bool failure_detail: str | None probe_timestamp: str -def read_probe_cache(cache_path: Path, cli_version: str) -> ProbeResult | None: +def read_probe_cache( + cache_path: Path, + cli_version: str, + policy_identity: str, +) -> ProbeResult | None: raw = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger) if raw is None: return None @@ -35,6 +49,8 @@ def read_probe_cache(cache_path: Path, cli_version: str) -> ProbeResult | None: entry = entries.get(cli_version) if entry is None: return None + if entry.get("policy_identity") != policy_identity: + return None try: ts = datetime.fromisoformat(entry["probe_timestamp"]) if (datetime.now(UTC) - ts) > PROBE_CACHE_TTL: @@ -43,6 +59,7 @@ def read_probe_cache(cache_path: Path, cli_version: str) -> ProbeResult | None: return None return ProbeResult( cli_version=cli_version, + policy_identity=policy_identity, passed=entry.get("passed", False), failure_detail=entry.get("failure_detail"), probe_timestamp=entry["probe_timestamp"], @@ -54,6 +71,7 @@ def write_probe_cache(cache_path: Path, result: ProbeResult) -> None: existing = read_versioned_json(cache_path, _SCHEMA_VERSION, logger=logger) entries: dict[str, dict] = (existing or {}).get("entries", {}) entries[result.cli_version] = { + "policy_identity": result.policy_identity, "passed": result.passed, "failure_detail": result.failure_detail, "probe_timestamp": result.probe_timestamp, diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 7d4d6cfcd6..4236608dca 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -576,6 +576,7 @@ def build_skill_session_cmd( temp_dir_relpath=temp_dir_relpath, has_skill_prefix=_has_prefix, profile_name=profile_name, + include_output_discipline=False, ), ) extras = self._assemble_shared_env_extras( @@ -665,6 +666,7 @@ def build_food_truck_cmd( temp_dir_relpath=temp_dir_relpath, has_skill_prefix=False, profile_name="", + include_output_discipline=False, ), ) diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index bbf0ab4519..01891e8915 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -6,6 +6,7 @@ import os import shutil import subprocess +import tomllib from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import StrEnum, unique @@ -28,6 +29,7 @@ FOOD_TRUCK_TOOL_TAGS_ENV_VAR, MCP_CLIENT_BACKEND_ENV_VAR, ORCHESTRATOR_SESSION_REQUIRED_ENV, + OUTPUT_DISCIPLINE_DIGEST, PROVIDER_PROFILE_ENV_VAR, RESUME_SESSION_BASELINE_KEYS, SESSION_TYPE_ORCHESTRATOR, @@ -466,8 +468,11 @@ def _generate_agent_tomls(session_dir: Path) -> int: effort = CODEX_EFFORT_MAPPING.get(model_key) if effort: lines.append(f"model_reasoning_effort = {_format_toml_value(effort)}") + body = f"{body}\n\n{OUTPUT_DISCIPLINE_DIGEST}" lines.append(f"developer_instructions = '''\n{body}\n'''") - atomic_write(out_dir / f"{name}.toml", "\n".join(lines) + "\n") + toml_path = out_dir / f"{name}.toml" + atomic_write(toml_path, "\n".join(lines) + "\n") + tomllib.loads(toml_path.read_text(encoding="utf-8")) count += 1 logger.debug("codex_agents_generated", count=count, dest=str(out_dir)) return count @@ -751,6 +756,7 @@ def build_skill_session_cmd( temp_dir_relpath=temp_dir_relpath, has_skill_prefix=_has_prefix, profile_name=profile_name, + include_output_discipline=True, ), ) @@ -863,6 +869,7 @@ def build_food_truck_cmd( temp_dir_relpath=temp_dir_relpath, has_skill_prefix=False, profile_name="", + include_output_discipline=True, ), ) @@ -958,8 +965,16 @@ def build_interactive_cmd( for override in self.model_config_overrides(model): builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, override) builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, _IMAGE_GENERATION_DISABLED) - if system_prompt is not None and isinstance(resume_spec, NoResume): - builder.kv_flag(CodexFlags.CONFIG_OVERRIDE, f"developer_instructions={system_prompt}") + if isinstance(resume_spec, NoResume): + developer_instructions = ( + f"{system_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" + if system_prompt is not None + else OUTPUT_DISCIPLINE_DIGEST + ) + builder.kv_flag( + CodexFlags.CONFIG_OVERRIDE, + f"developer_instructions={_format_toml_value(developer_instructions)}", + ) if initial_prompt is not None: builder.positional(initial_prompt) for d in add_dirs: diff --git a/src/autoskillit/skills_extended/audit-bugs/SKILL.md b/src/autoskillit/skills_extended/audit-bugs/SKILL.md index b359cd6298..6652b05379 100644 --- a/src/autoskillit/skills_extended/audit-bugs/SKILL.md +++ b/src/autoskillit/skills_extended/audit-bugs/SKILL.md @@ -26,6 +26,21 @@ Mine Claude Code conversation logs for `/autoskillit:investigate` skill invocati The user may provide a "since" date (e.g., `2/7`, `2026-02-07`, `last week`). If not specified, use `AskUserQuestion` to ask what the earliest lookback date should be before proceeding. + +### Output Discipline Policy v1 + +- Treat shell and tool output as a bounded resource. Choose the smallest useful producer and set a byte limit before running it. +- Bound discovery itself: use forms such as `rg -l PATTERN PATH 2>&1 | head -c N`, where `N` is within the configured inline-output ceiling, or redirect both descriptors to a project-temp artifact. +- For JSONL, use record-aware search with a per-record limit such as `rg -M 500`; never rely on a bare line cap because one record may contain an arbitrarily large payload. +- Route stdout and stderr from every stage of an output-producing pipeline into the terminal byte cap. Intermediate stderr must not bypass the cap. +- Follow `jq` field extraction with a byte cap. Selecting one field does not make its contents small. +- Redirect potentially unbounded output to `{{AUTOSKILLIT_TEMP}}//out.txt` with both descriptors captured, then inspect only bounded searches or byte slices from that artifact. +- Read complete files only when their size is known to be small. Otherwise locate matches first and read only the bounded relevant region. +- Give every subagent an explicit maximum size for its final report and request only evidence needed by the parent synthesis. +- Before authorizing each deep-mode batch, reserve enough context for synthesis, report writing, and validation. Stop gathering and begin synthesis when another batch would cross that reserve. +- A command's success does not make oversized inline output safe. Preserve full evidence in project temp and return a bounded summary plus the artifact path. + + ## Critical Constraints **NEVER:** diff --git a/src/autoskillit/skills_extended/audit-friction/SKILL.md b/src/autoskillit/skills_extended/audit-friction/SKILL.md index 70720c92ee..4dee39b694 100644 --- a/src/autoskillit/skills_extended/audit-friction/SKILL.md +++ b/src/autoskillit/skills_extended/audit-friction/SKILL.md @@ -26,6 +26,21 @@ Mine Claude Code conversation logs to identify and categorize friction — repea The user may provide a "since" date (e.g., `2/7`, `2026-02-07`, `last month`). If not specified, use `AskUserQuestion` to ask what the earliest lookback date should be before proceeding. If the resulting window contains no logs, fall back to the last 30 days and note the adjustment. + +### Output Discipline Policy v1 + +- Treat shell and tool output as a bounded resource. Choose the smallest useful producer and set a byte limit before running it. +- Bound discovery itself: use forms such as `rg -l PATTERN PATH 2>&1 | head -c N`, where `N` is within the configured inline-output ceiling, or redirect both descriptors to a project-temp artifact. +- For JSONL, use record-aware search with a per-record limit such as `rg -M 500`; never rely on a bare line cap because one record may contain an arbitrarily large payload. +- Route stdout and stderr from every stage of an output-producing pipeline into the terminal byte cap. Intermediate stderr must not bypass the cap. +- Follow `jq` field extraction with a byte cap. Selecting one field does not make its contents small. +- Redirect potentially unbounded output to `{{AUTOSKILLIT_TEMP}}//out.txt` with both descriptors captured, then inspect only bounded searches or byte slices from that artifact. +- Read complete files only when their size is known to be small. Otherwise locate matches first and read only the bounded relevant region. +- Give every subagent an explicit maximum size for its final report and request only evidence needed by the parent synthesis. +- Before authorizing each deep-mode batch, reserve enough context for synthesis, report writing, and validation. Stop gathering and begin synthesis when another batch would cross that reserve. +- A command's success does not make oversized inline output safe. Preserve full evidence in project temp and return a bounded summary plus the artifact path. + + ## Critical Constraints **NEVER:** diff --git a/src/autoskillit/skills_extended/investigate/SKILL.md b/src/autoskillit/skills_extended/investigate/SKILL.md index a88c7b5a12..a71f5f743f 100644 --- a/src/autoskillit/skills_extended/investigate/SKILL.md +++ b/src/autoskillit/skills_extended/investigate/SKILL.md @@ -65,6 +65,21 @@ tool **before** beginning any analysis. Use the returned `content` field as the - If `fetch_github_issue` returns `success: false`, log the failure and proceed with the raw ARGUMENTS as-is. + +### Output Discipline Policy v1 + +- Treat shell and tool output as a bounded resource. Choose the smallest useful producer and set a byte limit before running it. +- Bound discovery itself: use forms such as `rg -l PATTERN PATH 2>&1 | head -c N`, where `N` is within the configured inline-output ceiling, or redirect both descriptors to a project-temp artifact. +- For JSONL, use record-aware search with a per-record limit such as `rg -M 500`; never rely on a bare line cap because one record may contain an arbitrarily large payload. +- Route stdout and stderr from every stage of an output-producing pipeline into the terminal byte cap. Intermediate stderr must not bypass the cap. +- Follow `jq` field extraction with a byte cap. Selecting one field does not make its contents small. +- Redirect potentially unbounded output to `{{AUTOSKILLIT_TEMP}}//out.txt` with both descriptors captured, then inspect only bounded searches or byte slices from that artifact. +- Read complete files only when their size is known to be small. Otherwise locate matches first and read only the bounded relevant region. +- Give every subagent an explicit maximum size for its final report and request only evidence needed by the parent synthesis. +- Before authorizing each deep-mode batch, reserve enough context for synthesis, report writing, and validation. Stop gathering and begin synthesis when another batch would cross that reserve. +- A command's success does not make oversized inline output safe. Preserve full evidence in project temp and return a bounded summary plus the artifact path. + + ## Critical Constraints **NEVER:** @@ -338,6 +353,7 @@ When deep analysis mode is activated, Steps D1–D6 replace standard Steps 1–3 Parse the investigation target (same as Step 1). Then propose an adaptive batch plan: +- Before authorizing each batch, reserve enough remaining context budget for synthesis, report writing, and post-report validation. Stop gathering and proceed to synthesis when another batch would cross that reserve. - Minimum 2 batches; typically 3–4 for complex investigations - State the planned batch count and scope for each batch - Present as "Proposed investigation plan: Batch 1 — {scope}, Batch 2 — {scope}, ..." diff --git a/src/autoskillit/skills_extended/rectify/SKILL.md b/src/autoskillit/skills_extended/rectify/SKILL.md index 8ff702637b..981e357318 100644 --- a/src/autoskillit/skills_extended/rectify/SKILL.md +++ b/src/autoskillit/skills_extended/rectify/SKILL.md @@ -32,6 +32,21 @@ Do not change any code. - User says "rectify", "rectify this", or "address root cause" - User wants to understand why tests missed something and how to prevent it architecturally + +### Output Discipline Policy v1 + +- Treat shell and tool output as a bounded resource. Choose the smallest useful producer and set a byte limit before running it. +- Bound discovery itself: use forms such as `rg -l PATTERN PATH 2>&1 | head -c N`, where `N` is within the configured inline-output ceiling, or redirect both descriptors to a project-temp artifact. +- For JSONL, use record-aware search with a per-record limit such as `rg -M 500`; never rely on a bare line cap because one record may contain an arbitrarily large payload. +- Route stdout and stderr from every stage of an output-producing pipeline into the terminal byte cap. Intermediate stderr must not bypass the cap. +- Follow `jq` field extraction with a byte cap. Selecting one field does not make its contents small. +- Redirect potentially unbounded output to `{{AUTOSKILLIT_TEMP}}//out.txt` with both descriptors captured, then inspect only bounded searches or byte slices from that artifact. +- Read complete files only when their size is known to be small. Otherwise locate matches first and read only the bounded relevant region. +- Give every subagent an explicit maximum size for its final report and request only evidence needed by the parent synthesis. +- Before authorizing each deep-mode batch, reserve enough context for synthesis, report writing, and validation. Stop gathering and begin synthesis when another batch would cross that reserve. +- A command's success does not make oversized inline output safe. Preserve full evidence in project temp and return a bounded summary plus the artifact path. + + ## Critical Constraints **NEVER:** @@ -308,4 +323,4 @@ plan_parts = {path_to_part_a} ## Verification {How to verify the architectural changes provide the intended immunity} -``` \ No newline at end of file +``` diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 328490ec20..39b26b3539 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -91,6 +91,9 @@ def _get_call_func_name(node: ast.Call) -> str | None: "_sessions", # cli/_sessions.py: sessions_app = App(name="sessions", ...) "_validate", # cli/_validate.py: validate_app = App(name="validate", ...) "_type_backend", # core/types/_type_backend.py: CLAUDE_CODE_CAPABILITIES constant + # Canonical output-discipline block/digest and their SHA-256 cache identity are + # deliberately derived once at import time from the single source of truth. + "_type_constants", # _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 @@ -1053,7 +1056,7 @@ def test_data_directories_are_not_python_packages() -> None: "and ambiguous/empty step_name dependency-deny branches (+126 net lines)", ), "execution/backends/codex.py": ( - 1155, + 1167, "REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line " "keyword args to _ensure_skill_prefix call sites and _has_prefix guard; " "write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; " @@ -1079,7 +1082,9 @@ def test_data_directories_are_not_python_packages() -> None: "; explicit parameter dispositions for " "plugin_source/output_format/exit_after_stop_delay_ms " "replacing noqa:F841 silent discards (+18 net lines) for T5-P4-A2-WP1" - "; github_api_callable field + evidence comment in BackendCapabilities (+2 net lines)", + "; github_api_callable field + evidence comment in BackendCapabilities (+2 net lines)" + "; output-discipline delivery for fresh interactive sessions and generated agent " + "TOMLs (+12 net lines)", ), } diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 823b1d7574..574593d114 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,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) == 100, ( - f"Expected 100 symbols total, got {len(combined)} " + assert len(combined) == 103, ( + f"Expected 103 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/contracts/AGENTS.md b/tests/contracts/AGENTS.md index 8fa8716495..2a55d46d1b 100644 --- a/tests/contracts/AGENTS.md +++ b/tests/contracts/AGENTS.md @@ -50,6 +50,7 @@ Protocol satisfaction, package gateway, and skill contract compliance tests. | `test_make_campaign_skill_contracts.py` | Contract tests: structural invariants for the make-campaign SKILL.md | | `test_mermaid_palette_contracts.py` | Contract: any SKILL.md that generates mermaid diagrams must embed the canonical 9-class palette | | `test_no_pagination_file_read.py` | Contract tests for no-pagination file read instruction in high-turn SKILL.md files | +| `test_output_budget_discipline.py` | Cross-skill contract: canonical output-discipline policy identity, cohort parity, and marker ownership | | `test_package_gateways.py` | Tests for Package Gateway API (groupC) — REQ-GWAY-001 through REQ-GWAY-008 | | `test_plan_experiment_contracts.py` | Contract tests for plan-experiment SKILL.md — data provenance lifecycle | | `test_plan_visualization_contracts.py` | Contract tests: select-vis-lenses Tier B experiment-type canonical names match registry | diff --git a/tests/contracts/test_output_budget_discipline.py b/tests/contracts/test_output_budget_discipline.py new file mode 100644 index 0000000000..e10362344e --- /dev/null +++ b/tests/contracts/test_output_budget_discipline.py @@ -0,0 +1,80 @@ +"""Contracts for the canonical output-discipline policy shared by cohort skills.""" + +from __future__ import annotations + +from hashlib import sha256 +from pathlib import Path + +import pytest + +from autoskillit.core import ( + OUTPUT_DISCIPLINE_BLOCK, + OUTPUT_DISCIPLINE_BLOCK_SHA256, + OUTPUT_DISCIPLINE_DIGEST, + OUTPUT_DISCIPLINE_POLICY_VERSION, + OUTPUT_DISCIPLINE_REQUIRED_SKILLS, +) +from autoskillit.core.paths import pkg_root + +pytestmark = [pytest.mark.layer("contracts"), pytest.mark.medium] + +_BEGIN_MARKER = "" +_END_MARKER = "" +_SKILL_ROOTS = (pkg_root() / "skills", pkg_root() / "skills_extended") +_EXPECTED_REQUIRED_SKILLS = frozenset({"investigate", "rectify", "audit-bugs", "audit-friction"}) + + +def _skill_path(skill_name: str) -> Path: + matches = [root / skill_name / "SKILL.md" for root in _SKILL_ROOTS] + existing = [path for path in matches if path.is_file()] + assert len(existing) == 1, ( + f"Expected exactly one bundled SKILL.md for {skill_name!r}, found {existing}" + ) + return existing[0] + + +def _extract_policy_block(skill_text: str, *, skill_name: str) -> str: + assert skill_text.count(_BEGIN_MARKER) == 1, ( + f"{skill_name}/SKILL.md must contain exactly one {_BEGIN_MARKER!r} marker" + ) + assert skill_text.count(_END_MARKER) == 1, ( + f"{skill_name}/SKILL.md must contain exactly one {_END_MARKER!r} marker" + ) + start = skill_text.index(f"{_BEGIN_MARKER}\n") + len(f"{_BEGIN_MARKER}\n") + end = skill_text.index(f"\n{_END_MARKER}", start) + return skill_text[start:end] + + +def test_canonical_policy_identity() -> None: + assert OUTPUT_DISCIPLINE_POLICY_VERSION == 1 + assert f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}" in OUTPUT_DISCIPLINE_BLOCK + assert f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}" in OUTPUT_DISCIPLINE_DIGEST + assert ( + OUTPUT_DISCIPLINE_BLOCK_SHA256 + == sha256(OUTPUT_DISCIPLINE_BLOCK.encode("utf-8")).hexdigest() + ) + + +def test_required_skill_cohort_is_explicit_and_frozen() -> None: + assert isinstance(OUTPUT_DISCIPLINE_REQUIRED_SKILLS, frozenset) + assert OUTPUT_DISCIPLINE_REQUIRED_SKILLS == _EXPECTED_REQUIRED_SKILLS + + +@pytest.mark.parametrize("skill_name", sorted(_EXPECTED_REQUIRED_SKILLS)) +def test_required_skill_contains_byte_identical_policy_block(skill_name: str) -> None: + skill_text = _skill_path(skill_name).read_text(encoding="utf-8") + assert _extract_policy_block(skill_text, skill_name=skill_name) == OUTPUT_DISCIPLINE_BLOCK + + +def test_policy_marker_has_no_unmanaged_skill_copies() -> None: + marked_skills = { + skill_md.parent.name + for root in _SKILL_ROOTS + for skill_md in root.glob("*/SKILL.md") + if _BEGIN_MARKER in skill_md.read_text(encoding="utf-8") + } + assert marked_skills == OUTPUT_DISCIPLINE_REQUIRED_SKILLS + + +def test_digest_is_safe_for_agent_toml_multiline_literal_guard() -> None: + assert "'''" not in OUTPUT_DISCIPLINE_DIGEST diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index e1080f06b0..def86cddde 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -159,3 +159,4 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_cmd_builder.py` | CmdBuilder ordering invariant and CmdSpec origin tests | | `test_coding_agent_backend_conformance.py` | Parametrized conformance tests for all CodingAgentBackend implementations via BackendContractBase | | `test_cli_conformance_probes.py` | Live backend CLI conformance probes: schema checks plus isolated output-budget deny round trips with ProbeCache and shared error discrimination | +| `test_probe_cache.py` | Versioned probe-cache tests: CLI/policy identity invalidation, TTL, schema, and write preservation | diff --git a/tests/execution/backends/test_claude_backend.py b/tests/execution/backends/test_claude_backend.py index a757f0c3e0..d3bc26517f 100644 --- a/tests/execution/backends/test_claude_backend.py +++ b/tests/execution/backends/test_claude_backend.py @@ -181,6 +181,25 @@ def test_resume_prompt_never_discards_base_prompt( assert marker_text in prompt +class TestOutputDisciplineDelivery: + @staticmethod + def _extract_prompt(spec: CmdSpec) -> str: + cmd = list(spec.cmd) + return cmd[cmd.index(ClaudeFlags.PRINT) + 1] + + def test_fresh_skill_session_does_not_include_codex_digest(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + + spec = ClaudeCodeBackend().build_skill_session_cmd("/plan", **SKILL_BASE) + assert OUTPUT_DISCIPLINE_DIGEST not in self._extract_prompt(spec) + + def test_fresh_orchestrator_does_not_include_codex_digest(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + + spec = ClaudeCodeBackend().build_food_truck_cmd(**FOOD_TRUCK_BASE) + assert OUTPUT_DISCIPLINE_DIGEST not in self._extract_prompt(spec) + + class TestBuildSkillSessionCmdConfigAdapter: def test_config_adapter_matches_impl(self) -> None: backend = ClaudeCodeBackend() diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index 662433a38e..34d34682f7 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -26,6 +26,7 @@ ) from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config from autoskillit.execution.backends._probe_cache import ( + PROBE_POLICY_IDENTITY, ProbeResult, read_probe_cache, write_probe_cache, @@ -66,10 +67,41 @@ reason=_CLAUDE_CODE_SKIP_REASON, ) +_skip_unless_codex_config_parse_probe = pytest.mark.skipif( + not os.environ.get("CODEX_CONFIG_PARSE_PROBE") or not shutil.which("codex"), + reason="Set CODEX_CONFIG_PARSE_PROBE=1 and have 'codex' on PATH to run the config probe", +) + _PROBE_BACKEND = "codex" _CANARY_TITLE_PREFIX = "[Canary] codex conformance probe" +@_skip_unless_codex_config_parse_probe +def test_installed_codex_parses_multiline_developer_instructions(tmp_path: Path) -> None: + """Guard the exact interactive ``-c`` TOML value accepted by installed Codex.""" + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + from autoskillit.execution.backends._codex_config import _format_toml_value + + caller_prompt = 'caller "prompt"\nwith a second line and \\ path' + combined = f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" + override = f"developer_instructions={_format_toml_value(combined)}" + env = dict(os.environ) + env["CODEX_HOME"] = str(tmp_path / "codex-home") + Path(env["CODEX_HOME"]).mkdir() + + result = subprocess.run( # noqa: S603 + ["codex", "-c", override, "doctor", "--json"], + capture_output=True, + text=True, + timeout=20, + env=env, + ) + + assert result.stdout, result.stderr + config_check = json.loads(result.stdout)["checks"]["config.load"] + assert config_check["status"] == "ok", config_check + + class _CodexProbeOutput(NamedTuple): events: list[dict] config_dict: dict @@ -191,7 +223,7 @@ def _probe_state(self, tmp_path: Path) -> None: def _check_cache(self) -> None: cli_version = _get_codex_version() - cached = read_probe_cache(self._cache_path, cli_version) + cached = read_probe_cache(self._cache_path, cli_version, PROBE_POLICY_IDENTITY) if cached is not None and cached.passed: pytest.skip(f"Probe cached as passed for {cli_version}") @@ -203,6 +235,7 @@ def _record_success(self, cli_version: str) -> None: self._cache_path, ProbeResult( cli_version=cli_version, + policy_identity=PROBE_POLICY_IDENTITY, passed=True, failure_detail=None, probe_timestamp=datetime.now(UTC).isoformat(), @@ -227,6 +260,7 @@ def _record_failure( self._cache_path, ProbeResult( cli_version=cli_version, + policy_identity=PROBE_POLICY_IDENTITY, passed=False, failure_detail=detail, probe_timestamp=datetime.now(UTC).isoformat(), @@ -450,7 +484,7 @@ def _exercise_output_budget_deny_probe(backend: str, tmp_path: Path) -> None: binary = "codex" if backend == "codex" else "claude" cli_version = _cli_version(binary, version_env) cache_path = tmp_path / f"{backend}-output-budget-probe-cache.json" - cached = read_probe_cache(cache_path, cli_version) + cached = read_probe_cache(cache_path, cli_version, PROBE_POLICY_IDENTITY) if cached is not None and cached.passed: pytest.skip(f"Output-budget deny probe cached as passed for {cli_version}") @@ -459,6 +493,7 @@ def _record(passed: bool, version: str, detail: str | None) -> None: cache_path, ProbeResult( cli_version=version, + policy_identity=PROBE_POLICY_IDENTITY, passed=passed, failure_detail=detail, probe_timestamp=datetime.now(UTC).isoformat(), diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 889f5e1832..2b74551435 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -29,6 +29,7 @@ SkillSessionConfig, StreamParser, ValidatedAddDir, + load_yaml, pkg_root, ) from autoskillit.execution.backends.codex import ( @@ -514,6 +515,12 @@ def test_narration_suppression_injected(self) -> None: spec = CodexBackend().build_skill_session_cmd(**self.BASE) assert "EFFICIENCY DIRECTIVE" in spec.cmd[-1] + def test_fresh_headless_includes_output_discipline_digest(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + + spec = CodexBackend().build_skill_session_cmd(**self.BASE) + assert OUTPUT_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_completion_reminder_injected(self) -> None: spec = CodexBackend().build_skill_session_cmd(**self.BASE) assert "Remember: end your final response with" in spec.cmd[-1] @@ -801,11 +808,21 @@ def test_bare_resume_produces_resume_subcommand_without_session_id(self) -> None assert "abc" not in spec.cmd def test_system_prompt_with_no_resume_appends_config_override(self) -> None: + import tomllib + + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + spec = CodexBackend().build_interactive_cmd(system_prompt="foo") overrides = [ spec.cmd[i + 1] for i, v in enumerate(spec.cmd[:-1]) if v == CodexFlags.CONFIG_OVERRIDE ] - assert "developer_instructions=foo" in overrides + rendered = next( + value.partition("=")[2] + for value in overrides + if value.startswith("developer_instructions=") + ) + parsed = tomllib.loads(f"developer_instructions = {rendered}") + assert parsed["developer_instructions"] == f"foo\n\n{OUTPUT_DISCIPLINE_DIGEST}" assert "features.image_generation=false" in overrides def test_system_prompt_with_named_resume_does_not_append_config_override(self) -> None: @@ -968,6 +985,12 @@ def test_mcp_tools_only_prompt_reinforcement(self) -> None: spec = CodexBackend().build_food_truck_cmd(**self.BASE) assert "ORCHESTRATION DIRECTIVE" in spec.cmd[-1] + def test_fresh_orchestrator_includes_output_discipline_digest(self) -> None: + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + + spec = CodexBackend().build_food_truck_cmd(**self.BASE) + assert OUTPUT_DISCIPLINE_DIGEST in spec.cmd[-1] + def test_prompt_is_last_token(self) -> None: spec = CodexBackend().build_food_truck_cmd(**self.BASE) assert "dispatch the work" in spec.cmd[-1] @@ -1757,11 +1780,11 @@ def test_setup_session_dir_creates_agents_directory(self) -> None: CodexBackend().setup_session_dir(self.session_dir) assert (self.session_dir / "agents").is_dir() - def test_agent_toml_count_matches_md_sources(self) -> None: + def test_agent_toml_set_and_count_match_md_sources(self) -> None: self._write_all_source_files() CodexBackend().setup_session_dir(self.session_dir) toml_files = list((self.session_dir / "agents").glob("*.toml")) - expected = 0 + expected_names: set[str] = set() for md_path in (pkg_root() / "agents").glob("*.md"): if md_path.name in ("CLAUDE.md", "AGENTS.md"): continue @@ -1771,10 +1794,14 @@ def test_agent_toml_count_matches_md_sources(self) -> None: parts = text.split("---", 2) if len(parts) < 3 or not parts[2].strip() or "'''" in parts[2]: continue - expected += 1 - assert len(toml_files) == expected, ( - f"TOML count {len(toml_files)} != valid source count {expected}" + meta = load_yaml(parts[1]) + if isinstance(meta, dict) and meta.get("name") and meta.get("description"): + expected_names.add(f"{meta['name']}.toml") + actual_names = {path.name for path in toml_files} + assert actual_names == expected_names, ( + f"generated TOMLs {actual_names} != valid source set {expected_names}" ) + assert len(toml_files) == len(expected_names) def test_agent_toml_required_fields_present_and_nonempty(self) -> None: import tomllib @@ -1788,6 +1815,11 @@ def test_agent_toml_required_fields_present_and_nonempty(self) -> None: assert data["developer_instructions"], ( f"{toml_path.name}: developer_instructions empty" ) + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + + assert ( + data["developer_instructions"].rstrip().endswith(OUTPUT_DISCIPLINE_DIGEST.rstrip()) + ) assert data["sandbox_mode"] == "workspace-write", ( f"{toml_path.name}: wrong sandbox_mode" ) diff --git a/tests/execution/backends/test_codex_interactive.py b/tests/execution/backends/test_codex_interactive.py index 89be46205e..31a0f1f2d4 100644 --- a/tests/execution/backends/test_codex_interactive.py +++ b/tests/execution/backends/test_codex_interactive.py @@ -1,15 +1,41 @@ from __future__ import annotations +import tomllib from pathlib import Path import pytest -from autoskillit.core import BareResume, CmdSpec, NamedResume, NoResume +from autoskillit.core import ( + OUTPUT_DISCIPLINE_DIGEST, + BareResume, + CmdSpec, + NamedResume, + NoResume, +) from autoskillit.execution.backends.codex import CodexBackend, CodexFlags pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] +def _developer_instructions(spec: CmdSpec) -> str | None: + overrides = [ + spec.cmd[i + 1] + for i, value in enumerate(spec.cmd[:-1]) + if value == CodexFlags.CONFIG_OVERRIDE + ] + rendered = next( + ( + value.partition("=")[2] + for value in overrides + if value.startswith("developer_instructions=") + ), + None, + ) + if rendered is None: + return None + return tomllib.loads(f"developer_instructions = {rendered}")["developer_instructions"] + + class TestCodexInteractiveCmdBaseStructure: def test_no_resume_base_command(self) -> None: spec = CodexBackend().build_interactive_cmd(resume_spec=NoResume()) @@ -66,10 +92,10 @@ def test_system_prompt_with_no_resume_produces_config_override(self) -> None: system_prompt="do stuff", resume_spec=NoResume(), ) + assert _developer_instructions(spec) == f"do stuff\n\n{OUTPUT_DISCIPLINE_DIGEST}" overrides = [ spec.cmd[i + 1] for i, v in enumerate(spec.cmd[:-1]) if v == CodexFlags.CONFIG_OVERRIDE ] - assert "developer_instructions=do stuff" in overrides assert "features.image_generation=false" in overrides def test_system_prompt_with_named_resume_suppressed(self) -> None: @@ -99,7 +125,16 @@ def test_no_system_prompt_with_no_resume_excludes_config_override(self) -> None: overrides = [ spec.cmd[i + 1] for i, v in enumerate(spec.cmd[:-1]) if v == CodexFlags.CONFIG_OVERRIDE ] - assert overrides == ["features.image_generation=false"] + assert _developer_instructions(spec) == OUTPUT_DISCIPLINE_DIGEST + assert "features.image_generation=false" in overrides + + def test_system_prompt_override_is_valid_toml_with_quotes_and_newlines(self) -> None: + caller_prompt = 'line one "quoted"\nline two \\ path' + spec = CodexBackend().build_interactive_cmd( + system_prompt=caller_prompt, + resume_spec=NoResume(), + ) + assert _developer_instructions(spec) == (f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}") class TestCodexInteractiveCmdAddDirs: diff --git a/tests/execution/backends/test_probe_cache.py b/tests/execution/backends/test_probe_cache.py index d384124e51..edafa64021 100644 --- a/tests/execution/backends/test_probe_cache.py +++ b/tests/execution/backends/test_probe_cache.py @@ -6,9 +6,14 @@ import pytest +from autoskillit.core import ( + OUTPUT_DISCIPLINE_BLOCK_SHA256, + OUTPUT_DISCIPLINE_POLICY_VERSION, +) from autoskillit.execution.backends._probe_cache import ( _SCHEMA_VERSION, PROBE_CACHE_TTL, + PROBE_POLICY_IDENTITY, ProbeResult, read_probe_cache, write_probe_cache, @@ -16,9 +21,12 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] +_POLICY_IDENTITY = "v1-policy-hash" + def _make_result( cli_version: str = "1.0.0", + policy_identity: str = _POLICY_IDENTITY, passed: bool = True, failure_detail: str | None = None, ts: datetime | None = None, @@ -27,6 +35,7 @@ def _make_result( ts = datetime.now(UTC) return ProbeResult( cli_version=cli_version, + policy_identity=policy_identity, passed=passed, failure_detail=failure_detail, probe_timestamp=ts.isoformat(), @@ -41,67 +50,131 @@ def _write_raw_cache(path: Path, entries: dict, *, schema_version: int = _SCHEMA class TestReadProbeCache: def test_returns_none_on_missing_file(self, tmp_path: Path) -> None: - assert read_probe_cache(tmp_path / "nope.json", "1.0.0") is None + assert read_probe_cache(tmp_path / "nope.json", "1.0.0", _POLICY_IDENTITY) is None def test_returns_none_on_corrupt_json(self, tmp_path: Path) -> None: p = tmp_path / "cache.json" p.write_text("NOT VALID JSON") - assert read_probe_cache(p, "1.0.0") is None + assert read_probe_cache(p, "1.0.0", _POLICY_IDENTITY) is None def test_returns_none_on_stale_entry(self, tmp_path: Path) -> None: stale_ts = (datetime.now(UTC) - PROBE_CACHE_TTL - timedelta(hours=1)).isoformat() _write_raw_cache( tmp_path / "cache.json", { - "1.0.0": {"passed": True, "failure_detail": None, "probe_timestamp": stale_ts}, + "1.0.0": { + "policy_identity": _POLICY_IDENTITY, + "passed": True, + "failure_detail": None, + "probe_timestamp": stale_ts, + }, }, ) - assert read_probe_cache(tmp_path / "cache.json", "1.0.0") is None + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", _POLICY_IDENTITY) is None def test_returns_none_on_version_mismatch(self, tmp_path: Path) -> None: fresh_ts = datetime.now(UTC).isoformat() _write_raw_cache( tmp_path / "cache.json", { - "2.0.0": {"passed": True, "failure_detail": None, "probe_timestamp": fresh_ts}, + "2.0.0": { + "policy_identity": _POLICY_IDENTITY, + "passed": True, + "failure_detail": None, + "probe_timestamp": fresh_ts, + }, }, ) - assert read_probe_cache(tmp_path / "cache.json", "1.0.0") is None + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", _POLICY_IDENTITY) is None + + def test_returns_none_on_policy_version_mismatch(self, tmp_path: Path) -> None: + fresh_ts = datetime.now(UTC).isoformat() + _write_raw_cache( + tmp_path / "cache.json", + { + "1.0.0": { + "policy_identity": "v1-policy-hash", + "passed": True, + "failure_detail": None, + "probe_timestamp": fresh_ts, + }, + }, + ) + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", "v2-policy-hash") is None + + def test_returns_none_on_policy_hash_mismatch(self, tmp_path: Path) -> None: + fresh_ts = datetime.now(UTC).isoformat() + _write_raw_cache( + tmp_path / "cache.json", + { + "1.0.0": { + "policy_identity": "v1-policy-hash-a", + "passed": True, + "failure_detail": None, + "probe_timestamp": fresh_ts, + }, + }, + ) + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", "v1-policy-hash-b") is None def test_returns_probe_result_on_fresh_hit(self, tmp_path: Path) -> None: fresh_ts = datetime.now(UTC).isoformat() _write_raw_cache( tmp_path / "cache.json", { - "1.0.0": {"passed": True, "failure_detail": None, "probe_timestamp": fresh_ts}, + "1.0.0": { + "policy_identity": _POLICY_IDENTITY, + "passed": True, + "failure_detail": None, + "probe_timestamp": fresh_ts, + }, }, ) - result = read_probe_cache(tmp_path / "cache.json", "1.0.0") + result = read_probe_cache(tmp_path / "cache.json", "1.0.0", _POLICY_IDENTITY) assert result is not None assert result.passed is True assert result.cli_version == "1.0.0" + assert result.policy_identity == _POLICY_IDENTITY def test_returns_none_on_schema_version_mismatch(self, tmp_path: Path) -> None: fresh_ts = datetime.now(UTC).isoformat() _write_raw_cache( tmp_path / "cache.json", - {"1.0.0": {"passed": True, "failure_detail": None, "probe_timestamp": fresh_ts}}, + { + "1.0.0": { + "policy_identity": _POLICY_IDENTITY, + "passed": True, + "failure_detail": None, + "probe_timestamp": fresh_ts, + } + }, schema_version=999, ) - assert read_probe_cache(tmp_path / "cache.json", "1.0.0") is None + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", _POLICY_IDENTITY) is None def test_returns_none_on_naive_timestamp(self, tmp_path: Path) -> None: _write_raw_cache( tmp_path / "cache.json", { "1.0.0": { + "policy_identity": _POLICY_IDENTITY, "passed": True, "failure_detail": None, "probe_timestamp": "2000-01-01T00:00:00", }, }, ) - assert read_probe_cache(tmp_path / "cache.json", "1.0.0") is None + assert read_probe_cache(tmp_path / "cache.json", "1.0.0", _POLICY_IDENTITY) is None + + +def test_probe_policy_identity_uses_output_discipline_authorities() -> None: + assert PROBE_POLICY_IDENTITY == ( + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}" + ) + + +def test_probe_cache_schema_is_version_two() -> None: + assert _SCHEMA_VERSION == 2 class TestWriteProbeCache: @@ -112,6 +185,7 @@ def test_creates_file(self, tmp_path: Path) -> None: raw = json.loads(p.read_text()) assert "entries" in raw assert "1.0.0" in raw["entries"] + assert raw["entries"]["1.0.0"]["policy_identity"] == _POLICY_IDENTITY def test_preserves_other_entries(self, tmp_path: Path) -> None: p = tmp_path / "cache.json" diff --git a/tests/execution/test_headless_core.py b/tests/execution/test_headless_core.py index b4ae090fa4..3946d4436a 100644 --- a/tests/execution/test_headless_core.py +++ b/tests/execution/test_headless_core.py @@ -2955,6 +2955,7 @@ def test_prompt_injector_registry(): assert "completion-directive" in names assert "cwd-anchor" in names assert "narration-suppression" in names + assert "output-discipline" in names assert "output-format-reinforcement" in names assert "completion-reminder" in names assert names[0] == "completion-directive" @@ -2967,6 +2968,24 @@ def test_prompt_injector_registry(): ) +def test_output_discipline_injector_is_backend_conditional(): + from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST + from autoskillit.execution.backends._claude_prompt import ( + PromptBuildContext, + apply_prompt_injector_chain, + ) + + codex_prompt = apply_prompt_injector_chain( + "base", PromptBuildContext(include_output_discipline=True) + ) + claude_prompt = apply_prompt_injector_chain( + "base", PromptBuildContext(include_output_discipline=False) + ) + + assert OUTPUT_DISCIPLINE_DIGEST in codex_prompt + assert OUTPUT_DISCIPLINE_DIGEST not in claude_prompt + + def test_inject_output_format_reinforcement_non_anthropic(): from autoskillit.execution.backends._claude_prompt import _inject_output_format_reinforcement from autoskillit.execution.headless._headless_path_tokens import _OUTPUT_PATH_TOKENS diff --git a/tests/infra/test_conformance_probes_workflow.py b/tests/infra/test_conformance_probes_workflow.py index 52c6287862..011e1e3634 100644 --- a/tests/infra/test_conformance_probes_workflow.py +++ b/tests/infra/test_conformance_probes_workflow.py @@ -124,6 +124,18 @@ def test_version_fallback_to_unknown(self, workflow: dict, job_name: str) -> Non class TestCacheGate: + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) + def test_exports_output_discipline_policy_identity( + self, workflow: dict, job_name: str + ) -> None: + steps = workflow["jobs"][job_name]["steps"] + policy_steps = [s for s in steps if s.get("id") == "resolve-policy"] + assert len(policy_steps) == 1 + run = policy_steps[0].get("run", "") + assert "PROBE_POLICY_IDENTITY" in run + assert "policy_identity=${POLICY_IDENTITY}" in run + assert "GITHUB_OUTPUT" in run + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) def test_cache_restore_step(self, workflow: dict, job_name: str) -> None: steps = workflow["jobs"][job_name]["steps"] @@ -156,6 +168,21 @@ def test_cache_key_contains_backend( key = step.get("with", {}).get("key", "") assert f"probe-{expected_backend}" in key + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) + def test_restore_and_save_keys_include_policy_identity( + self, workflow: dict, job_name: str + ) -> None: + steps = workflow["jobs"][job_name]["steps"] + cache_steps = [ + s + for s in steps + if "cache/restore" in (s.get("uses") or "") or "cache/save" in (s.get("uses") or "") + ] + assert len(cache_steps) == 2 + for step in cache_steps: + key = step.get("with", {}).get("key", "") + assert "steps.resolve-policy.outputs.policy_identity" in key + class TestPostFailure: @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) diff --git a/tests/skills/test_investigate_deep_mode_contracts.py b/tests/skills/test_investigate_deep_mode_contracts.py index 3db5e40d62..b3bef743f7 100644 --- a/tests/skills/test_investigate_deep_mode_contracts.py +++ b/tests/skills/test_investigate_deep_mode_contracts.py @@ -145,6 +145,21 @@ def test_d1_headless_auto_approve(deep_workflow_section: str) -> None: ) +def test_d1_reserves_completion_budget_before_each_batch( + deep_workflow_section: str, +) -> None: + """Deep mode must stop gathering before it consumes the completion reserve.""" + d1 = extract_step_section(deep_workflow_section, "Step D1") + normalized = d1.lower() + assert "before authorizing each batch" in normalized + assert "reserve" in normalized + assert "synthesis" in normalized + assert "report writing" in normalized + assert "post-report validation" in normalized + assert "stop gathering" in normalized + assert "would cross that reserve" in normalized + + def test_d2_broad_exploration_minimum_subagents(deep_workflow_section: str) -> None: """Step D2 must specify a minimum of 5 parallel subagents.""" d2 = extract_step_section(deep_workflow_section, "Step D2") From 42bd9a17ea7d6b72ed68c11aa1853fa6aa622524 Mon Sep 17 00:00:00 2001 From: Trecek Date: Wed, 15 Jul 2026 18:26:54 -0700 Subject: [PATCH 04/30] feat: derive output ceilings and add capability coverage --- .github/workflows/conformance-probes.yml | 13 +- docs/decisions/0005-output-budget-protocol.md | 151 ++++++++++ docs/decisions/README.md | 1 + src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/types/_type_constants.py | 3 + .../execution/backends/_codex_config.py | 75 +++-- .../hooks/formatters/_fmt_recipe.py | 2 +- tests/_test_filter.py | 22 ++ tests/arch/test_subpackage_structure.py | 4 +- tests/docs/AGENTS.md | 1 + tests/docs/test_filename_naming.py | 1 + .../test_output_budget_protocol_decision.py | 135 +++++++++ tests/execution/AGENTS.md | 1 + .../backends/_conformance_assertions.py | 67 +++++ .../config_toml_schema_template.json | 4 +- .../backends/test_cli_conformance_probes.py | 282 ++++++++++++++++++ tests/execution/backends/test_codex_config.py | 90 +++++- .../test_codex_deterministic_conformance.py | 49 ++- .../infra/test_conformance_probes_workflow.py | 23 ++ tests/infra/test_pretty_output_recipe.py | 33 +- tests/server/AGENTS.md | 1 + tests/server/test_output_budget_e2e.py | 202 +++++++++++++ tests/test_test_filter.py | 8 + 23 files changed, 1126 insertions(+), 43 deletions(-) create mode 100644 docs/decisions/0005-output-budget-protocol.md create mode 100644 tests/docs/test_output_budget_protocol_decision.py create mode 100644 tests/server/test_output_budget_e2e.py diff --git a/.github/workflows/conformance-probes.yml b/.github/workflows/conformance-probes.yml index 4d36884e1e..563013d99c 100644 --- a/.github/workflows/conformance-probes.yml +++ b/.github/workflows/conformance-probes.yml @@ -17,7 +17,7 @@ jobs: codex-probe: name: Codex CLI conformance probe runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 75 env: CODEX_SMOKE_TEST: "1" AUTOSKILLIT_FEATURES__EXPERIMENTAL_ENABLED: "true" @@ -95,6 +95,7 @@ jobs: .venv/bin/python -m pytest \ tests/execution/test_smoke_codex.py \ tests/execution/backends/test_cli_conformance_probes.py \ + tests/server/test_output_budget_e2e.py \ -m smoke -v --tb=short -o "addopts=" \ --basetemp="$TMPDIR/basetemp" \ -o "cache_dir=$TMPDIR/cache" @@ -121,7 +122,7 @@ jobs: claude-probe: name: Claude Code CLI conformance probe runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 75 env: CLAUDE_CODE_SMOKE_TEST: "1" AUTOSKILLIT_FEATURES__EXPERIMENTAL_ENABLED: "true" @@ -186,10 +187,18 @@ jobs: - name: Run Claude Code conformance probes if: steps.restore-cache.outputs.cache-hit != 'true' + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} run: | + if [ -z "$ANTHROPIC_API_KEY" ] && [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then + echo "::error::No isolated Claude credential is configured — cannot run Claude probes" + exit 1 + fi mkdir -p .autoskillit/temp/probe-cache/claude .venv/bin/python -m pytest \ tests/execution/backends/test_cli_conformance_probes.py \ + tests/server/test_output_budget_e2e.py \ -m smoke -v --tb=short -o "addopts=" \ --basetemp="$TMPDIR/basetemp" \ -o "cache_dir=$TMPDIR/cache" diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md new file mode 100644 index 0000000000..f780c84600 --- /dev/null +++ b/docs/decisions/0005-output-budget-protocol.md @@ -0,0 +1,151 @@ +# ADR-0005: Output Budget Protocol + +**Status:** Accepted +**Date:** 2026-07-15 +**Issue:** [#4272](https://github.com/TalonT-Org/AutoSkillit/issues/4272) + +## Context + +An investigation of a Codex session exhausted the orchestrator's context after two +commands returned large, mostly irrelevant bodies. The model had no enforceable +per-response budget before the commands ran, at the producing tools, or at the common +MCP response boundary. This is the recurring failure class behind +[#4253](https://github.com/TalonT-Org/AutoSkillit/issues/4253), +[#2819](https://github.com/TalonT-Org/AutoSkillit/issues/2819), +[#2564](https://github.com/TalonT-Org/AutoSkillit/issues/2564), and +[#3938](https://github.com/TalonT-Org/AutoSkillit/issues/3938): a useful control-plane +signal is carried beside unbounded evidence, and a transport or context limit removes +the information needed to continue. + +The system has two competing masters. `open_kitchen` must retain enough transport +headroom to deliver the complete recipe and terminal sentinel. Native shell and tool +responses must have a low enough containment ceiling that one response cannot consume +an excessive share of context. Treating either requirement as the only authority makes +the other path unsafe. + +## Decision + +AutoSkillit adopts a four-layer Output Budget Protocol. The first applicable layer owns +containment; downstream layers are independent backstops rather than substitutes. + +1. **Lossless source shaping:** output-aware producers spill the complete output to an + atomically published project artifact and return a bounded head/tail summary. This is + producer-side, post-execution enforcement. +2. **Universal response backstop:** the common MCP response boundary measures the final + handler representation and uses routing- and shape-preserving projection. This is + producer-blind, post-execution enforcement. A response that cannot be persisted or + projected safely fails closed without returning the original payload. +3. **Pre-spend command guard:** enumerated high-confidence unbounded shell shapes are + denied before execution and receive a bounded-rewrite instruction. This is static, + pre-execution enforcement for rules R1 through R3. +4. **Producer-aware discipline and derived transport ceiling:** one evidence-output + policy is delivered on backend surfaces that support it, while Codex's stored + tool/function output receives a derived damage ceiling. The policy is advisory; + the ceiling is downstream containment. Neither is cumulative-context accounting. + +The raw-text `open_kitchen` and `load_recipe` responses are measured exemptions from the +universal backstop. Adding an exemption requires its own measured-budget test. + +## Numeric Limits and Rationale + +| Limit | Decision and rationale | +|---|---| +| `OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000` | This is a stable regression ceiling, not an estimate of the external gate. The 2026-07-15 maximum canonical rendering was 95,771 UTF-8 bytes for `remediation` with all truthy ingredients. The 229-byte project margin keeps growth explicit, while the payload remains roughly 4 KB below the last observed external persistence gate near 100 KB. Re-measure after CLI upgrades instead of silently raising the constant. | +| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 32_000` | Derive it as `((96_000 + 3) // 4) + 8_000`: 24,000 tokens under the current client's four-byte heuristic, plus 8,000 tokens (32 KB under that heuristic) for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. | +| `CODEX_AUTO_COMPACT_LIMIT = 999_999_999` | Retain the unreachable sentinel and the recovery obligation accepted in [ADR-0004](0004-recipe-redelivery.md). This protocol does not relax recipe-preservation policy. | +| `inline_max_chars = 5_000` | Preserve the previous truncation threshold while changing the representation from destructive clipping to an artifact-backed preview. The configured 2,500-character head and 2,500-character tail retain both diagnostic setup and terminal status; a spill marker is added outside those source slices. | +| `response_max_bytes = 90_000` | Bound the exact compact serialized handler payload before a coarser transport can clip it. Bytes are authoritative here; this is not a token or full JSON-RPC-envelope estimate. | +| `MAX_MCP_OUTPUT_TOKENS = 50_000` | Keep Claude's independently defined setting separate. It has no shared source of truth with `CODEX_TOOL_OUTPUT_TOKEN_LIMIT` and does not control Claude Code's observed disk-persistence gate. Claude's native Bash spill behavior covers shell output on that backend. | + +The command guard also reads `small_file_max_bytes = 5_000` for its narrow literal-JSONL +exception and accepts proven byte sinks only through `shell_max_inline_bytes = 12_000`. +Those are classification bounds, not transport limits. Parser limits of 65,536 command +characters and nesting depth 8 bound classification work; exceeding them produces an +unknown disposition and cannot authorize a risky R1-R3 command. + +## Ceiling and Backstop Reconciliation + +Codex CLI 0.144.1 uses `APPROX_BYTES_PER_TOKEN = 4` and integer byte arithmetic in +[`codex-rs/utils/string/src/truncate.rs` at tag `rust-v0.144.1`, commit +`44918ea10c0f99151c6710411b4322c2f5c96bea`](https://github.com/openai/codex/blob/44918ea10c0f99151c6710411b4322c2f5c96bea/codex-rs/utils/string/src/truncate.rs). +This is a coarse one-token-per-four-UTF-8-bytes truncation heuristic. It performs no real +tokenization and provides neither a tokenizer guarantee nor a cumulative-context +estimate. + +The project therefore requires the stricter relationship +`response_max_bytes // 3 < CODEX_TOOL_OUTPUT_TOKEN_LIMIT`. The three-byte divisor is +deliberate margin: the 90,000-byte response backstop must fire before Codex's 32,000-token +transport ceiling can clip a producer-blind response. A static test pins the relationship, +and the live large-output probe must pass before either side is retuned. + +The measured raw-text exemptions, `open_kitchen` and `load_recipe`, must each remain below +the 128,000-byte budget implied by the current 32,000-token, four-byte heuristic. Their +measurements are independent release gates; the heuristic is not permission to omit +those tests. + +## Corrections of Record + +Commit `6b421e38e` introduced the `_codex_config.py` comment framing +`tool_output_token_limit` as a per-MCP-tool response budget sized for `open_kitchen`. +That framing was incorrect. The Codex setting governs tool/function output stored in +context, including native shell, `unified_exec`, and MCP output. It is a global damage +ceiling and does not make `open_kitchen` lossless. + +[PR #4259](https://github.com/TalonT-Org/AutoSkillit/pull/4259) included +`Closes #4253`, but GitHub did not auto-close the issue because the PR merged into +`develop`, not the repository's default branch, `main`. Closure must therefore be +recorded explicitly in the issue body. + +## Accepted Gaps + +1. Non-JSONL single-file searches and shell verbs outside R1-R3, including `python -c` + file dumps, `git log -p`, and `curl`, are bounded on Codex only by the derived ceiling. + Codex may still truncate and discard their excess output. +2. The interactive discipline digest does not survive Codex `resume` and cannot + guarantee post-compaction reinjection. Those paths retain the command guard, transport + ceilings, and file-materialized skill content. +3. If tool context or artifact persistence is unavailable, the response backstop fails + closed with a bounded explicit error. The original response is unavailable to the + caller but never enters model context. +4. Prompt-side caps for GitHub issue/comment embedding, `resume_message`, and + `attempt_history` are outside this tool-output protocol. +5. `merge_worktree` drops passing raw test output at the server boundary by design. +6. Routing- and shape-preserving projections retain control-plane keys and value types + and place complete domain data in the artifact. A caller needing pruned collection + members must retrieve bounded slices from that artifact. +7. Repeated individually bounded calls can still exhaust cumulative context. No pre-call + component owns current context usage, so reserve instructions remain advisory. +8. Subprocess output is still materialized in worker memory before model-context shaping. + The protocol does not bound worker memory or temporary-disk consumption. + +## Operational Signals + +Output-budget instrumentation uses low-cardinality structured counters or events for: + +- spill count by producer/tool class; +- original and artifact UTF-8 byte totals; +- measured exemption use; and +- spill failures grouped by bounded cause code. + +Signals must never contain artifact paths, hashes, or output content. This decision does +not introduce artifact quota, artifact cleanup, cumulative reserve accounting, or +reserve-trigger metrics, so instrumentation must not claim those mechanisms exist. + +## Forward Obligations + +- Re-measure the 96,000-byte empirical `open_kitchen` bound after CLI upgrades. +- Any ceiling relaxation, command-guard rule removal, response-backstop exemption + addition, or output-discipline policy-version change invalidates the applicable cached + capability probe. +- Run and pass the live large-output probe before making any of those changes effective. +- Preserve ADR-0004's end-to-end `load_recipe` re-delivery obligation if the + 999,999,999 auto-compaction sentinel is ever relaxed. + +## Consequences + +- Ordinary large responses become lossless artifacts with bounded inline evidence. +- High-confidence unbounded shell calls are refused before their output is produced. +- Transport limits are derived from a measured control-plane payload instead of an + unrelated generous constant. +- Artifact/invariant failures become explicit bounded errors instead of context floods. +- The accepted gaps above remain visible work rather than implicit guarantees. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 75de7f9edc..63f65d2b12 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -4,3 +4,4 @@ - [0002-ban-inline-shell-scripts-from-cmd.md](0002-ban-inline-shell-scripts-from-cmd.md) — Prohibit inline shell scripts in recipe cmd fields; require externalization to .sh files or run_python callables - [0003-skill-args.md](0003-skill-args.md) — Pass skill inputs as positional arguments, not environment variables - [0004-recipe-redelivery.md](0004-recipe-redelivery.md) — Sanctioned `load_recipe` channel for recipe knowledge re-delivery after Codex context compaction +- [0005-output-budget-protocol.md](0005-output-budget-protocol.md) — Bound per-response model-context output with lossless artifacts, pre-spend guards, and derived transport ceilings diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index 4795aa2ab1..ac5ceb3b1d 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -184,6 +184,7 @@ from .types import LABEL_TRANSITIONS as LABEL_TRANSITIONS from .types import LAUNCH_ID_ENV_VAR as LAUNCH_ID_ENV_VAR from .types import MCP_CLIENT_BACKEND_ENV_VAR as MCP_CLIENT_BACKEND_ENV_VAR from .types import NON_VARIADIC_CLAUDE_FLAGS as NON_VARIADIC_CLAUDE_FLAGS +from .types import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES as OPEN_KITCHEN_OUTPUT_BUDGET_BYTES from .types import ORCHESTRATOR_SESSION_REQUIRED_ENV as ORCHESTRATOR_SESSION_REQUIRED_ENV from .types import ORDER_INTERACTIVE_REQUIRED_ENV as ORDER_INTERACTIVE_REQUIRED_ENV from .types import OUTPUT_DISCIPLINE_BLOCK as OUTPUT_DISCIPLINE_BLOCK diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index 443ac5efbf..0b92dc2037 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -11,6 +11,7 @@ from ._type_constants_registries import SKILL_CAPABILITY_REGISTRY __all__ = [ + "OPEN_KITCHEN_OUTPUT_BUDGET_BYTES", "OUTPUT_DISCIPLINE_POLICY_VERSION", "OUTPUT_DISCIPLINE_BLOCK", "OUTPUT_DISCIPLINE_BLOCK_SHA256", @@ -50,6 +51,8 @@ "CODEX_SESSIONS_SUBDIR", ] +OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000 + OUTPUT_DISCIPLINE_POLICY_VERSION = 1 OUTPUT_DISCIPLINE_BLOCK = "\n".join( diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 0be87a2360..91c677694b 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -10,6 +10,7 @@ from autoskillit.core import ( CODEX_MCP_ENV_FORWARD_VARS, HEADLESS_AUTO_GATE_ENV_VAR, + OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, ReadResult, atomic_write, get_logger, @@ -23,9 +24,11 @@ CODEX_MCP_STARTUP_TIMEOUT_SEC: float = 30.0 -# Per-tool response size budget for Codex MCP tools. Sufficient for current -# `open_kitchen` response sizes with 2x headroom. -CODEX_TOOL_OUTPUT_TOKEN_LIMIT: int = 50_000 +# Damage bound for all tool/function output stored in Codex context, including +# native shell and MCP output. Codex currently applies a coarse four-UTF-8-byte +# truncation heuristic; the extra 8,000 tokens provide serialized-payload +# headroom. Guard and spill layers remain the load-bearing mechanisms. +CODEX_TOOL_OUTPUT_TOKEN_LIMIT: int = ((OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + 3) // 4) + 8_000 # Disable Codex auto-compaction by setting the limit to an unreachable value. # Auto-compaction at 90% of 258K context window can destroy recipe content @@ -239,7 +242,7 @@ def _is_autoskillit_registered( return False if entry.get("startup_timeout_sec") != CODEX_MCP_STARTUP_TIMEOUT_SEC: return False - if config.get("tool_output_token_limit", 0) < CODEX_TOOL_OUTPUT_TOKEN_LIMIT: + if config.get("tool_output_token_limit") != CODEX_TOOL_OUTPUT_TOKEN_LIMIT: return False if config.get("model_auto_compact_token_limit", 0) < CODEX_AUTO_COMPACT_LIMIT: return False @@ -247,26 +250,55 @@ def _is_autoskillit_registered( def _ensure_top_level_key(path: Path, *, key: str, value: int) -> None: - """Insert `key = value` at the top of a TOML file if not already present. + """Ensure a bare top-level integer scalar is at least ``value``. `safe_upsert_section` only writes `[section]` blocks; it cannot write bare top-level scalars. This helper handles the bare-scalar case for the - corrupt-file path where the entire config needs text-level edits. + corrupt-file path while preserving higher values. """ existing = path.read_text(encoding="utf-8") if path.exists() else "" - pattern = _re.compile(rf"^\s*{_re.escape(key)}\s*=", _re.MULTILINE) - if pattern.search(existing): - return - line = f"{key} = {value}\n" lines = existing.splitlines(keepends=True) - insert_at = 0 - for i, ln in enumerate(lines): - if ln.lstrip().startswith("["): + assignment = _re.compile(rf"^\s*{_re.escape(key)}\s*=\s*(?P[+-]?[0-9][0-9_]*)") + insert_at = len(lines) + for i, line in enumerate(lines): + if line.lstrip().startswith("["): insert_at = i break - insert_at = i + 1 - new_lines = lines[:insert_at] + [line] + lines[insert_at:] - atomic_write(path, "".join(new_lines)) + match = assignment.match(line) + if match: + current = int(match.group("value").replace("_", "")) + if current >= value: + return + newline = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else "" + lines[i] = f"{key} = {value}{newline}" + atomic_write(path, "".join(lines)) + return + lines.insert(insert_at, f"{key} = {value}\n") + atomic_write(path, "".join(lines)) + + +def _upsert_top_level_key_exact(path: Path, *, key: str, value: int) -> None: + """Set a bare top-level scalar to exactly ``value`` using text-level edits. + + This is deliberately separate from ``_ensure_top_level_key``: the Codex + tool-output setting is exact, while the auto-compact setting retains its + independent minimum semantics. + """ + existing = path.read_text(encoding="utf-8") if path.exists() else "" + lines = existing.splitlines(keepends=True) + assignment = _re.compile(rf"^\s*{_re.escape(key)}\s*=") + insert_at = len(lines) + for i, line in enumerate(lines): + if line.lstrip().startswith("["): + insert_at = i + break + if assignment.match(line): + newline = "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else "" + lines[i] = f"{key} = {value}{newline}" + atomic_write(path, "".join(lines)) + return + lines.insert(insert_at, f"{key} = {value}\n") + atomic_write(path, "".join(lines)) def ensure_codex_mcp_registered( @@ -299,7 +331,7 @@ def ensure_codex_mcp_registered( if result.is_corrupt: section_text = _serialize_mcp_autoskillit_section(entry) safe_upsert_section(config_path, "[mcp_servers.autoskillit]", section_text) - _ensure_top_level_key( + _upsert_top_level_key_exact( config_path, key="tool_output_token_limit", value=CODEX_TOOL_OUTPUT_TOKEN_LIMIT, @@ -319,7 +351,12 @@ def ensure_codex_mcp_registered( ): return False config.setdefault("mcp_servers", {})["autoskillit"] = entry - config.setdefault("tool_output_token_limit", CODEX_TOOL_OUTPUT_TOKEN_LIMIT) - config["model_auto_compact_token_limit"] = CODEX_AUTO_COMPACT_LIMIT + config["tool_output_token_limit"] = CODEX_TOOL_OUTPUT_TOKEN_LIMIT + existing_compact_limit = config.get("model_auto_compact_token_limit", 0) + if not isinstance(existing_compact_limit, int): + existing_compact_limit = 0 + config["model_auto_compact_token_limit"] = max( + existing_compact_limit, CODEX_AUTO_COMPACT_LIMIT + ) _write_codex_config(config_path, config, source=result) return True diff --git a/src/autoskillit/hooks/formatters/_fmt_recipe.py b/src/autoskillit/hooks/formatters/_fmt_recipe.py index 0bb0872a53..c3a718c49f 100644 --- a/src/autoskillit/hooks/formatters/_fmt_recipe.py +++ b/src/autoskillit/hooks/formatters/_fmt_recipe.py @@ -29,7 +29,7 @@ # Empirically below the ~100KB Claude Code CLI disk-persistence gate (measured on # CLI 2.1.197); re-measure after CLI upgrades rather than treating this as a # protocol constant. See issue #4253. -_OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 95_000 +_OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000 # Field coverage contract for _fmt_load_recipe ↔ LoadRecipeResult _FMT_LOAD_RECIPE_RENDERED: frozenset[str] = frozenset( diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 2e7094a224..04c0c8bd26 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -864,6 +864,9 @@ class ImportContext(enum.StrEnum): "fleet/test_resume_precondition.py", # file-level: cross-layer test that imports server TypedDict schemas "execution/backends/test_failure_predicate_spanning.py", + # file-level: Codex output-budget config and generated agent contracts + "execution/backends/test_codex_config.py", + "execution/backends/test_codex_backend.py", } ), "cli": frozenset( @@ -886,6 +889,9 @@ class ImportContext(enum.StrEnum): "server/test_tools_kitchen_gate_hook_config.py", "execution/test_quota_sleep.py", "execution/test_session_log_fields.py", + # file-level: Codex output-budget config and generated agent contracts + "execution/backends/test_codex_config.py", + "execution/backends/test_codex_backend.py", } ), "hook_registry": frozenset( @@ -900,6 +906,8 @@ class ImportContext(enum.StrEnum): "server/test_tools_fleet_dispatch_preflight.py", "server/test_lifespan.py", "server/test_run_skill_backend_compat.py", + # file-level: live output-budget E2E imports the hook registry directly + "server/test_output_budget_e2e.py", # infra/ narrowed to 7 files "infra/test_adr_runtime_guard_coverage.py", "infra/test_command_guard_completeness.py", @@ -958,6 +966,20 @@ class ImportContext(enum.StrEnum): # otherwise test_file_level_entries_import_their_cascade_package will # incorrectly require a direct AST import from those test files. _IMPORT_GUARD_TRANSITIVE_OVERRIDES: dict[str, frozenset[str]] = { + # Output-budget hook/server changes affect the derived Codex ceiling and generated + # agent contracts even though those backend tests intentionally import execution only. + "hooks": frozenset( + { + "execution/backends/test_codex_backend.py", + "execution/backends/test_codex_config.py", + } + ), + "server": frozenset( + { + "execution/backends/test_codex_backend.py", + "execution/backends/test_codex_config.py", + } + ), "workspace": frozenset( { "recipe/test_api.py", diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 574593d114..5d2e64f2ff 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,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) == 103, ( - f"Expected 103 symbols total, got {len(combined)} " + assert len(combined) == 104, ( + f"Expected 104 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/docs/AGENTS.md b/tests/docs/AGENTS.md index 94c275befb..0e2b7d03b0 100644 --- a/tests/docs/AGENTS.md +++ b/tests/docs/AGENTS.md @@ -22,4 +22,5 @@ Documentation integrity, link validity, and naming convention tests. | `test_tests_sub_claude_md_completeness.py` | Structural tests for per-subfolder AGENTS.md files under 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_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_filename_naming.py b/tests/docs/test_filename_naming.py index 0ffece4e91..44e89c4245 100644 --- a/tests/docs/test_filename_naming.py +++ b/tests/docs/test_filename_naming.py @@ -22,6 +22,7 @@ "recording-replay-accepted-degradations.md", "0001-prohibit-background-subagent-execution.md", "0002-ban-inline-shell-scripts-from-cmd.md", + "0005-output-budget-protocol.md", # prescribed by issue #4272 "paper-backend-n3-exercise.md", # 4 segments; prescribed by issue #4052 }, } diff --git a/tests/docs/test_output_budget_protocol_decision.py b/tests/docs/test_output_budget_protocol_decision.py new file mode 100644 index 0000000000..49d08dd937 --- /dev/null +++ b/tests/docs/test_output_budget_protocol_decision.py @@ -0,0 +1,135 @@ +"""Ratchet the accepted Output Budget Protocol decision record.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DECISION = REPO_ROOT / "docs/decisions/0005-output-budget-protocol.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-0005 must exist" + return DECISION.read_text(encoding="utf-8") + + +def test_output_budget_decision_is_indexed(decision_text: str) -> None: + assert "**Status:** Accepted" in decision_text + assert "**Date:** 2026-07-15" in decision_text + assert "#4272" in decision_text + assert "0005-output-budget-protocol.md" in DECISION_INDEX.read_text(encoding="utf-8") + + +def test_decision_owns_independent_backend_limits(decision_text: str) -> None: + assert "MAX_MCP_OUTPUT_TOKENS" in decision_text + assert "CODEX_TOOL_OUTPUT_TOKEN_LIMIT" in decision_text + assert "no shared source of truth" in decision_text + + +@pytest.mark.parametrize( + "required", + [ + "Lossless source shaping", + "Universal response backstop", + "Pre-spend command guard", + "Producer-aware discipline and derived transport ceiling", + ], +) +def test_decision_names_all_four_layers(decision_text: str, required: str) -> None: + assert required in decision_text + + +@pytest.mark.parametrize( + "required", + [ + "OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000", + "95,771 UTF-8 bytes", + "229-byte project margin", + "((96_000 + 3) // 4) + 8_000", + "CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 32_000", + "CODEX_AUTO_COMPACT_LIMIT = 999_999_999", + "inline_max_chars = 5_000", + "response_max_bytes = 90_000", + "MAX_MCP_OUTPUT_TOKENS = 50_000", + ], +) +def test_decision_pins_numeric_rationales(decision_text: str, required: str) -> None: + assert required in decision_text + + +def test_decision_pins_ceiling_backstop_pair(decision_text: str) -> None: + assert "rust-v0.144.1" in decision_text + assert "44918ea10c0f99151c6710411b4322c2f5c96bea" in decision_text + assert "codex-rs/utils/string/src/truncate.rs" in decision_text + assert "one-token-per-four-UTF-8-bytes" in decision_text + assert "response_max_bytes // 3 < CODEX_TOOL_OUTPUT_TOKEN_LIMIT" in decision_text + assert "open_kitchen" in decision_text + assert "load_recipe" in decision_text + assert "live large-output probe" in decision_text + + +def test_decision_records_both_corrections(decision_text: str) -> None: + correction = decision_text.split("## Corrections of Record", maxsplit=1)[1].split( + "## Accepted Gaps", maxsplit=1 + )[0] + for required in ["6b421e38e", "native shell", "unified_exec", "PR #4259", "develop", "main"]: + assert required in correction + + +def test_decision_names_all_eight_accepted_gaps(decision_text: str) -> None: + gaps = decision_text.split("## Accepted Gaps", maxsplit=1)[1].split( + "## Operational Signals", maxsplit=1 + )[0] + numbered = re.findall(r"^\d+\. ", gaps, flags=re.MULTILINE) + assert len(numbered) == 8 + for required in [ + "Non-JSONL single-file searches", + "Codex `resume`", + "artifact persistence is unavailable", + "Prompt-side caps", + "`merge_worktree` drops passing raw test output", + "pruned collection", + "cumulative context", + "worker memory", + ]: + assert required in gaps + + +def test_decision_limits_operational_signal_claims(decision_text: str) -> None: + signals = decision_text.split("## Operational Signals", maxsplit=1)[1].split( + "## Forward Obligations", maxsplit=1 + )[0] + for required in [ + "spill count", + "original and artifact UTF-8 byte totals", + "measured exemption use", + "spill failures grouped by bounded cause code", + "must never contain artifact paths, hashes, or output content", + "artifact quota", + "artifact cleanup", + "cumulative reserve accounting", + ]: + assert required in signals + + +def test_decision_ratchets_forward_obligations(decision_text: str) -> None: + obligations = decision_text.split("## Forward Obligations", maxsplit=1)[1].split( + "## Consequences", maxsplit=1 + )[0] + for required in [ + "after CLI upgrades", + "ceiling relaxation", + "command-guard rule removal", + "response-backstop exemption", + "output-discipline policy-version change", + "invalidates the applicable cached", + "live large-output probe", + ]: + assert required in obligations diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index def86cddde..7ed4b959f8 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -140,6 +140,7 @@ Subprocess integration, headless session, process lifecycle, and session result | File | Purpose | |------|---------| +| `_conformance_assertions.py` | Pytest-free live/deterministic conformance assertions, including output-boundary, sentinel, and spill-integrity checks | | `test_claude_backend.py` | Equivalence tests for retained shims; backend method tests, config adapter, resume preservation | | `test_claude_code_backend.py` | Structural tests for ClaudeCodeBackend | | `test_claude_env_policy.py` | Tests for ClaudeEnvPolicy | diff --git a/tests/execution/backends/_conformance_assertions.py b/tests/execution/backends/_conformance_assertions.py index 1f2ee84745..bece5ba25f 100644 --- a/tests/execution/backends/_conformance_assertions.py +++ b/tests/execution/backends/_conformance_assertions.py @@ -6,6 +6,9 @@ from __future__ import annotations +import hashlib +from pathlib import Path + from autoskillit.core.types._type_enums import CodexEventType from autoskillit.execution.process._process_jsonl import _marker_is_standalone @@ -96,3 +99,67 @@ def assert_config_schema(config_dict: dict, version_str: str) -> None: assert not missing, ( f"Config (version {version_str}) missing expected top-level keys: {sorted(missing)}" ) + + +def assert_boundary_spill_behavior(spilled_by_size: dict[int, bool], threshold: int) -> None: + """Assert the lossless-spill contract immediately around a source threshold.""" + expected = {threshold - 1: False, threshold: False, threshold + 1: True} + observed = {size: spilled_by_size.get(size) for size in expected} + assert observed == expected, ( + f"spill boundary mismatch at {threshold}: expected {expected}, observed {observed}" + ) + + +def assert_sentinels_present(text: str, sentinels: tuple[str, ...]) -> None: + """Assert distinct workload sentinels survived a delivery or artifact path.""" + missing = [sentinel for sentinel in sentinels if sentinel not in text] + assert not missing, f"missing sentinels: {missing}" + + +def assert_spill_artifact_integrity( + artifact_path: str, + expected_text: str, + sentinels: tuple[str, ...], +) -> None: + """Assert an atomically published spill is byte-complete and content-addressable.""" + path = Path(artifact_path) + assert path.is_file(), f"spill artifact does not exist: {path}" + artifact_bytes = path.read_bytes() + expected_bytes = expected_text.encode("utf-8") + assert artifact_bytes == expected_bytes, ( + f"spill artifact differs from source: {len(artifact_bytes)} != {len(expected_bytes)} bytes" + ) + expected_sha256 = hashlib.sha256(expected_bytes).hexdigest() + actual_sha256 = hashlib.sha256(artifact_bytes).hexdigest() + assert actual_sha256 == expected_sha256, ( + f"spill sha256 mismatch: {actual_sha256} != {expected_sha256}" + ) + assert_sentinels_present(artifact_bytes.decode("utf-8"), sentinels) + + +def assert_inline_within_byte_budget( + inline_text: str, + byte_budget: int, + *, + envelope_slack_bytes: int = 0, +) -> None: + """Assert inline output stays within a transport ceiling plus explicit envelope slack.""" + inline_bytes = len(inline_text.encode("utf-8")) + effective_budget = byte_budget + envelope_slack_bytes + assert inline_bytes <= effective_budget, ( + f"inline output is {inline_bytes} bytes, over {byte_budget} + " + f"{envelope_slack_bytes} envelope bytes" + ) + + +def assert_terminal_sentinel_preserved( + delivered_text: str, + terminal_sentinel: str, + truncation_markers: tuple[str, ...], +) -> None: + """Assert a terminal sentinel arrived and no known transport truncation marker did.""" + assert terminal_sentinel in delivered_text, ( + f"terminal sentinel missing from delivered text: {terminal_sentinel!r}" + ) + observed_markers = [marker for marker in truncation_markers if marker in delivered_text] + assert not observed_markers, f"transport truncation markers present: {observed_markers}" diff --git a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json index 2607caccbf..cfde161093 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json +++ b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json @@ -26,9 +26,9 @@ "floor_value": 999999999 }, "tool_output_token_limit": { - "constraint": "minimum", + "constraint": "exact", "expected_type": "int", - "floor_value": 50000 + "expected_value": 32000 } } } diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index 34d34682f7..c034bf212b 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -24,6 +24,12 @@ CanaryState, ErrorKind, ) +from autoskillit.config import OutputBudgetConfig +from autoskillit.core import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, pkg_root +from autoskillit.execution.backends._codex_config import ( + CODEX_TOOL_OUTPUT_TOKEN_LIMIT, + ensure_codex_mcp_registered, +) from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config from autoskillit.execution.backends._probe_cache import ( PROBE_POLICY_IDENTITY, @@ -33,10 +39,15 @@ ) from autoskillit.hook_registry import generate_hooks_json from tests.execution.backends._conformance_assertions import ( + assert_boundary_spill_behavior, assert_config_schema, assert_hook_event_format, + assert_inline_within_byte_budget, assert_no_unknown_event_types, + assert_sentinels_present, assert_session_start_present, + assert_spill_artifact_integrity, + assert_terminal_sentinel_preserved, assert_turn_completed_usage_nonzero, assert_vocabulary_coverage, ) @@ -533,6 +544,277 @@ def test_hook_fires_and_reason_reaches_model(self, tmp_path: Path) -> None: _exercise_output_budget_deny_probe("claude-code", tmp_path) +_SOURCE_SPILL_THRESHOLD = OutputBudgetConfig().inline_max_chars +_CODEX_HEURISTIC_BYTES = CODEX_TOOL_OUTPUT_TOKEN_LIMIT * 4 +_SERIALIZED_ENVELOPE_SLACK_BYTES = 4096 +_LARGE_OUTPUT_CASE_BYTES = tuple( + sorted( + { + _SOURCE_SPILL_THRESHOLD - 1, + _SOURCE_SPILL_THRESHOLD, + _SOURCE_SPILL_THRESHOLD + 1, + _CODEX_HEURISTIC_BYTES - 1, + _CODEX_HEURISTIC_BYTES, + _CODEX_HEURISTIC_BYTES + 1, + 500_000, + } + ) +) +_OPEN_KITCHEN_TERMINAL_SENTINEL = "success=false: escalate_stop_no_ci, escalate_stop" +_TRANSPORT_TRUNCATION_MARKERS = ( + "[tool output truncated]", + "[output truncated by transport]", +) + + +class _LargeOutputProbe(NamedTuple): + transcript: str + cli_version: str + expected_payloads: dict[int, str] + + +def _large_payload(size: int) -> tuple[str, tuple[str, str, str]]: + sentinels = ( + f"HEAD-SENTINEL::{size}", + f"MIDDLE-SENTINEL::{size}", + f"TAIL-SENTINEL::{size}", + ) + fixed = sum(len(value) for value in sentinels) + 2 + assert fixed <= size + filler = size - fixed + before_middle = filler // 2 + after_middle = filler - before_middle + payload = ( + sentinels[0] + + "\n" + + ("a" * before_middle) + + sentinels[1] + + ("z" * after_middle) + + "\n" + + sentinels[2] + ) + assert len(payload.encode("utf-8")) == size + return payload, sentinels + + +def _walk_json_values(value: object): + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _walk_json_values(child) + elif isinstance(value, list): + for child in value: + yield from _walk_json_values(child) + elif isinstance(value, str): + try: + nested = json.loads(value) + except (json.JSONDecodeError, TypeError): + return + yield from _walk_json_values(nested) + + +def _run_cmd_payloads(transcript: str) -> list[dict]: + payloads: list[dict] = [] + seen: set[tuple[str, str]] = set() + for line in transcript.splitlines(): + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + for candidate in _walk_json_values(event): + if not {"success", "exit_code", "stdout", "stderr"}.issubset(candidate): + continue + key = ( + str(candidate.get("stdout", "")), + str(candidate.get("stdout_artifact_path", "")), + ) + if key not in seen: + seen.add(key) + payloads.append(candidate) + return payloads + + +def _large_output_prompt(workspace: Path) -> str: + calls = "\n".join( + f'- run_cmd cmd="LC_ALL=C head -c {size} ' + f'.autoskillit/temp/probe-fixtures/case-{size}.txt" cwd="{workspace}"' + for size in _LARGE_OUTPUT_CASE_BYTES + ) + return ( + "This is an output-budget conformance probe. First call the autoskillit " + "open_kitchen tool with name=remediation and overrides " + '{"task":"test task","issue_url":"https://github.com/test/test/issues/1",' + '"is_fleet_dispatch":"true","adversarial_review_level":"true",' + '"local_review_rounds":"true","base_branch":"true",' + '"post_run_diagnostics":"true"}. Then make every autoskillit run_cmd call below, ' + "in order. Do not substitute native shell tools and do not omit a call.\n" + f"{calls}\nAfter all calls, respond with exactly: probe-complete" + ) + + +def _run_large_output_probe(backend: str, tmp_path: Path) -> _LargeOutputProbe: + workspace = tmp_path / "workspace" + fixture_dir = workspace / ".autoskillit" / "temp" / "probe-fixtures" + fixture_dir.mkdir(parents=True) + expected_payloads: dict[int, str] = {} + for size in _LARGE_OUTPUT_CASE_BYTES: + payload, _ = _large_payload(size) + expected_payloads[size] = payload + (fixture_dir / f"case-{size}.txt").write_text(payload, encoding="utf-8") + + env, codex_home, claude_config = _isolated_cli_env(tmp_path / "isolated", workspace) + venv_bin = Path(__file__).resolve().parents[3] / ".venv" / "bin" + env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}" + env["AUTOSKILLIT_FEATURES__EXPERIMENTAL_ENABLED"] = "true" + prompt = _large_output_prompt(workspace) + + if backend == "codex": + config_path = codex_home / "config.toml" + ensure_codex_mcp_registered(config_path=config_path, headless_auto_gate=False) + sync_hooks_to_codex_config(config_path=config_path) + subprocess.run( + ["git", "init", "-q"], + cwd=workspace, + env=env, + check=True, + capture_output=True, + text=True, + timeout=10, + ) + command = [ + "codex", + "exec", + "--json", + "--sandbox", + "workspace-write", + prompt, + ] + elif backend == "claude-code": + (claude_config / "settings.json").write_text( + json.dumps(generate_hooks_json(), indent=2) + "\n", + encoding="utf-8", + ) + command = [ + "claude", + "-p", + prompt, + "--output-format", + "stream-json", + "--verbose", + "--dangerously-skip-permissions", + "--plugin-dir", + str(pkg_root()), + ] + else: # pragma: no cover - parametrization is sealed below + raise ValueError(f"unsupported probe backend: {backend}") + + timeout = int(os.environ.get("OUTPUT_BUDGET_LARGE_SMOKE_TIMEOUT", "900")) + result = subprocess.run( # noqa: S603 + command, + cwd=workspace, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + transcript = result.stdout + "\n" + result.stderr + if result.returncode != 0: + raise OSError(f"{backend} large-output probe rc={result.returncode}: {transcript}") + return _LargeOutputProbe( + transcript=transcript, + cli_version=_cli_version(command[0], env), + expected_payloads=expected_payloads, + ) + + +def _assert_large_output_probe(output: _LargeOutputProbe) -> None: + payloads = _run_cmd_payloads(output.transcript) + observed_by_size: dict[int, dict] = {} + for size, expected in output.expected_payloads.items(): + head = f"HEAD-SENTINEL::{size}" + matches = [entry for entry in payloads if head in str(entry.get("stdout", ""))] + assert len(matches) == 1, f"expected one run_cmd result for {size}, got {len(matches)}" + observed_by_size[size] = matches[0] + + spill_by_size: dict[int, bool] = {} + for size, entry in observed_by_size.items(): + expected = output.expected_payloads[size] + _, sentinels = _large_payload(size) + inline = str(entry["stdout"]) + artifact_path = str(entry.get("stdout_artifact_path", "")) + spilled = bool(artifact_path) + spill_by_size[size] = spilled + assert_inline_within_byte_budget( + json.dumps(entry), + CODEX_TOOL_OUTPUT_TOKEN_LIMIT * 4, + envelope_slack_bytes=_SERIALIZED_ENVELOPE_SLACK_BYTES, + ) + if spilled: + assert "[spilled " in inline + assert_sentinels_present(inline, (sentinels[0], sentinels[2])) + assert_spill_artifact_integrity(artifact_path, expected, sentinels) + else: + assert inline == expected + assert_sentinels_present(inline, sentinels) + + assert_boundary_spill_behavior(spill_by_size, _SOURCE_SPILL_THRESHOLD) + assert_terminal_sentinel_preserved( + output.transcript, + _OPEN_KITCHEN_TERMINAL_SENTINEL, + _TRANSPORT_TRUNCATION_MARKERS, + ) + assert len(_OPEN_KITCHEN_TERMINAL_SENTINEL.encode("utf-8")) < ( + OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + ) + + +def _exercise_large_output_probe(backend: str, tmp_path: Path) -> None: + version_workspace = tmp_path / "version-workspace" + version_workspace.mkdir() + version_env, _, _ = _isolated_cli_env(tmp_path / "version-env", version_workspace) + binary = "codex" if backend == "codex" else "claude" + cli_version = _cli_version(binary, version_env) + cache_path = tmp_path / f"{backend}-large-output-probe-cache.json" + cached = read_probe_cache(cache_path, cli_version, PROBE_POLICY_IDENTITY) + if cached is not None and cached.passed: + pytest.skip(f"Large-output probe cached as passed for {cli_version}") + + def _record(passed: bool, version: str, detail: str | None) -> None: + write_probe_cache( + cache_path, + ProbeResult( + cli_version=version, + policy_identity=PROBE_POLICY_IDENTITY, + passed=passed, + failure_detail=detail, + probe_timestamp=datetime.now(UTC).isoformat(), + ), + ) + + _run_probe_with_discrimination( + f"large_output_boundaries_{backend}", + cli_version, + lambda: _run_large_output_probe(backend, tmp_path / "round-trip"), + _assert_large_output_probe, + record_success=lambda version: _record(True, version, None), + record_failure=lambda kind, name, version, detail: _record( + False, version, f"{kind.value}:{name}:{detail}" + ), + ) + + +@_skip_unless_codex_output_budget_smoke +class TestCodexLargeOutputAndOpenKitchenRoundTrip: + def test_boundaries_spill_integrity_and_terminal_sentinel(self, tmp_path: Path) -> None: + _exercise_large_output_probe("codex", tmp_path) + + +@_skip_unless_claude_output_budget_smoke +class TestClaudeCodeLargeOutputAndOpenKitchenRoundTrip: + def test_boundaries_spill_integrity_and_terminal_sentinel(self, tmp_path: Path) -> None: + _exercise_large_output_probe("claude-code", tmp_path) + + @_skip_unless_claude_code_smoke class TestClaudeCodeOutputSchemaProbe: """Retirement signal for ClaudeCodeCompatMiddleware (_wire_compat.py). diff --git a/tests/execution/backends/test_codex_config.py b/tests/execution/backends/test_codex_config.py index 0f7a0328f4..3a4fef03ca 100644 --- a/tests/execution/backends/test_codex_config.py +++ b/tests/execution/backends/test_codex_config.py @@ -5,7 +5,12 @@ import pytest -from autoskillit.core import CODEX_MCP_ENV_FORWARD_VARS, HEADLESS_AUTO_GATE_ENV_VAR +from autoskillit.config import OutputBudgetConfig +from autoskillit.core import ( + CODEX_MCP_ENV_FORWARD_VARS, + HEADLESS_AUTO_GATE_ENV_VAR, + OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, +) from autoskillit.execution.backends import ensure_codex_mcp_registered from autoskillit.execution.backends._codex_config import ( CODEX_AUTO_COMPACT_LIMIT, @@ -22,6 +27,21 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] +def test_codex_tool_output_limit_is_derived_from_open_kitchen_budget() -> None: + """The four-byte relation is Codex's coarse truncation heuristic, not tokenization. + + This ceiling is a blast-radius damage bound; producer guards and output + spilling remain the load-bearing protections. + """ + assert OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == 96_000 + assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == ((OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + 3) // 4) + 8_000 + assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == 32_000 + + +def test_response_backstop_fires_below_codex_transport_ceiling() -> None: + assert OutputBudgetConfig().response_max_bytes // 3 < CODEX_TOOL_OUTPUT_TOKEN_LIMIT + + class TestReadCodexConfig: def test_returns_empty_dict_when_path_missing(self, tmp_path): result = _read_codex_config(tmp_path / "nonexistent.toml") @@ -337,6 +357,22 @@ def test_low_tool_output_token_limit_returns_false(self): } assert _is_autoskillit_registered(config, headless_auto_gate=True) is False + def test_high_tool_output_token_limit_returns_false(self): + """The tool-output setting is exact, not a minimum.""" + config = { + "mcp_servers": { + "autoskillit": { + "command": "autoskillit", + "env_vars": sorted(CODEX_MCP_ENV_FORWARD_VARS), + "startup_timeout_sec": CODEX_MCP_STARTUP_TIMEOUT_SEC, + "tool_timeout_sec": CODEX_MCP_TOOL_TIMEOUT_FLOOR, + } + }, + "tool_output_token_limit": CODEX_TOOL_OUTPUT_TOKEN_LIMIT + 1, + "model_auto_compact_token_limit": CODEX_AUTO_COMPACT_LIMIT, + } + assert _is_autoskillit_registered(config, headless_auto_gate=True) is False + def test_missing_auto_compact_limit_returns_false(self): """Must return False when model_auto_compact_token_limit is missing.""" config = { @@ -403,7 +439,7 @@ def test_tool_output_token_limit_written_to_top_level(self, tmp_path): ensure_codex_mcp_registered(config_path=p) config = _read_codex_config(p).data assert "tool_output_token_limit" in config - assert config["tool_output_token_limit"] >= 50_000 + assert config["tool_output_token_limit"] == CODEX_TOOL_OUTPUT_TOKEN_LIMIT assert "tool_output_token_limit" not in config["mcp_servers"]["autoskillit"], ( "tool_output_token_limit is a Codex global key, not a server-entry key" ) @@ -429,6 +465,20 @@ def test_stale_auto_compact_limit_triggers_rewrite(self, tmp_path): config = _read_codex_config(p).data assert config["model_auto_compact_token_limit"] == CODEX_AUTO_COMPACT_LIMIT + def test_parseable_config_rewrites_tool_limit_without_lowering_compact_minimum(self, tmp_path): + p = tmp_path / "config.toml" + ensure_codex_mcp_registered(config_path=p) + higher_compact_limit = CODEX_AUTO_COMPACT_LIMIT + 1 + text = p.read_text(encoding="utf-8") + text = text.replace(str(CODEX_TOOL_OUTPUT_TOKEN_LIMIT), "50000") + text = text.replace(str(CODEX_AUTO_COMPACT_LIMIT), str(higher_compact_limit)) + p.write_text(text, encoding="utf-8") + + assert ensure_codex_mcp_registered(config_path=p) is True + config = _read_codex_config(p).data + assert config["tool_output_token_limit"] == CODEX_TOOL_OUTPUT_TOKEN_LIMIT + assert config["model_auto_compact_token_limit"] == higher_compact_limit + def test_headless_auto_gate_true_includes_auto_gate_env(self, tmp_path): p = tmp_path / "config.toml" ensure_codex_mcp_registered(config_path=p, headless_auto_gate=True) @@ -594,6 +644,20 @@ def test_corrupt_file_path_writes_tool_output_token_limit(self, tmp_path): assert f"tool_output_token_limit = {CODEX_TOOL_OUTPUT_TOKEN_LIMIT}" in content assert "[mcp_servers.autoskillit]" in content + def test_corrupt_file_rewrites_existing_tool_output_limit_exactly(self, tmp_path): + p = tmp_path / "config.toml" + p.write_text( + "tool_output_token_limit = 50_000\nnot valid toml = =\n", + encoding="utf-8", + ) + + ensure_codex_mcp_registered(config_path=p) + + content = p.read_text(encoding="utf-8") + assert f"tool_output_token_limit = {CODEX_TOOL_OUTPUT_TOKEN_LIMIT}" in content + assert "tool_output_token_limit = 50_000" not in content + assert content.count("tool_output_token_limit =") == 1 + def test_corrupt_file_writes_auto_compact_limit(self, tmp_path): """The corrupt-file path must also insert the auto-compact limit at the top.""" p = tmp_path / "config.toml" @@ -602,6 +666,28 @@ def test_corrupt_file_writes_auto_compact_limit(self, tmp_path): content = p.read_text(encoding="utf-8") assert f"model_auto_compact_token_limit = {CODEX_AUTO_COMPACT_LIMIT}" in content + @pytest.mark.parametrize( + ("existing", "expected"), + [ + (100_000, CODEX_AUTO_COMPACT_LIMIT), + (CODEX_AUTO_COMPACT_LIMIT + 1, CODEX_AUTO_COMPACT_LIMIT + 1), + ], + ) + def test_corrupt_file_preserves_auto_compact_minimum_semantics( + self, tmp_path, existing, expected + ): + p = tmp_path / "config.toml" + p.write_text( + f"model_auto_compact_token_limit = {existing}\nnot valid toml = =\n", + encoding="utf-8", + ) + + ensure_codex_mcp_registered(config_path=p) + + content = p.read_text(encoding="utf-8") + assert f"model_auto_compact_token_limit = {expected}" in content + assert content.count("model_auto_compact_token_limit =") == 1 + def test_preserves_unknown_top_level_scalars(self, tmp_path): """Unknown top-level scalar keys (model/theme/disable_telemetry) must survive a fresh MCP registration round-trip through the read → mutate → write pipeline.""" diff --git a/tests/execution/backends/test_codex_deterministic_conformance.py b/tests/execution/backends/test_codex_deterministic_conformance.py index aeb275c1e3..ba341f6fef 100644 --- a/tests/execution/backends/test_codex_deterministic_conformance.py +++ b/tests/execution/backends/test_codex_deterministic_conformance.py @@ -26,11 +26,16 @@ from autoskillit.execution.backends._codex_hooks import generate_codex_hooks_config from autoskillit.hook_registry import HOOK_REGISTRY_HASH, HOOKS_DIR from tests.execution.backends._conformance_assertions import ( + assert_boundary_spill_behavior, assert_config_schema, assert_hook_event_format, + assert_inline_within_byte_budget, assert_no_unknown_event_types, assert_order_up_marker_standalone, + assert_sentinels_present, assert_session_start_present, + assert_spill_artifact_integrity, + assert_terminal_sentinel_preserved, assert_turn_completed_usage_nonzero, assert_vocabulary_coverage, ) @@ -80,8 +85,8 @@ def _generate_config_template() -> dict: "top_level_keys": { "tool_output_token_limit": { "expected_type": "int", - "constraint": "minimum", - "floor_value": CODEX_TOOL_OUTPUT_TOKEN_LIMIT, + "constraint": "exact", + "expected_value": CODEX_TOOL_OUTPUT_TOKEN_LIMIT, }, "model_auto_compact_token_limit": { "expected_type": "int", @@ -204,6 +209,19 @@ def test_update_fixtures_writes_snapshot( class TestCodexConfigTomlSchemaTemplate: _TEMPLATE_PATH = FIXTURES_DIR / "config_toml_schema_template.json" + def test_generator_preserves_distinct_top_level_constraint_semantics(self) -> None: + top = _generate_config_template()["top_level_keys"] + assert top["tool_output_token_limit"] == { + "constraint": "exact", + "expected_type": "int", + "expected_value": CODEX_TOOL_OUTPUT_TOKEN_LIMIT, + } + assert top["model_auto_compact_token_limit"] == { + "constraint": "minimum", + "expected_type": "int", + "floor_value": CODEX_AUTO_COMPACT_LIMIT, + } + def test_required_keys_present(self) -> None: template = json.loads(self._TEMPLATE_PATH.read_text(encoding="utf-8")) fixture_keys = set(template["_codex_mcp_required_keys"]) @@ -217,11 +235,11 @@ def test_required_keys_present(self) -> None: def test_pinned_constants_match(self) -> None: template = json.loads(self._TEMPLATE_PATH.read_text(encoding="utf-8")) top = template["top_level_keys"] - assert top["tool_output_token_limit"]["floor_value"] == CODEX_TOOL_OUTPUT_TOKEN_LIMIT, ( - f"CODEX_TOOL_OUTPUT_TOKEN_LIMIT drift: " - f"fixture={top['tool_output_token_limit']['floor_value']} " - f"vs live={CODEX_TOOL_OUTPUT_TOKEN_LIMIT}" - ) + assert top["tool_output_token_limit"] == { + "constraint": "exact", + "expected_type": "int", + "expected_value": CODEX_TOOL_OUTPUT_TOKEN_LIMIT, + } assert top["model_auto_compact_token_limit"]["floor_value"] == CODEX_AUTO_COMPACT_LIMIT, ( f"CODEX_AUTO_COMPACT_LIMIT drift: " f"fixture={top['model_auto_compact_token_limit']['floor_value']} " @@ -249,7 +267,7 @@ def test_update_fixtures_regenerates_template( reloaded = json.loads(self._TEMPLATE_PATH.read_text(encoding="utf-8")) assert set(reloaded["_codex_mcp_required_keys"]) == CODEX_MCP_REQUIRED_KEYS assert ( - reloaded["top_level_keys"]["tool_output_token_limit"]["floor_value"] + reloaded["top_level_keys"]["tool_output_token_limit"]["expected_value"] == CODEX_TOOL_OUTPUT_TOKEN_LIMIT ) assert ( @@ -287,6 +305,21 @@ def test_order_up_marker_standalone(self) -> None: def test_config_schema_valid(self) -> None: assert_config_schema({"model": "o4-mini", "instructions": "test"}, "synthetic") + def test_output_budget_assertions(self, tmp_path: Path) -> None: + assert_boundary_spill_behavior({4999: False, 5000: False, 5001: True}, 5000) + sentinels = ("HEAD", "MIDDLE", "TAIL") + payload = "HEAD\nMIDDLE\nTAIL" + assert_sentinels_present(payload, sentinels) + artifact = tmp_path / "spill.txt" + artifact.write_text(payload, encoding="utf-8") + assert_spill_artifact_integrity(str(artifact), payload, sentinels) + assert_inline_within_byte_budget("bounded", 7, envelope_slack_bytes=0) + assert_terminal_sentinel_preserved( + "complete\nTERMINAL", + "TERMINAL", + ("[truncated]", "... output omitted ..."), + ) + class TestConformanceAssertionsFullCoverage: """Meta-test: every assert_* name from _conformance_assertions is called in this file.""" diff --git a/tests/infra/test_conformance_probes_workflow.py b/tests/infra/test_conformance_probes_workflow.py index 011e1e3634..69a1fe47de 100644 --- a/tests/infra/test_conformance_probes_workflow.py +++ b/tests/infra/test_conformance_probes_workflow.py @@ -75,6 +75,12 @@ def test_codex_probe_smoke_env(self, workflow: dict) -> None: def test_claude_probe_smoke_env(self, workflow: dict) -> None: assert workflow["jobs"]["claude-probe"]["env"]["CLAUDE_CODE_SMOKE_TEST"] == "1" + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) + def test_live_probe_timeout_covers_default_dispatch( + self, workflow: dict, job_name: str + ) -> None: + assert workflow["jobs"][job_name]["timeout-minutes"] == 75 + class TestActionPinning: _SHA_RE = re.compile(r"@[0-9a-f]{40}\b") @@ -184,6 +190,23 @@ def test_restore_and_save_keys_include_policy_identity( assert "steps.resolve-policy.outputs.policy_identity" in key +class TestOutputBudgetE2E: + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) + def test_job_runs_real_server_harness(self, workflow: dict, job_name: str) -> None: + run_steps = [step.get("run", "") for step in workflow["jobs"][job_name]["steps"]] + assert any("tests/server/test_output_budget_e2e.py" in run for run in run_steps) + + def test_claude_job_exports_isolated_credential(self, workflow: dict) -> None: + steps = workflow["jobs"]["claude-probe"]["steps"] + probe_step = next( + step for step in steps if step.get("name") == "Run Claude Code conformance probes" + ) + env = probe_step["env"] + assert "ANTHROPIC_API_KEY" in env + assert "CLAUDE_CODE_OAUTH_TOKEN" in env + assert "No isolated Claude credential" in probe_step["run"] + + class TestPostFailure: @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) def test_post_failure_step_exists(self, workflow: dict, job_name: str) -> None: diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index c915a86caa..489c396ed4 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -7,7 +7,11 @@ import pytest -from autoskillit.hooks.formatters.pretty_output_hook import _format_response +from autoskillit.core import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES +from autoskillit.hooks.formatters.pretty_output_hook import ( + _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, + _format_response, +) from tests.infra._pretty_output_helpers import ( REALISTIC_RECIPE_YAML, _make_event, @@ -17,6 +21,12 @@ pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] +def test_open_kitchen_budget_dual_copy_matches_core_authority() -> None: + """The stdlib-only formatter copy must stay equal to the public authority.""" + assert OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == 96_000 + assert _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + + # PHK-15 def test_format_get_token_summary_compact(): """get_token_summary must show compact per-step lines and totals.""" @@ -972,11 +982,11 @@ def test_open_kitchen_payload_warning_uses_formatted_byte_count(capsys): formatted = _fmt_open_kitchen(data, pipeline=False) captured = capsys.readouterr() byte_len = len(formatted.encode("utf-8")) - assert byte_len > 95_000 + assert byte_len > OPEN_KITCHEN_OUTPUT_BUDGET_BYTES assert "open_kitchen" in captured.err assert "content_hash=deadbeef" in captured.err assert str(byte_len) in captured.err - assert "95000" in captured.err + assert str(OPEN_KITCHEN_OUTPUT_BUDGET_BYTES) in captured.err def test_rendered_open_kitchen_payload_under_budget(tmp_path, monkeypatch): @@ -995,6 +1005,7 @@ def test_rendered_open_kitchen_payload_under_budget(tmp_path, monkeypatch): project_root = Path(__file__).resolve().parent.parent.parent over_budget: list[str] = [] + maximum: tuple[int, str, str] = (0, "", "") for recipe_name in all_validated_recipe_names(project_root): for mode_name, overrides in _COMPACT_TEST_OVERRIDES.items(): @@ -1007,7 +1018,15 @@ def test_rendered_open_kitchen_payload_under_budget(tmp_path, monkeypatch): ) rendered = _fmt_open_kitchen(result, pipeline=False) byte_len = len(rendered.encode("utf-8")) - if byte_len > 100_000: - over_budget.append(f"{recipe_name} ({mode_name}): {byte_len} bytes > 100000") - - assert not over_budget, "\n".join(over_budget) + maximum = max(maximum, (byte_len, recipe_name, mode_name)) + if byte_len > OPEN_KITCHEN_OUTPUT_BUDGET_BYTES: + over_budget.append( + f"{recipe_name} ({mode_name}): {byte_len} bytes > " + f"{OPEN_KITCHEN_OUTPUT_BUDGET_BYTES}" + ) + + max_bytes, max_recipe, max_mode = maximum + assert not over_budget, ( + f"maximum rendered payload: {max_recipe} ({max_mode}) = {max_bytes} bytes\n" + + "\n".join(over_budget) + ) diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 46bf34c7c2..2ed737a95a 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -123,6 +123,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_no_path_cwd_in_tools.py` | Regression guard: Path.cwd() must not appear in server tool handlers | | `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 against controlled large-output fixtures for Codex and Claude Code | | `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/test_output_budget_e2e.py b/tests/server/test_output_budget_e2e.py new file mode 100644 index 0000000000..888e7e637a --- /dev/null +++ b/tests/server/test_output_budget_e2e.py @@ -0,0 +1,202 @@ +"""Env-gated end-to-end output-budget investigation probes. + +Unlike the one-prompt CLI conformance probe, this harness constructs the real +server composition root, opens the kitchen, and dispatches ``/investigate`` +through ``run_skill``. It requires isolated environment-provided credentials +and never reads the user's CLI configuration. +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path +from unittest.mock import AsyncMock + +import pytest + +from autoskillit.config import ( + AgentBackendConfig, + AutomationConfig, + QuotaGuardConfig, +) +from autoskillit.core import DirectInstall, pkg_root +from autoskillit.execution.backends._codex_config import ensure_codex_mcp_registered +from autoskillit.execution.backends._codex_hooks import sync_hooks_to_codex_config +from autoskillit.execution.process import DefaultSubprocessRunner +from autoskillit.hook_registry import generate_hooks_json +from autoskillit.server._factory import make_context +from autoskillit.server.tools.tools_execution import run_skill +from autoskillit.server.tools.tools_kitchen import close_kitchen, open_kitchen + +pytestmark = [pytest.mark.layer("server"), pytest.mark.large, pytest.mark.smoke] + +_INVESTIGATION_PATH_RE = re.compile(r"investigation_path\s*=\s*(/\S+)") +_REPORT_REQUIRED_SECTIONS = ( + "## Summary", + "## Affected Components", + "## Data Flow", + "## Test Gap Analysis", + "## Scope Boundary", + "## Recommendations", +) + + +def _has_codex_credentials() -> bool: + return bool(os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY")) + + +def _has_claude_credentials() -> bool: + return bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")) + + +_BACKEND_CASES = [ + pytest.param( + "codex", + marks=pytest.mark.skipif( + not os.environ.get("CODEX_SMOKE_TEST") + or not shutil.which("codex") + or not _has_codex_credentials(), + reason="Codex E2E requires CODEX_SMOKE_TEST and an environment API key", + ), + ), + pytest.param( + "claude-code", + marks=pytest.mark.skipif( + not os.environ.get("CLAUDE_CODE_SMOKE_TEST") + or not shutil.which("claude") + or not _has_claude_credentials(), + reason="Claude E2E requires CLAUDE_CODE_SMOKE_TEST and an environment credential", + ), + ), +] + + +def _fixture_payload(size: int = 520_000) -> str: + head = "HEAD-SENTINEL::deep-investigate\n" + middle = "\nMIDDLE-SENTINEL::deep-investigate\n" + tail = "\nTAIL-SENTINEL::deep-investigate" + filler = size - len(head) - len(middle) - len(tail) + before_middle = filler // 2 + payload = head + ("a" * before_middle) + middle + ("z" * (filler - before_middle)) + tail + assert len(payload.encode("utf-8")) == size + return payload + + +def _init_fixture_repo(path: Path) -> Path: + path.mkdir() + payload_path = path / "large-output-fixture.txt" + payload_path.write_text(_fixture_payload(), encoding="utf-8") + (path / "README.md").write_text( + "# Output budget fixture\n\nInvestigate lossless handling of the large fixture.\n", + encoding="utf-8", + ) + subprocess.run(["git", "init", "-q"], cwd=path, check=True, timeout=10) + subprocess.run(["git", "config", "user.email", "probe@example.invalid"], cwd=path, check=True) + subprocess.run(["git", "config", "user.name", "Output Budget Probe"], cwd=path, check=True) + subprocess.run(["git", "add", "."], cwd=path, check=True) + subprocess.run(["git", "commit", "-qm", "fixture"], cwd=path, check=True, timeout=10) + return payload_path + + +def _configure_isolated_cli( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + home = tmp_path / "home" + codex_home = home / ".codex" + claude_config = tmp_path / "claude-config" + for directory in (home, codex_home, claude_config): + directory.mkdir(parents=True) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) + monkeypatch.setenv("XDG_DATA_HOME", str(home / ".local" / "share")) + venv_bin = Path(__file__).resolve().parents[2] / ".venv" / "bin" + monkeypatch.setenv("PATH", f"{venv_bin}{os.pathsep}{os.environ.get('PATH', '')}") + monkeypatch.setenv("AUTOSKILLIT_FEATURES__EXPERIMENTAL_ENABLED", "true") + + if backend == "codex": + config_path = codex_home / "config.toml" + ensure_codex_mcp_registered(config_path=config_path) + sync_hooks_to_codex_config(config_path=config_path) + else: + (claude_config / "settings.json").write_text( + json.dumps(generate_hooks_json(), indent=2) + "\n", + encoding="utf-8", + ) + + +def _assert_investigation_result(raw_result: str, fixture_path: Path) -> None: + result = json.loads(raw_result) + assert result["success"] is True, result + match = _INVESTIGATION_PATH_RE.search(str(result.get("result", ""))) + assert match is not None, f"missing investigation_path token: {result}" + report_path = Path(match.group(1)) + assert report_path.is_file(), f"investigation report not found: {report_path}" + report = report_path.read_text(encoding="utf-8") + for section in _REPORT_REQUIRED_SECTIONS: + assert section in report, f"report missing coherent synthesis section {section!r}" + assert str(fixture_path) in report or fixture_path.name in report + assert "Deep Analysis" in report + + +@pytest.mark.anyio +@pytest.mark.parametrize("backend", _BACKEND_CASES) +async def test_deep_investigate_completes_with_bounded_large_evidence( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Real kitchen + run_skill dispatch completes and emits a validated report path.""" + fixture_repo = tmp_path / "fixture-repo" + fixture_path = _init_fixture_repo(fixture_repo) + _configure_isolated_cli(backend, tmp_path, monkeypatch) + + config = AutomationConfig( + agent_backend=AgentBackendConfig(backend=backend), + quota_guard=QuotaGuardConfig(enabled=False), + features={"codex_backend": backend == "codex"}, + experimental_enabled=True, + ) + tool_ctx = make_context( + config, + runner=DefaultSubprocessRunner(), + plugin_source=DirectInstall(plugin_dir=pkg_root()), + project_dir=fixture_repo, + ) + tool_ctx.config.linux_tracing.log_dir = str(tmp_path / "session-logs") + tool_ctx.config.linux_tracing.tmpfs_path = str(tmp_path / "shm") + + from autoskillit.server import _state + + monkeypatch.setattr(_state, "_ctx", tool_ctx) + monkeypatch.setattr(_state, "_startup_ready", None) + mcp_ctx = AsyncMock() + + kitchen_result = json.loads(await open_kitchen(ctx=mcp_ctx)) + assert kitchen_result["success"] is True, kitchen_result + assert kitchen_result["kitchen"] == "open" + + command = ( + f"/investigate --deep Analyze {fixture_path} and the repository paths that produce " + "or consume it. Use byte-bounded evidence commands, complete every required deep-mode " + "batch and validation stage, do not modify tracked files, and write the final report." + ) + try: + raw_result = await run_skill( + command, + str(fixture_repo), + step_name="output_budget_deep_investigate_probe", + idle_output_timeout=0, + ctx=mcp_ctx, + ) + _assert_investigation_result(raw_result, fixture_path) + finally: + await close_kitchen(ctx=mcp_ctx) diff --git a/tests/test_test_filter.py b/tests/test_test_filter.py index 6dcee93157..ec181715df 100644 --- a/tests/test_test_filter.py +++ b/tests/test_test_filter.py @@ -501,6 +501,14 @@ def test_conservative_wider_cascade(self) -> None: f"Aggressive cascade for {pkg} is not a subset of conservative" ) + @pytest.mark.parametrize("package", ["hooks", "server"]) + def test_output_budget_changes_cascade_to_codex_contracts(self, package: str) -> None: + expected = { + "execution/backends/test_codex_config.py", + "execution/backends/test_codex_backend.py", + } + assert expected <= LAYER_CASCADE_CONSERVATIVE[package] + # --------------------------------------------------------------------------- # Git Diff Edge Cases (G1–G5) From e8a7cca3428191bd88069a1b105409c49b1ffe7b Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 09:03:35 -0700 Subject: [PATCH 05/30] fix: harden output budget response contracts --- .../output-budget-remediation/manifest.json | 104 +++++ .autoskillit/test-filter-manifest.yaml | 2 + docs/decisions/0005-output-budget-protocol.md | 21 +- scripts/validate_output_budget_evidence.py | 408 ++++++++++++++++++ src/autoskillit/core/__init__.pyi | 6 +- src/autoskillit/core/types/_type_constants.py | 3 - .../core/types/_type_constants_registries.py | 39 ++ .../execution/backends/_codex_config.py | 7 +- .../execution/backends/_probe_cache.py | 4 +- src/autoskillit/hooks/formatters/AGENTS.md | 1 + .../hooks/formatters/_fmt_primitives.py | 69 ++- .../hooks/formatters/_fmt_recipe.py | 12 +- .../hooks/formatters/_fmt_response_spill.py | 115 +++++ .../hooks/formatters/pretty_output_hook.py | 27 +- src/autoskillit/server/AGENTS.md | 3 +- src/autoskillit/server/__init__.py | 7 + src/autoskillit/server/_notify.py | 31 +- src/autoskillit/server/_response_budget.py | 344 +++++++++++---- .../server/_response_conformance.py | 154 +++++++ .../server/tools/_execution_helpers.py | 4 +- .../server/tools/_serve_helpers.py | 38 ++ src/autoskillit/server/tools/tools_kitchen.py | 30 +- src/autoskillit/server/tools/tools_recipe.py | 6 +- tests/_test_filter.py | 1 + tests/arch/test_subpackage_isolation.py | 7 +- tests/arch/test_subpackage_structure.py | 4 +- tests/core/test_io_spill.py | 22 +- tests/core/test_type_constants.py | 68 +++ .../test_output_budget_protocol_decision.py | 17 +- .../config_toml_schema_template.json | 2 +- .../backends/test_cli_conformance_probes.py | 4 +- tests/execution/backends/test_codex_config.py | 13 +- tests/execution/backends/test_probe_cache.py | 4 +- tests/hooks/test_hook_sync.py | 45 ++ tests/infra/AGENTS.md | 7 +- tests/infra/test_output_budget_evidence.py | 283 ++++++++++++ .../test_pretty_output_generic_and_wrap.py | 79 +++- tests/infra/test_pretty_output_hook_infra.py | 127 ++++++ tests/infra/test_pretty_output_recipe.py | 144 +++++-- tests/infra/test_schema_version_convention.py | 8 +- tests/server/AGENTS.md | 10 +- tests/server/test_response_backstop.py | 269 +++++++++++- tests/server/test_tools_execution_spill.py | 185 +++++++- tests/server/test_tools_git.py | 58 ++- .../test_tools_kitchen_gate_hook_config.py | 2 + tests/server/test_track_response_size.py | 115 ++++- tests/server/test_wire_compat.py | 250 ++++++++++- tests/test_test_filter_core_cascade.py | 16 +- 48 files changed, 2917 insertions(+), 258 deletions(-) create mode 100644 .autoskillit/evidence/output-budget-remediation/manifest.json create mode 100644 scripts/validate_output_budget_evidence.py create mode 100644 src/autoskillit/hooks/formatters/_fmt_response_spill.py create mode 100644 src/autoskillit/server/_response_conformance.py create mode 100644 tests/infra/test_output_budget_evidence.py diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json new file mode 100644 index 0000000000..5b8ebdd9c6 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -0,0 +1,104 @@ +{ + "schema_version": 1, + "implementation_baseline_sha": "093179c8f7eb3ebe3b5783d84409d0b074333686", + "historical_context": [ + { + "phase": 1, + "phase_commit_sha": "8091755f5d4beffbf5d368625a5fb7fb055aae6e", + "bound_to_commit": false, + "summary": "30649 passed; historical log contains no embedded command or tested-SHA provenance", + "gate_log_path": "temp/test-2026-07-15_170319.txt", + "gate_log_sha256": "42f8cd8214abc10b5b075cdd88d81ca48c5040d642be6f7273de5aecc949ec14" + }, + { + "phase": 2, + "phase_commit_sha": "0726dc802bc7d04b16a30d96ecf8642164dcb4f3", + "bound_to_commit": false, + "summary": "29467 passed; historical log contains no embedded command or tested-SHA provenance", + "gate_log_path": "temp/test-2026-07-15_174512.txt", + "gate_log_sha256": "7702f2eb677daf51d8bfb187f2dcc42fc4c894d5fe8c342713d92cd9db0a6d84" + }, + { + "phase": 3, + "phase_commit_sha": "f43b98ddefbf7e4089f88d8f3ec089c1d17bc7ae", + "bound_to_commit": false, + "summary": "29504 passed; historical log contains no embedded command or tested-SHA provenance", + "gate_log_path": "temp/test-2026-07-15_180133.txt", + "gate_log_sha256": "1e8f2f003cfe88d389bf2d2ecc130d556cd447f82312b7eee918f364b9e3100b" + }, + { + "phase": 4, + "phase_commit_sha": "093179c8f7eb3ebe3b5783d84409d0b074333686", + "bound_to_commit": false, + "summary": "29561 passed; historical log contains no embedded command or tested-SHA provenance", + "gate_log_path": "temp/test-2026-07-15_182329.txt", + "gate_log_sha256": "7e5aff24b916a215711506be116026891d9fd9be4baa31a9ed58c44053cb5706" + } + ], + "baseline_regression": { + "command": "task test-check", + "status": "pass", + "summary": "T1-T8 pre-remediation baseline: 180 passed, 10 skipped", + "gate_tested_sha": "093179c8f7eb3ebe3b5783d84409d0b074333686", + "gate_log_path": "temp/test-2026-07-15_224649.txt", + "gate_log_sha256": "110cc8fc22aa832efcc461166f3c95052ec39fabc33ac95410fe8ac2697c0a16" + }, + "phases": [], + "closure": { + "historical_mutation_transcript": "non-reconstructible", + "historical_artifacts": [ + { + "path": ".autoskillit/temp/phase4-closure-prefetch.json", + "response_content_sha256": "3cfce1c21918abd78076fbf4c83dff5394ff5815c725b04d1f96980559d9431a" + }, + { + "path": ".autoskillit/temp/phase4-closure-verification.json", + "response_content_sha256": "02c4ca724ff9df480f85284da6e035ce0c36510b9055919164b185325f465847" + }, + { + "path": ".autoskillit/temp/issue-3938-body-before.md", + "response_content_sha256": "669352d9d9eaaa73cff6fe9ca2537884d6943024b60020d63e87afdab2d9aa5b" + }, + { + "path": ".autoskillit/temp/issue-3938-body-after.md", + "response_content_sha256": "1b27a003a689a67d6346dbfb126b1639b21e309dd69397351b84b0a2421ab012" + }, + { + "path": ".autoskillit/temp/issue-3938-closure.md", + "response_content_sha256": "72ba53afbd52d1a9eff1af0ba91e55e7bd33d12ff9e846df99c16601bb035873" + }, + { + "path": ".autoskillit/temp/issue-4253-body-before.md", + "response_content_sha256": "47feed1ea52be757c13590e264f487a6ddacfc49fdfd0dc1a66710e70d56569a" + }, + { + "path": ".autoskillit/temp/issue-4253-body-after.md", + "response_content_sha256": "2c79084734e3c4f4b662942ee006f0aff88d73653e27cb593711cb21a5a85402" + }, + { + "path": ".autoskillit/temp/issue-4253-closure.md", + "response_content_sha256": "d503654553ecedc746c2048f660d9db8a5b358481b86a83dff56e1f48d60fd85" + }, + { + "path": ".autoskillit/temp/pr-4259-body.md", + "response_content_sha256": "c60d2be6da0318b5ccfc2b023d0deec6e31d3a6ea6dc1ff0e27cab91df7b1c79" + }, + { + "path": ".autoskillit/temp/build_phase4_closure_bodies.py", + "response_content_sha256": "5ee14719e5160716c418cf4bddab94383072e4e39dd1e762ad0bf4b6a173ee81" + }, + { + "path": ".autoskillit/temp/phase4_prefetch_graphql.py", + "response_content_sha256": "edacf2fb5520a02e2ec36d8525f6209bc716344d94ad53770fe50d3a187962ed" + }, + { + "path": ".autoskillit/temp/verify_phase4_issue_closures.py", + "response_content_sha256": "7ac277b06543b682e5115b8f6f4d63f4748ecb84f9d9db95c47e371f5e030111" + }, + { + "path": ".autoskillit/temp/apply_phase4_issue_closures.sh", + "response_content_sha256": "56f8b56c6bc48a4f1dc9af8b98c5e1efedaeeae836beae5d6e2b44765e3fe25e" + } + ] + } +} diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index 0a8588b4fe..697c36fde9 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -223,6 +223,8 @@ scripts/check_tool_annotations.py: - arch/ scripts/compare-coverage-ast.py: - infra/ +scripts/validate_output_budget_evidence.py: + - infra/ scripts/compile_recipes.py: - recipe/ - infra/ diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index f780c84600..71221a4bd7 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -44,14 +44,18 @@ containment; downstream layers are independent backstops rather than substitutes the ceiling is downstream containment. Neither is cumulative-context accounting. The raw-text `open_kitchen` and `load_recipe` responses are measured exemptions from the -universal backstop. Adding an exemption requires its own measured-budget test. +universal backstop. `RESPONSE_BACKSTOP_EXEMPTION_REGISTRY` is the closed authority for +their independent character ceiling, UTF-8 byte ceiling, and measurement identity. Its +canonical digest is carried in tool metadata and probe-cache identity. Adding or relaxing +an exemption requires re-measurement and a deliberate registry-digest change. ## Numeric Limits and Rationale | Limit | Decision and rationale | |---|---| -| `OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000` | This is a stable regression ceiling, not an estimate of the external gate. The 2026-07-15 maximum canonical rendering was 95,771 UTF-8 bytes for `remediation` with all truthy ingredients. The 229-byte project margin keeps growth explicit, while the payload remains roughly 4 KB below the last observed external persistence gate near 100 KB. Re-measure after CLI upgrades instead of silently raising the constant. | -| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 32_000` | Derive it as `((96_000 + 3) // 4) + 8_000`: 24,000 tokens under the current client's four-byte heuristic, plus 8,000 tokens (32 KB under that heuristic) for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. | +| `load_recipe`: `max_chars = 179_000`, `max_utf8_bytes = 179_000` | The 2026-07-15 independent all-recipe/all-mode pre-backstop measurement reached 178,601 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 399-unit margin makes serving growth explicit. Measurement identity: `bundled-recipes-all-modes-2026-07-15/load-recipe`. | +| `open_kitchen`: `max_chars = 180_000`, `max_utf8_bytes = 180_000` | The matching current-version pre-backstop measurement reached 178,660 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,340-unit margin covers the open-kitchen routing fields without conflating this handler ceiling with the smaller formatted presentation. Measurement identity: `bundled-recipes-all-modes-2026-07-15/open-kitchen`. | +| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 53_000` | Derive it from the largest registered exemption as `((180_000 + 3) // 4) + 8_000`: 45,000 tokens under the current client's four-byte heuristic, plus 8,000 tokens for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. | | `CODEX_AUTO_COMPACT_LIMIT = 999_999_999` | Retain the unreachable sentinel and the recovery obligation accepted in [ADR-0004](0004-recipe-redelivery.md). This protocol does not relax recipe-preservation policy. | | `inline_max_chars = 5_000` | Preserve the previous truncation threshold while changing the representation from destructive clipping to an artifact-backed preview. The configured 2,500-character head and 2,500-character tail retain both diagnostic setup and terminal status; a spill marker is added outside those source slices. | | `response_max_bytes = 90_000` | Bound the exact compact serialized handler payload before a coarser transport can clip it. Bytes are authoritative here; this is not a token or full JSON-RPC-envelope estimate. | @@ -74,14 +78,15 @@ estimate. The project therefore requires the stricter relationship `response_max_bytes // 3 < CODEX_TOOL_OUTPUT_TOKEN_LIMIT`. The three-byte divisor is -deliberate margin: the 90,000-byte response backstop must fire before Codex's 32,000-token +deliberate margin: the 90,000-byte response backstop must fire before Codex's 53,000-token transport ceiling can clip a producer-blind response. A static test pins the relationship, and the live large-output probe must pass before either side is retuned. The measured raw-text exemptions, `open_kitchen` and `load_recipe`, must each remain below -the 128,000-byte budget implied by the current 32,000-token, four-byte heuristic. Their -measurements are independent release gates; the heuristic is not permission to omit -those tests. +their own registered character and UTF-8 byte ceilings, which in turn remain below the +212,000-byte budget implied by the current 53,000-token, four-byte heuristic. Their +measurements are independent release gates; the heuristic is not permission to omit those +tests or reuse one surface's observed maximum as the other's authority. ## Corrections of Record @@ -133,7 +138,7 @@ reserve-trigger metrics, so instrumentation must not claim those mechanisms exis ## Forward Obligations -- Re-measure the 96,000-byte empirical `open_kitchen` bound after CLI upgrades. +- Re-measure both registered raw-response ceilings after CLI upgrades. - Any ceiling relaxation, command-guard rule removal, response-backstop exemption addition, or output-discipline policy-version change invalidates the applicable cached capability probe. diff --git a/scripts/validate_output_budget_evidence.py b/scripts/validate_output_budget_evidence.py new file mode 100644 index 0000000000..4b94bbb9ed --- /dev/null +++ b/scripts/validate_output_budget_evidence.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +"""Validate the Output Budget Protocol remediation evidence manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + +SCHEMA_VERSION = 1 +PHASES = (1, 2, 3, 4) +HISTORICAL_PHASE_SHAS = ( + "8091755f5d4beffbf5d368625a5fb7fb055aae6e", + "0726dc802bc7d04b16a30d96ecf8642164dcb4f3", + "f43b98ddefbf7e4089f88d8f3ec089c1d17bc7ae", + "093179c8f7eb3ebe3b5783d84409d0b074333686", +) +REQUIRED_PHASE_GATE_COMMANDS = { + 1: frozenset({"task test-all", "pre-commit run --all-files"}), + 2: frozenset({"task test-all", "pre-commit run --all-files"}), + 3: frozenset( + { + "task test-codex-config-parse", + "task test-all", + "pre-commit run --all-files", + } + ), + 4: frozenset({"task test-all", "pre-commit run --all-files"}), +} +REQUIRED_PROBES = ( + "installed_codex_parse", + "generated_codex_child", + "deep_codex", + "deep_claude_200k", +) +SHA_RE = re.compile(r"[0-9a-f]{40}\Z") +DIGEST_RE = re.compile(r"[0-9a-f]{64}\Z") + + +class EvidenceValidationError(ValueError): + """Raised when evidence does not satisfy the remediation contract.""" + + +def _require_dict(value: Any, location: str) -> dict[str, Any]: + if not isinstance(value, dict): + raise EvidenceValidationError(f"{location} must be an object") + return value + + +def _require_list(value: Any, location: str) -> list[Any]: + if not isinstance(value, list): + raise EvidenceValidationError(f"{location} must be an array") + return value + + +def _require_string(value: Any, location: str) -> str: + if not isinstance(value, str) or not value: + raise EvidenceValidationError(f"{location} must be a non-empty string") + return value + + +def _require_sha(value: Any, location: str) -> str: + sha = _require_string(value, location) + if SHA_RE.fullmatch(sha) is None: + raise EvidenceValidationError(f"{location} must be a lowercase 40-hex SHA") + return sha + + +def _require_digest(value: Any, location: str) -> str: + digest = _require_string(value, location) + if DIGEST_RE.fullmatch(digest) is None: + raise EvidenceValidationError(f"{location} must be a lowercase SHA-256 digest") + return digest + + +def _resolve_evidence_path(repo_root: Path, value: Any, location: str) -> Path: + relative = Path(_require_string(value, location)) + if relative.is_absolute(): + raise EvidenceValidationError(f"{location} must be repository-relative") + root = repo_root.resolve() + resolved = (root / relative).resolve() + try: + resolved.relative_to(root) + except ValueError as exc: + raise EvidenceValidationError(f"{location} escapes the repository root") from exc + if not resolved.is_file(): + raise EvidenceValidationError(f"{location} does not exist: {relative}") + return resolved + + +def _validate_file_reference( + record: dict[str, Any], + repo_root: Path, + *, + location: str, + path_key: str, + digest_key: str, +) -> Path: + path = _resolve_evidence_path(repo_root, record.get(path_key), f"{location}.{path_key}") + expected = _require_digest(record.get(digest_key), f"{location}.{digest_key}") + actual = hashlib.sha256(path.read_bytes()).hexdigest() + if actual != expected: + raise EvidenceValidationError(f"{location}.{digest_key} mismatch for {record[path_key]}") + return path + + +def _validate_gate( + value: Any, + repo_root: Path, + *, + location: str, + expected_sha: str, +) -> dict[str, Any]: + gate = _require_dict(value, location) + _require_string(gate.get("command"), f"{location}.command") + if gate.get("status") != "pass": + raise EvidenceValidationError(f"{location}.status must be 'pass'") + _require_string(gate.get("summary"), f"{location}.summary") + tested_sha = _require_sha(gate.get("gate_tested_sha"), f"{location}.gate_tested_sha") + if tested_sha != expected_sha: + raise EvidenceValidationError(f"{location}.gate_tested_sha must equal {expected_sha}") + _validate_file_reference( + gate, + repo_root, + location=location, + path_key="gate_log_path", + digest_key="gate_log_sha256", + ) + return gate + + +def _validate_historical_context(manifest: dict[str, Any], repo_root: Path) -> None: + entries = _require_list(manifest.get("historical_context"), "historical_context") + if [entry.get("phase") for entry in entries if isinstance(entry, dict)] != list(PHASES): + raise EvidenceValidationError("historical_context must contain phases 1 through 4") + + seen: set[str] = set() + for index, value in enumerate(entries): + location = f"historical_context[{index}]" + entry = _require_dict(value, location) + sha = _require_sha(entry.get("phase_commit_sha"), f"{location}.phase_commit_sha") + if sha in seen: + raise EvidenceValidationError("historical phase_commit_sha values must be distinct") + seen.add(sha) + if entry.get("bound_to_commit") is not False: + raise EvidenceValidationError(f"{location}.bound_to_commit must be false") + _require_string(entry.get("summary"), f"{location}.summary") + _validate_file_reference( + entry, + repo_root, + location=location, + path_key="gate_log_path", + digest_key="gate_log_sha256", + ) + + actual_shas = tuple(entry["phase_commit_sha"] for entry in entries) + if actual_shas != HISTORICAL_PHASE_SHAS: + raise EvidenceValidationError( + "historical_context phase_commit_sha values do not match the audited baseline" + ) + + baseline = _require_sha( + manifest.get("implementation_baseline_sha"), "implementation_baseline_sha" + ) + if entries[-1]["phase_commit_sha"] != baseline: + raise EvidenceValidationError("implementation_baseline_sha must equal historical phase 4") + + +def _validate_phases(manifest: dict[str, Any], repo_root: Path) -> list[dict[str, Any]]: + values = _require_list(manifest.get("phases"), "phases") + if len(values) > len(PHASES): + raise EvidenceValidationError("phases contains more than four records") + expected_prefix = list(PHASES[: len(values)]) + actual = [value.get("phase") for value in values if isinstance(value, dict)] + if actual != expected_prefix: + raise EvidenceValidationError("phases must be the ordered prefix 1, 2, 3, 4") + + baseline = _require_sha( + manifest.get("implementation_baseline_sha"), "implementation_baseline_sha" + ) + seen = {baseline} + phases: list[dict[str, Any]] = [] + for index, value in enumerate(values): + location = f"phases[{index}]" + phase = _require_dict(value, location) + phase_sha = _require_sha(phase.get("phase_commit_sha"), f"{location}.phase_commit_sha") + gate_sha = _require_sha(phase.get("gate_tested_sha"), f"{location}.gate_tested_sha") + if phase_sha != gate_sha: + raise EvidenceValidationError( + f"{location}.phase_commit_sha must equal gate_tested_sha" + ) + if phase_sha in seen: + raise EvidenceValidationError("phase commit SHAs must be distinct and post-baseline") + seen.add(phase_sha) + gates = _require_list(phase.get("gates"), f"{location}.gates") + if not gates: + raise EvidenceValidationError(f"{location}.gates must not be empty") + commands: set[str] = set() + for gate_index, gate in enumerate(gates): + validated = _validate_gate( + gate, + repo_root, + location=f"{location}.gates[{gate_index}]", + expected_sha=phase_sha, + ) + commands.add(validated["command"]) + required_commands = REQUIRED_PHASE_GATE_COMMANDS[phase["phase"]] + if not required_commands <= commands: + missing = sorted(required_commands - commands) + raise EvidenceValidationError( + f"{location}.gates missing required command(s): {missing}" + ) + phases.append(phase) + return phases + + +def _validate_indexed_closure_artifacts( + manifest: dict[str, Any], repo_root: Path +) -> dict[str, Any] | None: + value = manifest.get("closure") + if value is None: + return None + closure = _require_dict(value, "closure") + artifacts = _require_list( + closure.get("historical_artifacts", []), "closure.historical_artifacts" + ) + for index, artifact in enumerate(artifacts): + record = _require_dict(artifact, f"closure.historical_artifacts[{index}]") + _validate_file_reference( + record, + repo_root, + location=f"closure.historical_artifacts[{index}]", + path_key="path", + digest_key="response_content_sha256", + ) + return closure + + +def _validate_probe( + value: Any, + repo_root: Path, + *, + location: str, + expected_sha: str, +) -> None: + probe = _require_dict(value, location) + if probe.get("status") != "pass": + raise EvidenceValidationError(f"{location}.status must be 'pass'") + _require_string(probe.get("command"), f"{location}.command") + _require_string(probe.get("summary"), f"{location}.summary") + tested_sha = _require_sha(probe.get("gate_tested_sha"), f"{location}.gate_tested_sha") + if tested_sha != expected_sha: + raise EvidenceValidationError(f"{location}.gate_tested_sha must equal {expected_sha}") + _validate_file_reference( + probe, + repo_root, + location=location, + path_key="gate_log_path", + digest_key="gate_log_sha256", + ) + + +def _validate_closure_postcondition(closure: dict[str, Any], repo_root: Path) -> None: + merge_sha = _require_sha(closure.get("closure_pr_merge_sha"), "closure.closure_pr_merge_sha") + postcondition = _require_dict(closure.get("postcondition"), "closure.postcondition") + path = _validate_file_reference( + postcondition, + repo_root, + location="closure.postcondition", + path_key="path", + digest_key="response_content_sha256", + ) + try: + response = _require_dict(json.loads(path.read_text()), "closure postcondition") + repository = _require_dict( + _require_dict(response.get("data"), "closure postcondition.data").get("repository"), + "closure postcondition.data.repository", + ) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise EvidenceValidationError("closure postcondition is not valid JSON") from exc + + for issue_number in (3938, 4253): + issue = _require_dict( + repository.get(f"issue{issue_number}"), + f"closure postcondition issue{issue_number}", + ) + if issue.get("state") != "CLOSED" or "## Closure" not in str(issue.get("body", "")): + raise EvidenceValidationError( + f"issue #{issue_number} must be CLOSED with a Closure section" + ) + + pull_request = _require_dict(repository.get("pr4259"), "closure postcondition pr4259") + merge_commit = _require_dict( + pull_request.get("mergeCommit"), "closure postcondition pr4259.mergeCommit" + ) + if pull_request.get("state") != "MERGED" or merge_commit.get("oid") != merge_sha: + raise EvidenceValidationError("PR #4259 must be MERGED at closure_pr_merge_sha") + + +def validate_manifest(manifest: dict[str, Any], repo_root: Path, *, mode: str) -> None: + """Validate one manifest in incremental or complete mode.""" + if manifest.get("schema_version") != SCHEMA_VERSION: + raise EvidenceValidationError(f"schema_version must be {SCHEMA_VERSION}") + if mode not in {"incremental", "complete"}: + raise EvidenceValidationError("mode must be incremental or complete") + + _validate_historical_context(manifest, repo_root) + baseline_sha = _require_sha( + manifest.get("implementation_baseline_sha"), "implementation_baseline_sha" + ) + _validate_gate( + manifest.get("baseline_regression"), + repo_root, + location="baseline_regression", + expected_sha=baseline_sha, + ) + phases = _validate_phases(manifest, repo_root) + closure = _validate_indexed_closure_artifacts(manifest, repo_root) + if mode == "incremental": + return + + if len(phases) != len(PHASES): + raise EvidenceValidationError("complete mode requires all four phases") + audit_base_sha = _require_sha(manifest.get("audit_base_sha"), "audit_base_sha") + audit_head_sha = _require_sha(manifest.get("audit_head_sha"), "audit_head_sha") + if audit_base_sha == audit_head_sha: + raise EvidenceValidationError("audit_base_sha and audit_head_sha must differ") + if phases[-1]["phase_commit_sha"] != audit_head_sha: + raise EvidenceValidationError("audit_head_sha must equal the Phase 4 phase_commit_sha") + + final_gates = _require_list(manifest.get("final_gates"), "final_gates") + commands = set() + for index, gate in enumerate(final_gates): + validated = _validate_gate( + gate, + repo_root, + location=f"final_gates[{index}]", + expected_sha=audit_head_sha, + ) + commands.add(validated["command"]) + if "task test-all" not in commands or "pre-commit run --all-files" not in commands: + raise EvidenceValidationError( + "complete mode requires final task test-all and pre-commit gates" + ) + + probes = _require_dict(manifest.get("probes"), "probes") + for name in REQUIRED_PROBES: + expected_sha = ( + phases[2]["phase_commit_sha"] if name == "installed_codex_parse" else audit_head_sha + ) + _validate_probe( + probes.get(name), + repo_root, + location=f"probes.{name}", + expected_sha=expected_sha, + ) + + if closure is None: + raise EvidenceValidationError("complete mode requires closure evidence") + _validate_closure_postcondition(closure, repo_root) + + +def _git_root(manifest_path: Path) -> Path: + result = subprocess.run( + ["git", "-C", str(manifest_path.parent), "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ) + return Path(result.stdout.strip()) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "manifest", + nargs="?", + type=Path, + default=Path(".autoskillit/evidence/output-budget-remediation/manifest.json"), + ) + parser.add_argument("--mode", choices=("incremental", "complete"), required=True) + args = parser.parse_args(argv) + + try: + manifest_path = args.manifest.resolve(strict=True) + repo_root = _git_root(manifest_path) + manifest = _require_dict(json.loads(manifest_path.read_text()), "manifest") + validate_manifest(manifest, repo_root, mode=args.mode) + except ( + EvidenceValidationError, + FileNotFoundError, + json.JSONDecodeError, + subprocess.CalledProcessError, + ) as exc: + print(f"OUTPUT_BUDGET_EVIDENCE=FAIL: {exc}", file=sys.stderr) + return 1 + + print(f"OUTPUT_BUDGET_EVIDENCE=PASS mode={args.mode}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index ac5ceb3b1d..bc36810021 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -184,7 +184,6 @@ from .types import LABEL_TRANSITIONS as LABEL_TRANSITIONS from .types import LAUNCH_ID_ENV_VAR as LAUNCH_ID_ENV_VAR from .types import MCP_CLIENT_BACKEND_ENV_VAR as MCP_CLIENT_BACKEND_ENV_VAR from .types import NON_VARIADIC_CLAUDE_FLAGS as NON_VARIADIC_CLAUDE_FLAGS -from .types import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES as OPEN_KITCHEN_OUTPUT_BUDGET_BYTES from .types import ORCHESTRATOR_SESSION_REQUIRED_ENV as ORCHESTRATOR_SESSION_REQUIRED_ENV from .types import ORDER_INTERACTIVE_REQUIRED_ENV as ORDER_INTERACTIVE_REQUIRED_ENV from .types import OUTPUT_DISCIPLINE_BLOCK as OUTPUT_DISCIPLINE_BLOCK @@ -206,6 +205,10 @@ from .types import RECIPE_PACK_REGISTRY as RECIPE_PACK_REGISTRY from .types import RECIPE_PACK_TAGS as RECIPE_PACK_TAGS 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 +from .types import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST as RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, +) from .types import RESUME_SESSION_BASELINE_KEYS as RESUME_SESSION_BASELINE_KEYS from .types import RETIRED_AGENT_NAMES as RETIRED_AGENT_NAMES from .types import RETIRED_FEATURES as RETIRED_FEATURES @@ -341,6 +344,7 @@ from .types import RecipeNotFoundError as RecipeNotFoundError from .types import RecipePackDef as RecipePackDef from .types import RecipeRepository as RecipeRepository from .types import RecipeSource as RecipeSource +from .types import ResponseBackstopExemptionDef as ResponseBackstopExemptionDef from .types import RestartScope as RestartScope from .types import ResultParser as ResultParser from .types import ResumeSpec as ResumeSpec diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index 0b92dc2037..443ac5efbf 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -11,7 +11,6 @@ from ._type_constants_registries import SKILL_CAPABILITY_REGISTRY __all__ = [ - "OPEN_KITCHEN_OUTPUT_BUDGET_BYTES", "OUTPUT_DISCIPLINE_POLICY_VERSION", "OUTPUT_DISCIPLINE_BLOCK", "OUTPUT_DISCIPLINE_BLOCK_SHA256", @@ -51,8 +50,6 @@ "CODEX_SESSIONS_SUBDIR", ] -OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000 - OUTPUT_DISCIPLINE_POLICY_VERSION = 1 OUTPUT_DISCIPLINE_BLOCK = "\n".join( diff --git a/src/autoskillit/core/types/_type_constants_registries.py b/src/autoskillit/core/types/_type_constants_registries.py index 5e07a1527a..3f1c60d29a 100644 --- a/src/autoskillit/core/types/_type_constants_registries.py +++ b/src/autoskillit/core/types/_type_constants_registries.py @@ -7,6 +7,8 @@ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass, fields from typing import Literal, NamedTuple @@ -34,6 +36,9 @@ "RecipePackDef", "RECIPE_PACK_REGISTRY", "RECIPE_PACK_TAGS", + "ResponseBackstopExemptionDef", + "RESPONSE_BACKSTOP_EXEMPTION_REGISTRY", + "RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST", "AgentPackDef", "AGENT_PACK_REGISTRY", "CORE_PACKS", @@ -174,6 +179,40 @@ ) +class ResponseBackstopExemptionDef(NamedTuple): + """Measured ceiling for a tool that bypasses universal response shaping.""" + + max_chars: int + max_utf8_bytes: int + measurement_id: str + + +RESPONSE_BACKSTOP_EXEMPTION_REGISTRY: dict[str, ResponseBackstopExemptionDef] = { + "load_recipe": ResponseBackstopExemptionDef( + max_chars=179_000, + max_utf8_bytes=179_000, + measurement_id="bundled-recipes-all-modes-2026-07-15/load-recipe", + ), + "open_kitchen": ResponseBackstopExemptionDef( + max_chars=180_000, + max_utf8_bytes=180_000, + measurement_id="bundled-recipes-all-modes-2026-07-15/open-kitchen", + ), +} + + +def _response_backstop_exemption_registry_digest() -> str: + canonical = { + tool_name: definition._asdict() + for tool_name, definition in sorted(RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.items()) + } + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(payload.encode("ascii")).hexdigest() + + +RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST: str = _response_backstop_exemption_registry_digest() + + class PackDef(NamedTuple): """Definition of a named skill pack with default visibility state.""" diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 91c677694b..4960f9d491 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -10,7 +10,7 @@ from autoskillit.core import ( CODEX_MCP_ENV_FORWARD_VARS, HEADLESS_AUTO_GATE_ENV_VAR, - OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, ReadResult, atomic_write, get_logger, @@ -28,7 +28,10 @@ # native shell and MCP output. Codex currently applies a coarse four-UTF-8-byte # truncation heuristic; the extra 8,000 tokens provide serialized-payload # headroom. Guard and spill layers remain the load-bearing mechanisms. -CODEX_TOOL_OUTPUT_TOKEN_LIMIT: int = ((OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + 3) // 4) + 8_000 +_MAX_RESPONSE_BACKSTOP_EXEMPTION_BYTES: int = max( + definition.max_utf8_bytes for definition in RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.values() +) +CODEX_TOOL_OUTPUT_TOKEN_LIMIT: int = ((_MAX_RESPONSE_BACKSTOP_EXEMPTION_BYTES + 3) // 4) + 8_000 # Disable Codex auto-compaction by setting the limit to an unreachable value. # Auto-compaction at 90% of 258K context window can destroy recipe content diff --git a/src/autoskillit/execution/backends/_probe_cache.py b/src/autoskillit/execution/backends/_probe_cache.py index baa518188e..d6931981fd 100644 --- a/src/autoskillit/execution/backends/_probe_cache.py +++ b/src/autoskillit/execution/backends/_probe_cache.py @@ -14,6 +14,7 @@ from autoskillit.core import ( OUTPUT_DISCIPLINE_BLOCK_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, get_logger, read_versioned_json, write_versioned_json, @@ -23,7 +24,8 @@ PROBE_CACHE_TTL: timedelta = timedelta(hours=24) PROBE_POLICY_IDENTITY: str = ( - f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}" + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" + f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}" ) _SCHEMA_VERSION: int = 2 diff --git a/src/autoskillit/hooks/formatters/AGENTS.md b/src/autoskillit/hooks/formatters/AGENTS.md index c389ea6b35..7fc35d9b39 100644 --- a/src/autoskillit/hooks/formatters/AGENTS.md +++ b/src/autoskillit/hooks/formatters/AGENTS.md @@ -9,6 +9,7 @@ PostToolUse output formatters — MCP JSON to Markdown-KV reformatter (30-77% to | `__init__.py` | Package marker (no imports) | | `pretty_output_hook.py` | Dispatch entrypoint: intercepts MCP tool responses, routes to per-tool formatters | | `_fmt_primitives.py` | Shared primitives: `_CHECK_MARK`, `_CROSS_MARK`, payload dataclasses, token formatter | +| `_fmt_response_spill.py` | Standalone artifact metadata trust, containment, size, and digest validation | | `_fmt_execution.py` | Formatters for `run_skill`, `run_cmd`, `test_check`, `merge_worktree` | | `_fmt_dispatch.py` | Formatter for `dispatch_food_truck` artifact-backed nested data | | `_fmt_recipe.py` | Formatters for `load_recipe`, `open_kitchen`, `list_recipes` | diff --git a/src/autoskillit/hooks/formatters/_fmt_primitives.py b/src/autoskillit/hooks/formatters/_fmt_primitives.py index d3805e22f7..65c979f404 100644 --- a/src/autoskillit/hooks/formatters/_fmt_primitives.py +++ b/src/autoskillit/hooks/formatters/_fmt_primitives.py @@ -1,20 +1,34 @@ -"""Shared primitives for the pretty_output_hook PostToolUse formatter split. - -Stdlib-only at runtime — runs under any Python interpreter without the -autoskillit package, so the four ``_fmt_*`` modules and ``pretty_output_hook.py`` -all import directly from this module without going through any IL-1+ layer. -""" +"""Shared primitives for the standalone, stdlib-only pretty-output hook.""" from __future__ import annotations import json from dataclasses import dataclass +from importlib import import_module from pathlib import Path from typing import Any -# Keep in sync with HOOK_DIR_COMPONENTS + HOOK_CONFIG_FILENAME in _hook_settings.py -# (stdlib-only boundary prevents a shared import). _HOOK_CONFIG_PATH_COMPONENTS = (".autoskillit", "temp", ".hook_config.json") +_RESPONSE_SPILL_EXPORTS = ( + "_RESPONSE_BACKSTOP_EXEMPTION_REGISTRY", + "_RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST", + "_RESPONSE_SPILL_METADATA_KEY", + "_RESPONSE_SPILL_METADATA_KEYS", + "_RESPONSE_SPILL_REASONS", + "_RESPONSE_SPILL_SCHEMA_DIGEST", + "_RESPONSE_SPILL_SCHEMA_VERSION", + "_validate_response_spill_metadata", +) + + +def __getattr__(name: str) -> Any: + """Lazily re-export spill contracts in package and standalone hook modes.""" + if name not in _RESPONSE_SPILL_EXPORTS: + raise AttributeError(name) + module_name = f"{__package__}._fmt_response_spill" if __package__ else "_fmt_response_spill" + value = getattr(import_module(module_name), name) + globals()[name] = value + return value @dataclass(frozen=True, slots=True) @@ -79,31 +93,41 @@ def _filter_pytest_output(raw: str) -> str: ) -def _fmt_generic(short_name: str, data: dict, _pipeline: bool) -> str: - """Format tools without a dedicated response renderer.""" +def _fmt_generic( + short_name: str, + data: dict, + _pipeline: bool, + *, + artifact_backed: bool, +) -> str: + """Format generic data, reducing it only when a complete artifact is trusted.""" lines = [f"## {short_name}", ""] for key, val in data.items(): if isinstance(val, list): val = list(val) + visible = val[:20] if artifact_backed else val if not val: lines.append(f"{key}: []") elif all(isinstance(item, str) for item in val): lines.append(f"{key}:") - lines.extend(f" - {item}" for item in val[:20]) + lines.extend(f" - {item}" for item in visible) else: lines.append(f"{key}:") - for item in val[:20]: + for item in visible: if isinstance(item, dict): - kvs = [ - f"{k}: {(s := str(v))[:120] + ('...' if len(s) > 120 else '')}" - for k, v in item.items() - ] + kvs = [] + for nested_key, nested_value in item.items(): + rendered = str(nested_value) + if artifact_backed and len(rendered) > 120: + rendered = rendered[:120] + "..." + kvs.append(f"{nested_key}: {rendered}") lines.append(f" - {', '.join(kvs)}") else: - compact = json.dumps(item, separators=(",", ":")) - rendered = compact[:2000] + "..." if len(compact) > 2000 else compact + rendered = json.dumps(item, ensure_ascii=False, separators=(",", ":")) + if artifact_backed and len(rendered) > 2000: + rendered = rendered[:2000] + "..." lines.append(f" - {rendered}") - if len(val) > 20: + if artifact_backed and len(val) > 20: lines.append(f" ... and {len(val) - 20} more") elif isinstance(val, dict): if not val: @@ -112,8 +136,11 @@ def _fmt_generic(short_name: str, data: dict, _pipeline: bool) -> str: lines.append(f"{key}:") for nested_key, nested_value in val.items(): if isinstance(nested_value, (dict, list)): - compact = json.dumps(nested_value, separators=(",", ":")) - rendered = compact[:2000] + "..." if len(compact) > 2000 else compact + rendered = json.dumps( + nested_value, ensure_ascii=False, separators=(",", ":") + ) + if artifact_backed and len(rendered) > 2000: + rendered = rendered[:2000] + "..." lines.append(f" {nested_key}: {rendered}") else: lines.append(f" {nested_key}: {nested_value}") diff --git a/src/autoskillit/hooks/formatters/_fmt_recipe.py b/src/autoskillit/hooks/formatters/_fmt_recipe.py index c3a718c49f..85c7b6924a 100644 --- a/src/autoskillit/hooks/formatters/_fmt_recipe.py +++ b/src/autoskillit/hooks/formatters/_fmt_recipe.py @@ -14,6 +14,7 @@ from _fmt_primitives import ( # type: ignore[import-not-found] _CHECK_MARK, _CROSS_MARK, + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, _WARN_MARK, ) from _fmt_recipe_compact import ( # type: ignore[import-not-found] @@ -25,12 +26,6 @@ from autoskillit.recipe import ListRecipesResult, LoadRecipeResult, OpenKitchenResult -# Regression budget for the rendered open_kitchen payload — see _fmt_open_kitchen. -# Empirically below the ~100KB Claude Code CLI disk-persistence gate (measured on -# CLI 2.1.197); re-measure after CLI upgrades rather than treating this as a -# protocol constant. See issue #4253. -_OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000 - # Field coverage contract for _fmt_load_recipe ↔ LoadRecipeResult _FMT_LOAD_RECIPE_RENDERED: frozenset[str] = frozenset( { @@ -243,11 +238,12 @@ def _fmt_open_kitchen(data: OpenKitchenResult, pipeline: bool) -> str: formatted = "\n".join(lines) byte_len = len(formatted.encode("utf-8")) - if byte_len > _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES: + budget = _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"]["max_utf8_bytes"] + if byte_len > budget: content_hash = data.get("content_hash", "") print( f"open_kitchen payload over budget: content_hash={content_hash} " - f"bytes={byte_len} budget={_OPEN_KITCHEN_OUTPUT_BUDGET_BYTES}", + f"bytes={byte_len} budget={budget}", file=sys.stderr, ) return formatted diff --git a/src/autoskillit/hooks/formatters/_fmt_response_spill.py b/src/autoskillit/hooks/formatters/_fmt_response_spill.py new file mode 100644 index 0000000000..6d5d55338a --- /dev/null +++ b/src/autoskillit/hooks/formatters/_fmt_response_spill.py @@ -0,0 +1,115 @@ +"""Artifact trust contract for the standalone pretty-output hook.""" + +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Any + +# Keep in sync with the stdlib-only path constants in _hook_settings.py. +_HOOK_CONFIG_PATH_COMPONENTS = (".autoskillit", "temp", ".hook_config.json") +_RESPONSE_SPILL_METADATA_KEY = "_autoskillit_response_spill" +_RESPONSE_SPILL_SCHEMA_VERSION = 1 +_RESPONSE_SPILL_METADATA_KEYS = frozenset( + { + "schema_version", + "artifact_path", + "sha256", + "original_utf8_bytes", + "projected_utf8_bytes", + "omitted_chars", + "omitted_items", + "reason", + } +) +_RESPONSE_SPILL_REASONS = frozenset({"oversized_values", "minimal_projection", "plain_text"}) +_RESPONSE_SPILL_SCHEMA_DIGEST = hashlib.sha256( + json.dumps( + { + "metadata_key": _RESPONSE_SPILL_METADATA_KEY, + "metadata_keys": sorted(_RESPONSE_SPILL_METADATA_KEYS), + "reasons": sorted(_RESPONSE_SPILL_REASONS), + "schema_version": _RESPONSE_SPILL_SCHEMA_VERSION, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() +).hexdigest() +_RESPONSE_SPILL_NUMERIC_KEYS = ( + "original_utf8_bytes", + "projected_utf8_bytes", + "omitted_chars", + "omitted_items", +) +_RESPONSE_BACKSTOP_EXEMPTION_REGISTRY = { + "load_recipe": { + "max_chars": 179_000, + "max_utf8_bytes": 179_000, + "measurement_id": "bundled-recipes-all-modes-2026-07-15/load-recipe", + }, + "open_kitchen": { + "max_chars": 180_000, + "max_utf8_bytes": 180_000, + "measurement_id": "bundled-recipes-all-modes-2026-07-15/open-kitchen", + }, +} +_RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST = hashlib.sha256( + json.dumps( + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("ascii") +).hexdigest() + + +def _response_temp_root() -> Path: + default = Path.cwd().joinpath(".autoskillit", "temp").resolve() + try: + config = json.loads(Path.cwd().joinpath(*_HOOK_CONFIG_PATH_COMPONENTS).read_text()) + configured = config.get("response_temp_root") if isinstance(config, dict) else None + if isinstance(configured, str) and Path(configured).is_absolute(): + return Path(configured).resolve(strict=True) + except (OSError, RuntimeError, ValueError, json.JSONDecodeError): + pass + return default + + +def _validate_response_spill_metadata(value: object) -> dict[str, Any] | None: + """Return trusted spill metadata only after verifying its published artifact.""" + if not isinstance(value, dict) or set(value) != _RESPONSE_SPILL_METADATA_KEYS: + return None + if type(value["schema_version"]) is not int: + return None + if value["schema_version"] != _RESPONSE_SPILL_SCHEMA_VERSION: + return None + if any(type(value[key]) is not int or value[key] < 0 for key in _RESPONSE_SPILL_NUMERIC_KEYS): + return None + digest = value["sha256"] + if not isinstance(digest, str) or re.fullmatch(r"[0-9a-f]{64}", digest) is None: + return None + reason = value["reason"] + raw_path = value["artifact_path"] + if not isinstance(reason, str) or reason not in _RESPONSE_SPILL_REASONS: + return None + if not isinstance(raw_path, str): + return None + artifact = Path(raw_path) + try: + project_temp = _response_temp_root() + if not artifact.is_absolute() or artifact.is_symlink() or not artifact.is_file(): + return None + resolved = artifact.resolve(strict=True) + if not resolved.is_relative_to(project_temp): + return None + if resolved.stat().st_size != value["original_utf8_bytes"]: + return None + hasher = hashlib.sha256() + with resolved.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + hasher.update(chunk) + except (OSError, RuntimeError, ValueError): + return None + return value if hasher.hexdigest() == digest else None diff --git a/src/autoskillit/hooks/formatters/pretty_output_hook.py b/src/autoskillit/hooks/formatters/pretty_output_hook.py index 9d7d6c639c..54f0ba39af 100644 --- a/src/autoskillit/hooks/formatters/pretty_output_hook.py +++ b/src/autoskillit/hooks/formatters/pretty_output_hook.py @@ -10,6 +10,7 @@ * ``_fmt_primitives`` — payload dataclasses, token formatter, pipeline-mode detector, and short-name extractor. + * ``_fmt_response_spill`` — artifact metadata trust and integrity validation. * ``_fmt_execution`` — ``run_skill``, ``run_cmd``, ``test_check``, ``merge_worktree``. * ``_fmt_status`` — ``get_token_summary``, ``get_timing_summary``, @@ -62,12 +63,20 @@ _CHECK_MARK, _CROSS_MARK, _HOOK_CONFIG_PATH_COMPONENTS, + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + _RESPONSE_SPILL_METADATA_KEY, + _RESPONSE_SPILL_METADATA_KEYS, + _RESPONSE_SPILL_REASONS, + _RESPONSE_SPILL_SCHEMA_DIGEST, + _RESPONSE_SPILL_SCHEMA_VERSION, _DictPayload, _extract_tool_short_name, _fmt_generic, _is_pipeline_mode, _Payload, _PlainTextPayload, + _validate_response_spill_metadata, ) from _fmt_recipe import ( # type: ignore[import-not-found] # noqa: E402, F401 _FMT_LIST_RECIPES_RENDERED, @@ -79,7 +88,6 @@ _FMT_RECIPE_LIST_ITEM_RENDERED, _FMT_RECIPE_LIST_ITEM_SUPPRESSED, _LOAD_RECIPE_CONTENT_DERIVED_FROM, - _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, _fmt_list_recipes, _fmt_load_recipe, _fmt_open_kitchen, @@ -119,9 +127,6 @@ def _fmt_gate_error(data: dict, _pipeline: bool) -> str: return "\n".join(lines) -_RESPONSE_SPILL_METADATA_KEY = "_autoskillit_response_spill" - - def _response_spill_notice(metadata: dict) -> str: path = metadata.get("artifact_path", "unavailable") original_bytes = metadata.get("original_utf8_bytes", "unknown") @@ -262,7 +267,13 @@ def _format_response(tool_name: str, tool_response: str, pipeline: bool) -> str # DictPayload path — envelope was successfully unwrapped (or was never an envelope). data = dict(payload.data) - spill_metadata = data.pop(_RESPONSE_SPILL_METADATA_KEY, None) + raw_spill_metadata = data.get(_RESPONSE_SPILL_METADATA_KEY) + spill_metadata = _validate_response_spill_metadata(raw_spill_metadata) + artifact_backed = spill_metadata is not None + if artifact_backed: + data.pop(_RESPONSE_SPILL_METADATA_KEY) + elif _RESPONSE_SPILL_METADATA_KEY in data: + return _fmt_generic(short_name, data, pipeline, artifact_backed=False) def with_spill(formatted: str) -> str: if not isinstance(spill_metadata, dict): @@ -279,13 +290,15 @@ def with_spill(formatted: str) -> str: return with_spill(_fmt_tool_exception(data, pipeline)) if short_name in _UNFORMATTED_TOOLS: - return with_spill(_fmt_generic(short_name, data, pipeline)) + return with_spill( + _fmt_generic(short_name, data, pipeline, artifact_backed=artifact_backed) + ) formatter = _FORMATTERS.get(short_name) if formatter is not None: return with_spill(formatter(data, pipeline)) - return with_spill(_fmt_generic(short_name, data, pipeline)) + return with_spill(_fmt_generic(short_name, data, pipeline, artifact_backed=artifact_backed)) def main() -> None: diff --git a/src/autoskillit/server/AGENTS.md b/src/autoskillit/server/AGENTS.md index 302ca8f354..05e89d6d7e 100644 --- a/src/autoskillit/server/AGENTS.md +++ b/src/autoskillit/server/AGENTS.md @@ -14,7 +14,8 @@ Sub-package: tools/ (see tools/AGENTS.md). | `_lifespan.py` | FastMCP lifespan context manager — deferred startup (recovery, audit loading, stale cleanup, drift check) | | `_misc.py` | Quota, hook-config, triage, and miscellaneous server utilities; re-exports selected execution/workspace symbols for tools | | `_notify.py` | MCP notification dispatch and response-size tracking | -| `_response_budget.py` | Lossless response spill and shape-preserving output-budget enforcement | +| `_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 | | `_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/__init__.py b/src/autoskillit/server/__init__.py index 608377db9f..a862e2365f 100644 --- a/src/autoskillit/server/__init__.py +++ b/src/autoskillit/server/__init__.py @@ -62,6 +62,7 @@ "_compute_effective_backend_map", # Wire-format compatibility middleware "ClaudeCodeCompatMiddleware", + "ResponseConformanceMiddleware", # Session-type visibility dispatcher (callable by tests) "_apply_session_type_visibility", ] @@ -158,4 +159,10 @@ mcp.add_middleware(ClaudeCodeCompatMiddleware()) +from autoskillit.server._response_conformance import ( # noqa: E402 + ResponseConformanceMiddleware, +) + +mcp.add_middleware(ResponseConformanceMiddleware(mcp)) + _apply_session_type_visibility() diff --git a/src/autoskillit/server/_notify.py b/src/autoskillit/server/_notify.py index 436682be42..973e738a57 100644 --- a/src/autoskillit/server/_notify.py +++ b/src/autoskillit/server/_notify.py @@ -13,7 +13,10 @@ from autoskillit.config import OutputBudgetConfig from autoskillit.core import RESERVED_LOG_RECORD_KEYS, get_logger -from autoskillit.server._response_budget import enforce_response_budget +from autoskillit.server._response_budget import ( + bounded_response_budget_failure, + enforce_response_budget, +) if TYPE_CHECKING: from fastmcp import Context @@ -99,7 +102,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: ), } ) - logger.exception("Unhandled exception in tool %s", tool_name) + logger.error("track_response_size_handler_failed", tool_name=tool_name) ctx = _get_ctx_or_none() try: response_str = result if isinstance(result, str) else json.dumps(result) @@ -107,12 +110,11 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: logger.warning( "track_response_size_nonserializable_result", tool_name=tool_name, - result_type=type(result).__name__, ) - return result + response_str = None exceeded = False try: - if ctx is not None: + if ctx is not None and response_str is not None: threshold = ctx.config.mcp_response.alert_threshold_tokens exceeded = ctx.response_log.record( tool_name, response_str, alert_threshold_tokens=threshold @@ -121,7 +123,6 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: logger.warning( "track_response_size_telemetry_failed", tool_name=tool_name, - exc_info=True, ) configured_budget = ( @@ -144,14 +145,15 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: config=budget, ) except Exception: - logger.exception("track_response_size_enforcement_failed", tool_name=tool_name) - result = json.dumps( - { - "success": False, - "error": "response_budget_enforcement_failed", - "tool_name": tool_name, - }, - separators=(",", ":"), + logger.error("track_response_size_enforcement_failed", tool_name=tool_name) + result = bounded_response_budget_failure( + result, + cause="internal_invariant_failed", + tool_name=tool_name, + max_bytes=budget.response_max_bytes, + original_utf8_bytes=( + len(response_str.encode("utf-8")) if response_str is not None else 0 + ), ) if exceeded: @@ -177,7 +179,6 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: logger.warning( "track_response_size_notification_failed", tool_name=tool_name, - exc_info=True, ) return result diff --git a/src/autoskillit/server/_response_budget.py b/src/autoskillit/server/_response_budget.py index c9f8836fb1..5c907d12f0 100644 --- a/src/autoskillit/server/_response_budget.py +++ b/src/autoskillit/server/_response_budget.py @@ -5,10 +5,15 @@ import hashlib import json import uuid +from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING, Any -from autoskillit.core import atomic_write, get_logger +from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + atomic_write, + get_logger, +) if TYPE_CHECKING: from autoskillit.config import OutputBudgetConfig @@ -16,13 +21,78 @@ logger = get_logger(__name__) RESPONSE_SPILL_METADATA_KEY = "_autoskillit_response_spill" -RESPONSE_BACKSTOP_EXEMPT_TOOLS = frozenset({"open_kitchen", "load_recipe"}) +RESPONSE_SPILL_SCHEMA_VERSION = 1 +RESPONSE_SPILL_METADATA_KEYS = frozenset( + { + "schema_version", + "artifact_path", + "sha256", + "original_utf8_bytes", + "projected_utf8_bytes", + "omitted_chars", + "omitted_items", + "reason", + } +) +RESPONSE_SPILL_REASONS = frozenset({"oversized_values", "minimal_projection", "plain_text"}) +RESPONSE_SPILL_SCHEMA_DIGEST = hashlib.sha256( + json.dumps( + { + "metadata_key": RESPONSE_SPILL_METADATA_KEY, + "metadata_keys": sorted(RESPONSE_SPILL_METADATA_KEYS), + "reasons": sorted(RESPONSE_SPILL_REASONS), + "schema_version": RESPONSE_SPILL_SCHEMA_VERSION, + }, + sort_keys=True, + separators=(",", ":"), + ).encode() +).hexdigest() + +RESPONSE_BUDGET_FAILURE_CAUSES = frozenset( + { + "context_unavailable", + "artifact_publication_failed", + "serialization_failed", + "projection_nonconvergent", + "irreducible_shape", + "schema_nonconforming", + "internal_invariant_failed", + } +) + + +class _ProjectionNonconvergentError(RuntimeError): + pass + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) def _serialized(value: Any) -> str: if isinstance(value, str): return value - return json.dumps(value, ensure_ascii=False, separators=(",", ":"), default=str) + return _canonical_json(value) + + +def _bounded_tool_name(tool_name: str) -> str: + return tool_name.encode("ascii", "replace").decode("ascii")[:64] + + +def _emit_response_budget_event(event: str, **payload: Any) -> None: + with suppress(Exception): + logger.info(event, **payload) + + +def emit_response_budget_failure(tool_name: str, cause: str, original_utf8_bytes: int) -> None: + if cause not in RESPONSE_BUDGET_FAILURE_CAUSES: + cause = "internal_invariant_failed" + _emit_response_budget_event( + "response_budget_failure", + tool_name=_bounded_tool_name(tool_name), + cause=cause, + original_utf8_bytes=original_utf8_bytes, + ) def _preview_string(value: str, limit: int) -> tuple[str, int]: @@ -101,10 +171,40 @@ def _bounded_failure( } if artifact_path is not None: payload["artifact_path"] = artifact_path - rendered = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) - if len(rendered.encode("utf-8")) <= max_bytes: + candidates = ( + _canonical_json(payload), + '{"success":false,"error":"response_budget_failure"}', + '{"success":false}', + "{}", + "", + ) + return next( + candidate for candidate in candidates if len(candidate.encode("utf-8")) <= max_bytes + ) + + +def bounded_response_budget_failure( + result: Any, + *, + cause: str, + tool_name: str, + max_bytes: int, + original_utf8_bytes: int, + artifact_path: str | None = None, +) -> Any: + emit_response_budget_failure(tool_name, cause, original_utf8_bytes) + rendered = _bounded_failure( + reason=f"response_budget_{cause}", + tool_name=_bounded_tool_name(tool_name), + max_bytes=max_bytes, + artifact_path=artifact_path, + ) + if isinstance(result, str): return rendered - return '{"success":false,"error":"response_budget_failure"}' + try: + return json.loads(rendered) + except ValueError: + return {"success": False, "error": "response_budget_failure"} def _artifact_path(artifact_dir: Path, tool_name: str) -> Path: @@ -112,6 +212,55 @@ def _artifact_path(artifact_dir: Path, tool_name: str) -> Path: return artifact_dir / f"{safe_name or 'response'}_{uuid.uuid4().hex[:8]}.log" +def _finalize_envelope(envelope: dict[str, Any], *, max_bytes: int) -> str: + metadata = envelope.get(RESPONSE_SPILL_METADATA_KEY) + if not isinstance(metadata, dict) or "projected_utf8_bytes" not in metadata: + raise _ProjectionNonconvergentError("spill metadata missing projected byte field") + max_decimal_width = len(str(max(0, max_bytes))) + seen: set[tuple[int, int]] = set() + for _ in range(max_decimal_width + 2): + rendered = _canonical_json(envelope) + measured = len(rendered.encode("utf-8")) + current = metadata.get("projected_utf8_bytes") + if current == measured: + return rendered + state = (current if isinstance(current, int) else -1, measured) + if state in seen: + break + seen.add(state) + metadata["projected_utf8_bytes"] = measured + raise _ProjectionNonconvergentError("projected byte fixed point did not converge") + + +def _spill_metadata( + metadata: dict[str, Any], + *, + reason: str, + omitted_chars: int, + omitted_items: int, +) -> dict[str, Any]: + return { + "schema_version": RESPONSE_SPILL_SCHEMA_VERSION, + **metadata, + "projected_utf8_bytes": 0, + "omitted_chars": omitted_chars, + "omitted_items": omitted_items, + "reason": reason, + } + + +def _total_omissions(value: Any) -> tuple[int, int]: + if isinstance(value, str): + return len(value), 0 + if isinstance(value, list): + nested = [_total_omissions(item) for item in value] + return sum(chars for chars, _ in nested), len(value) + sum(items for _, items in nested) + if isinstance(value, dict): + nested = [_total_omissions(item) for item in value.values()] + return sum(chars for chars, _ in nested), len(value) + sum(items for _, items in nested) + return 0, 0 + + def _project_json_object( parsed: dict[str, Any], *, @@ -134,30 +283,32 @@ def _project_json_object( omitted_chars += chars omitted_items += items projected[RESPONSE_SPILL_METADATA_KEY] = { - **metadata, - "omitted_chars": omitted_chars, - "omitted_items": omitted_items, - "reason": "oversized_values", + **_spill_metadata( + metadata, + reason="oversized_values", + omitted_chars=omitted_chars, + omitted_items=omitted_items, + ) } - rendered = json.dumps(projected, ensure_ascii=False, separators=(",", ":")) - projected[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len( - rendered.encode("utf-8") - ) - rendered = json.dumps(projected, ensure_ascii=False, separators=(",", ":")) + rendered = _finalize_envelope(projected, max_bytes=max_bytes) if len(rendered.encode("utf-8")) <= max_bytes: return rendered value_limit //= 2 minimal = {key: _minimal_same_type(value) for key, value in parsed.items()} - minimal[RESPONSE_SPILL_METADATA_KEY] = { - **metadata, - "omitted_chars": 0, - "omitted_items": 0, - "reason": "minimal_projection", - } - rendered = json.dumps(minimal, ensure_ascii=False, separators=(",", ":")) - minimal[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len(rendered.encode("utf-8")) - rendered = json.dumps(minimal, ensure_ascii=False, separators=(",", ":")) + omitted_chars = 0 + omitted_items = 0 + for value in parsed.values(): + chars, items = _total_omissions(value) + omitted_chars += chars + omitted_items += items + minimal[RESPONSE_SPILL_METADATA_KEY] = _spill_metadata( + metadata, + reason="minimal_projection", + omitted_chars=omitted_chars, + omitted_items=omitted_items, + ) + rendered = _finalize_envelope(minimal, max_bytes=max_bytes) return rendered if len(rendered.encode("utf-8")) <= max_bytes else None @@ -167,24 +318,25 @@ def _plain_spill_envelope( metadata: dict[str, Any], max_bytes: int, inline_chars: int, -) -> str: - preview, _ = _preview_string(original, min(inline_chars, max_bytes // 3)) - envelope: dict[str, Any] = { - RESPONSE_SPILL_METADATA_KEY: { - **metadata, - "projected_utf8_bytes": 0, - "reason": "plain_text", - }, - "preview": preview, - } - rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) - envelope[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] = len(rendered.encode("utf-8")) - rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) - while len(rendered.encode("utf-8")) > max_bytes and preview: - preview = preview[: len(preview) // 2] - envelope["preview"] = preview - rendered = json.dumps(envelope, ensure_ascii=False, separators=(",", ":")) - return rendered +) -> str | None: + preview_limit = min(inline_chars, max_bytes // 3) + while True: + preview, omitted_chars = _preview_string(original, preview_limit) + envelope: dict[str, Any] = { + RESPONSE_SPILL_METADATA_KEY: _spill_metadata( + metadata, + reason="plain_text", + omitted_chars=omitted_chars, + omitted_items=0, + ), + "preview": preview, + } + rendered = _finalize_envelope(envelope, max_bytes=max_bytes) + if len(rendered.encode("utf-8")) <= max_bytes: + return rendered + if preview_limit == 0: + return None + preview_limit //= 2 def enforce_response_budget( @@ -201,32 +353,60 @@ def enforce_response_budget( Artifact failure and missing-context cases fail closed without echoing the original payload. """ - if tool_name in RESPONSE_BACKSTOP_EXEMPT_TOOLS: - return result - - original = _serialized(result) + try: + original = _serialized(result) + except (TypeError, ValueError, OverflowError): + return bounded_response_budget_failure( + result, + cause="serialization_failed", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + original_utf8_bytes=0, + ) original_bytes = original.encode("utf-8") + original_size = len(original_bytes) + exemption = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.get(tool_name) + if exemption is not None: + if len(original) <= exemption.max_chars and original_size <= exemption.max_utf8_bytes: + _emit_response_budget_event( + "response_budget_exemption", + tool_name=_bounded_tool_name(tool_name), + measurement_id=exemption.measurement_id, + original_chars=len(original), + original_utf8_bytes=original_size, + max_chars=exemption.max_chars, + max_utf8_bytes=exemption.max_utf8_bytes, + ) + return result + return bounded_response_budget_failure( + result, + cause="internal_invariant_failed", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + original_utf8_bytes=original_size, + ) if not force_spill and len(original_bytes) <= config.response_max_bytes: return result if artifact_dir is None: - failure = _bounded_failure( - reason="response_budget_context_unavailable", + return bounded_response_budget_failure( + result, + cause="context_unavailable", tool_name=tool_name, max_bytes=config.response_max_bytes, + original_utf8_bytes=original_size, ) - return failure if isinstance(result, str) else {"success": False, "error": failure} path = _artifact_path(artifact_dir, tool_name) try: atomic_write(path, original) - except Exception: - logger.error("response_budget_artifact_write_failed", tool_name=tool_name, exc_info=True) - failure = _bounded_failure( - reason="response_budget_artifact_write_failed", + except OSError: + return bounded_response_budget_failure( + result, + cause="artifact_publication_failed", tool_name=tool_name, max_bytes=config.response_max_bytes, + original_utf8_bytes=original_size, ) - return failure if isinstance(result, str) else {"success": False, "error": failure} published = str(path.resolve()) metadata = { @@ -245,33 +425,45 @@ def enforce_response_budget( parsed = result rendered: str | None = None - if isinstance(parsed, dict): - rendered = _project_json_object( - parsed, - metadata=metadata, - max_bytes=config.response_max_bytes, - inline_chars=config.inline_max_chars, - ) - if rendered is None and not isinstance(parsed, dict): - rendered = _plain_spill_envelope( - original, - metadata=metadata, + try: + if isinstance(parsed, dict): + rendered = _project_json_object( + parsed, + metadata=metadata, + max_bytes=config.response_max_bytes, + inline_chars=config.inline_max_chars, + ) + if rendered is None and not isinstance(parsed, dict): + rendered = _plain_spill_envelope( + original, + metadata=metadata, + max_bytes=config.response_max_bytes, + inline_chars=config.inline_max_chars, + ) + except _ProjectionNonconvergentError: + rendered = bounded_response_budget_failure( + "", + cause="projection_nonconvergent", + tool_name=tool_name, max_bytes=config.response_max_bytes, - inline_chars=config.inline_max_chars, + original_utf8_bytes=original_size, + artifact_path=published, ) if rendered is None: - rendered = _bounded_failure( - reason="response_budget_irreducible_shape", + rendered = bounded_response_budget_failure( + "", + cause="irreducible_shape", tool_name=tool_name, max_bytes=config.response_max_bytes, + original_utf8_bytes=original_size, artifact_path=published, ) - logger.info( + _emit_response_budget_event( "response_budget_spill", - tool_name=tool_name, - original_utf8_bytes=len(original_bytes), - artifact_utf8_bytes=len(original_bytes), + tool_name=_bounded_tool_name(tool_name), + original_utf8_bytes=original_size, + projected_utf8_bytes=len(rendered.encode("utf-8")), ) if isinstance(result, str): return rendered @@ -303,8 +495,14 @@ def shape_json_response( __all__ = [ - "RESPONSE_BACKSTOP_EXEMPT_TOOLS", + "RESPONSE_BUDGET_FAILURE_CAUSES", "RESPONSE_SPILL_METADATA_KEY", + "RESPONSE_SPILL_METADATA_KEYS", + "RESPONSE_SPILL_REASONS", + "RESPONSE_SPILL_SCHEMA_DIGEST", + "RESPONSE_SPILL_SCHEMA_VERSION", + "bounded_response_budget_failure", + "emit_response_budget_failure", "enforce_response_budget", "shape_json_response", ] diff --git a/src/autoskillit/server/_response_conformance.py b/src/autoskillit/server/_response_conformance.py new file mode 100644 index 0000000000..277e0cb5f3 --- /dev/null +++ b/src/autoskillit/server/_response_conformance.py @@ -0,0 +1,154 @@ +"""Post-conversion conformance checks for registered string tools.""" + +from __future__ import annotations + +import typing +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from fastmcp import FastMCP +from fastmcp.server.middleware import Middleware +from fastmcp.tools.base import ToolResult +from fastmcp.tools.function_tool import FunctionTool +from jsonschema.exceptions import SchemaError, ValidationError +from jsonschema.validators import validator_for +from mcp.types import TextContent + +from autoskillit.server._response_budget import emit_response_budget_failure + +if TYPE_CHECKING: + import mcp.types as mt + from fastmcp.server.middleware import CallNext, MiddlewareContext + + +_WRAPPED_STRING_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"result": {"type": "string"}}, + "required": ["result"], + "x-fastmcp-wrap-result": True, +} + +_SCHEMA_NONCONFORMING_FAILURE = ( + '{"success":false,"error":"response_budget_failure","cause":"schema_nonconforming"}' +) + + +class ResponseConformanceDecision(StrEnum): + """Exhaustive outcomes for the registered-handler-converted decision table.""" + + BYPASS_NON_FUNCTION_TOOL = "bypass_non_function_tool" + BYPASS_HANDLER_TYPE = "bypass_handler_type" + BYPASS_REGISTERED_SCHEMA = "bypass_registered_schema" + CONFORMING = "conforming" + REWRITE_NONCONFORMING = "rewrite_nonconforming" + + +def decide_response_conformance( + *, + is_function_tool: bool, + handler_returns_exact_str: bool, + registered_schema_is_wrapped_string: bool, + converted_result_conforms: bool, +) -> ResponseConformanceDecision: + """Return the enforcement action for the four response representations.""" + if not is_function_tool: + return ResponseConformanceDecision.BYPASS_NON_FUNCTION_TOOL + if not handler_returns_exact_str: + return ResponseConformanceDecision.BYPASS_HANDLER_TYPE + if not registered_schema_is_wrapped_string: + return ResponseConformanceDecision.BYPASS_REGISTERED_SCHEMA + if converted_result_conforms: + return ResponseConformanceDecision.CONFORMING + return ResponseConformanceDecision.REWRITE_NONCONFORMING + + +def _handler_returns_exact_str(tool: FunctionTool) -> bool: + try: + return typing.get_type_hints(tool.fn).get("return") is str + except (NameError, TypeError): + return False + + +def _is_wrapped_string_schema(schema: dict[str, Any] | None) -> bool: + return schema == _WRAPPED_STRING_OUTPUT_SCHEMA + + +def _converted_result_conforms(result: Any, schema: dict[str, Any]) -> bool: + if not isinstance(result, ToolResult) or len(result.content) != 1: + return False + block = result.content[0] + if not isinstance(block, TextContent): + return False + structured = result.structured_content + if not isinstance(structured, dict) or structured.get("result") != block.text: + return False + try: + validator_class = validator_for(schema) + validator_class.check_schema(schema) + validator_class(schema).validate(structured) + except (SchemaError, ValidationError): + return False + return True + + +def _converted_text_utf8_bytes(result: Any) -> int: + if not isinstance(result, ToolResult): + return 0 + return sum( + len(block.text.encode("utf-8")) + for block in result.content + if isinstance(block, TextContent) + ) + + +class ResponseConformanceMiddleware(Middleware): + """Fail closed when FastMCP converts a registered string tool inconsistently.""" + + def __init__(self, mcp: FastMCP) -> None: + self._mcp = mcp + + async def on_call_tool( + self, + context: MiddlewareContext[mt.CallToolRequestParams], + call_next: CallNext[mt.CallToolRequestParams, ToolResult], + ) -> ToolResult: + registered_tool = await self._mcp.get_tool(context.message.name) + result = await call_next(context) + + if isinstance(registered_tool, FunctionTool): + is_function_tool = True + registered_schema = registered_tool.output_schema + handler_returns_exact_str = _handler_returns_exact_str(registered_tool) + registered_schema_is_wrapped_string = _is_wrapped_string_schema(registered_schema) + converted_result_conforms = ( + registered_schema_is_wrapped_string + and isinstance(registered_schema, dict) + and _converted_result_conforms(result, registered_schema) + ) + else: + is_function_tool = False + handler_returns_exact_str = False + registered_schema_is_wrapped_string = False + converted_result_conforms = False + decision = decide_response_conformance( + is_function_tool=is_function_tool, + handler_returns_exact_str=handler_returns_exact_str, + registered_schema_is_wrapped_string=registered_schema_is_wrapped_string, + converted_result_conforms=converted_result_conforms, + ) + if decision is ResponseConformanceDecision.REWRITE_NONCONFORMING: + assert isinstance(registered_tool, FunctionTool) + emit_response_budget_failure( + context.message.name, + "schema_nonconforming", + _converted_text_utf8_bytes(result), + ) + return registered_tool.convert_result(_SCHEMA_NONCONFORMING_FAILURE) + return result + + +__all__ = [ + "ResponseConformanceDecision", + "ResponseConformanceMiddleware", + "decide_response_conformance", +] diff --git a/src/autoskillit/server/tools/_execution_helpers.py b/src/autoskillit/server/tools/_execution_helpers.py index fcf591e3f9..b1d36fd6f6 100644 --- a/src/autoskillit/server/tools/_execution_helpers.py +++ b/src/autoskillit/server/tools/_execution_helpers.py @@ -60,7 +60,7 @@ def spill_run_cmd_result( stderr: str, ) -> dict[str, object]: artifact_root = ( - resolve_temp_dir(Path(cwd), tool_ctx.config.workspace.temp_dir) / "run_cmd" + resolve_temp_dir(Path(cwd).resolve(), tool_ctx.config.workspace.temp_dir) / "run_cmd" if cwd and Path(cwd).is_absolute() else tool_ctx.temp_dir / "run_cmd" ) @@ -88,7 +88,7 @@ def shape_execution_response( work_dir: str, ) -> str: artifact_root = ( - resolve_temp_dir(Path(work_dir), tool_ctx.config.workspace.temp_dir) / tool_name + resolve_temp_dir(Path(work_dir).resolve(), tool_ctx.config.workspace.temp_dir) / tool_name if work_dir and Path(work_dir).is_absolute() else tool_ctx.temp_dir / tool_name ) diff --git a/src/autoskillit/server/tools/_serve_helpers.py b/src/autoskillit/server/tools/_serve_helpers.py index a0d2e9b4c4..e8438a5d03 100644 --- a/src/autoskillit/server/tools/_serve_helpers.py +++ b/src/autoskillit/server/tools/_serve_helpers.py @@ -9,9 +9,15 @@ from __future__ import annotations +import json from pathlib import Path from typing import TYPE_CHECKING, Any +from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, +) + if TYPE_CHECKING: from autoskillit.core import BackendCapabilities, CodingAgentBackend from autoskillit.pipeline.context import ToolContext @@ -45,6 +51,38 @@ def build_backend_capabilities_map( return out +def response_backstop_tool_meta( + tool_name: str, *, always_load: bool = False +) -> dict[str, bool | int | str]: + """Build transport metadata from the measured exemption authority.""" + definition = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY[tool_name] + metadata: dict[str, bool | int | str] = { + "anthropic/maxResultSizeChars": definition.max_chars, + "autoskillit/responseBackstopMeasurement": definition.measurement_id, + "autoskillit/responseBackstopMaxUtf8Bytes": definition.max_utf8_bytes, + "autoskillit/responseBackstopRegistryDigest": ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + ), + } + if always_load: + metadata["anthropic/alwaysLoad"] = True + return metadata + + +def render_served_response(payload: dict[str, Any]) -> str: + """Render the authoritative pre-backstop response used by recipe serve tools.""" + return json.dumps(payload) + + +def build_open_kitchen_recipe_payload(result: dict[str, Any], *, version: str) -> dict[str, Any]: + """Add the routing fields shared by every recipe-bearing open-kitchen response.""" + payload = dict(result) + payload.update(success=True, kitchen="open", version=version) + if not payload.get("ingredients_table"): + payload["ingredients_table"] = None + return payload + + def _build_serve_override_stack( ctx: ToolContext, caller_overrides: dict[str, str] | None, diff --git a/src/autoskillit/server/tools/tools_kitchen.py b/src/autoskillit/server/tools/tools_kitchen.py index f500d90f31..e55915db2b 100644 --- a/src/autoskillit/server/tools/tools_kitchen.py +++ b/src/autoskillit/server/tools/tools_kitchen.py @@ -80,6 +80,9 @@ ) from autoskillit.server.tools._serve_helpers import ( build_backend_capabilities_map, + build_open_kitchen_recipe_payload, + render_served_response, + response_backstop_tool_meta, serve_recipe, ) from autoskillit.server.tools._types import _validate_result @@ -249,9 +252,15 @@ def _write_hook_config() -> None: from autoskillit.server import _get_ctx, logger # circular-break ctx = _get_ctx() + response_temp_root = ( + ctx.temp_dir + if isinstance(getattr(ctx, "temp_dir", None), Path) + else ctx.project_dir / ".autoskillit" / "temp" + ) payload = { "quota_guard": _quota_guard_hook_payload(ctx.config.quota_guard), "output_budget_policy": _output_budget_policy_hook_payload(ctx.config.output_budget), + "response_temp_root": str(response_temp_root.resolve()), "kitchen_id": ctx.kitchen_id, "git_ops_policy": {}, } @@ -667,7 +676,7 @@ def _check_override_keys( @mcp.tool( tags={"autoskillit"}, annotations={"readOnlyHint": True}, - meta={"anthropic/maxResultSizeChars": 100_000, "anthropic/alwaysLoad": True}, + meta=response_backstop_tool_meta("open_kitchen", always_load=True), ) @_cancellation_shield() @track_response_size("open_kitchen") @@ -934,11 +943,7 @@ async def open_kitchen( tool_ctx.gate_infrastructure_ready = False await ctx.disable_components(tags={"kitchen"}) return _preflight_err - result["success"] = True - result["kitchen"] = "open" - result["version"] = __version__ - if "ingredients_table" not in result or not result["ingredients_table"]: - result["ingredients_table"] = None + result = build_open_kitchen_recipe_payload(result, version=__version__) try: result = await _apply_triage_gate(result, name, recipe_info=recipe_info) except Exception as exc: @@ -964,7 +969,7 @@ async def open_kitchen( if overrides is not None: tool_ctx.session_serve_overrides = dict(overrides) tool_ctx.session_serve_defer_unresolved = not bool(overrides) - return json.dumps(result) + return render_served_response(result) try: result = serve_recipe( tool_ctx, @@ -1074,16 +1079,11 @@ async def open_kitchen( tool_ctx.session_serve_overrides = dict(overrides) if overrides else {} tool_ctx.session_serve_defer_unresolved = not bool(overrides) - result["success"] = True - result["kitchen"] = "open" - result["version"] = __version__ + result = build_open_kitchen_recipe_payload(result, version=__version__) if ingredients_only: result = strip_ingredients_only_keys(result) - if "ingredients_table" not in result or not result["ingredients_table"]: - result["ingredients_table"] = None - if _normal_recipe_obj is not None: _override_warnings = _check_override_keys( overrides, @@ -1116,7 +1116,7 @@ async def open_kitchen( ) return _validation_err - return json.dumps(result) + return render_served_response(result) text = ( f"Kitchen is open. AutoSkillit {__version__}. Tools are ready for service.\n\n" @@ -1159,7 +1159,7 @@ async def open_kitchen( if warning: text += warning - return json.dumps( + return render_served_response( { "success": True, "kitchen": "open", diff --git a/src/autoskillit/server/tools/tools_recipe.py b/src/autoskillit/server/tools/tools_recipe.py index 2aa7be0f71..7e3ebaa7bb 100644 --- a/src/autoskillit/server/tools/tools_recipe.py +++ b/src/autoskillit/server/tools/tools_recipe.py @@ -35,6 +35,8 @@ from autoskillit.server.tools._cancellation_shield import _cancellation_shield from autoskillit.server.tools._serve_helpers import ( build_backend_capabilities_map, + render_served_response, + response_backstop_tool_meta, serve_recipe, ) from autoskillit.server.tools._types import _validate_result @@ -85,7 +87,7 @@ async def list_recipes() -> str: @mcp.tool( tags={"autoskillit", "kitchen-core", "fleet-dispatch"}, annotations={"readOnlyHint": True}, - meta={"anthropic/maxResultSizeChars": 100_000}, + meta=response_backstop_tool_meta("load_recipe"), ) @_cancellation_shield() @track_response_size("load_recipe") @@ -321,7 +323,7 @@ async def load_recipe( stage="validate_result", ) return _validation_err - return json.dumps(result) + return render_served_response(result) except Exception as exc: logger.error("load_recipe unhandled exception", exc_info=True) return json.dumps({"success": False, "error": f"{type(exc).__name__}: {exc}"}) diff --git a/tests/_test_filter.py b/tests/_test_filter.py index 04c0c8bd26..345ad51566 100644 --- a/tests/_test_filter.py +++ b/tests/_test_filter.py @@ -251,6 +251,7 @@ class ImportContext(enum.StrEnum): "cli", "config", "core", + "execution", "pipeline", "recipe", "server", diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 39b26b3539..706a297336 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -94,6 +94,10 @@ def _get_call_func_name(node: ast.Call) -> str | None: # Canonical output-discipline block/digest and their SHA-256 cache identity are # deliberately derived once at import time from the single source of truth. "_type_constants", + "_type_constants_registries", # measured response-exemption registry digest + "_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 # _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 @@ -1019,12 +1023,11 @@ def test_data_directories_are_not_python_packages() -> None: "; _auto_init_pipeline_tracker tool_ctx param typed as ToolContext under " "TYPE_CHECKING instead of Any, matching _active_order_ids_for_kitchen's " "established pattern (+1 net line)" -<<<<<<< HEAD "; serve_recipe backend_capabilities_map threading at get_recipe + deferred-recall + " "normal open_kitchen paths with safe _distinct_backends extraction from " "_effective_backend_map and tool_ctx.backend.name (+28 net lines)" "; output-budget hook payload type, serializer, and hook-config bridge " - "(+21 net lines)", + "(+21 net lines); response artifact temp-root bridge (+2 net lines)", ), "tools_execution.py": ( 1600, diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 5d2e64f2ff..990f8b2918 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,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) == 104, ( - f"Expected 104 symbols total, got {len(combined)} " + assert len(combined) == 106, ( + f"Expected 106 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/core/test_io_spill.py b/tests/core/test_io_spill.py index c809ab3db9..f7ff1eac14 100644 --- a/tests/core/test_io_spill.py +++ b/tests/core/test_io_spill.py @@ -47,13 +47,25 @@ def test_large_output_is_published_losslessly_with_metadata(tmp_path): assert artifact.stat().st_mode & 0o777 == 0o600 -def test_publication_failure_returns_no_path_or_partial_file(tmp_path, monkeypatch): +def test_write_permission_failure_returns_no_path_or_partial_file(tmp_path, monkeypatch): from autoskillit.core import io - def fail_write(*_args, **_kwargs): - raise OSError("ENOSPC") + def fail_create(*_args, **_kwargs): + raise PermissionError("permission denied") - monkeypatch.setattr(io, "atomic_write", fail_write) - with pytest.raises(OSError, match="ENOSPC"): + monkeypatch.setattr(io.tempfile, "mkstemp", fail_create) + with pytest.raises(PermissionError, match="permission denied"): + spill_output("x" * 100, tmp_path, "stdout", SpillSpec(inline_max_chars=10)) + assert list(tmp_path.iterdir()) == [] + + +def test_replace_failure_removes_temporary_and_final_files(tmp_path, monkeypatch): + from autoskillit.core import io + + def fail_replace(*_args, **_kwargs): + raise OSError("replace failed") + + monkeypatch.setattr(io.os, "replace", fail_replace) + with pytest.raises(OSError, match="replace failed"): spill_output("x" * 100, tmp_path, "stdout", SpillSpec(inline_max_chars=10)) assert list(tmp_path.iterdir()) == [] diff --git a/tests/core/test_type_constants.py b/tests/core/test_type_constants.py index 7c1969b3f9..a40a0fd46a 100644 --- a/tests/core/test_type_constants.py +++ b/tests/core/test_type_constants.py @@ -2,11 +2,79 @@ from __future__ import annotations +import hashlib +import json + import pytest pytestmark = [pytest.mark.layer("core"), pytest.mark.small] +def test_response_backstop_exemption_def_namedtuple_fields() -> None: + from autoskillit.core import ResponseBackstopExemptionDef + + definition = ResponseBackstopExemptionDef( + max_chars=1, + max_utf8_bytes=2, + measurement_id="measurement-v1", + ) + assert definition._fields == ("max_chars", "max_utf8_bytes", "measurement_id") + assert definition.max_chars == 1 + assert definition.max_utf8_bytes == 2 + assert definition.measurement_id == "measurement-v1" + + +def test_response_backstop_exemption_registry_is_closed_and_pinned() -> None: + from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + ResponseBackstopExemptionDef, + ) + + assert RESPONSE_BACKSTOP_EXEMPTION_REGISTRY == { + "load_recipe": ResponseBackstopExemptionDef( + max_chars=179_000, + max_utf8_bytes=179_000, + measurement_id="bundled-recipes-all-modes-2026-07-15/load-recipe", + ), + "open_kitchen": ResponseBackstopExemptionDef( + max_chars=180_000, + max_utf8_bytes=180_000, + measurement_id="bundled-recipes-all-modes-2026-07-15/open-kitchen", + ), + } + + +def test_response_backstop_exemption_registry_digest_is_canonical() -> None: + from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + ) + + canonical = { + tool_name: definition._asdict() + for tool_name, definition in sorted(RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.items()) + } + payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + assert hashlib.sha256(payload.encode("ascii")).hexdigest() == ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + ) + assert ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + == "2e44a2d65867ccac075ff1978f207bac5b25487e0c037254a62059a363bd8d60" + ) + + +def test_response_backstop_exemption_registry_public_gateways() -> None: + import autoskillit.core as core + import autoskillit.core.types as core_types + + for module in (core, core_types): + assert module.RESPONSE_BACKSTOP_EXEMPTION_REGISTRY + assert module.RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + assert module.ResponseBackstopExemptionDef + assert not hasattr(module, "OPEN_KITCHEN_OUTPUT_BUDGET_BYTES") + + # REQ-PACK-001: PACK_REGISTRY defines all packs with default_enabled def test_core_packs_constant_defined() -> None: """CORE_PACKS must be a frozenset defined in _type_constants and exported via core.""" diff --git a/tests/docs/test_output_budget_protocol_decision.py b/tests/docs/test_output_budget_protocol_decision.py index 49d08dd937..0e0d5c1f40 100644 --- a/tests/docs/test_output_budget_protocol_decision.py +++ b/tests/docs/test_output_budget_protocol_decision.py @@ -49,11 +49,14 @@ def test_decision_names_all_four_layers(decision_text: str, required: str) -> No @pytest.mark.parametrize( "required", [ - "OPEN_KITCHEN_OUTPUT_BUDGET_BYTES = 96_000", - "95,771 UTF-8 bytes", - "229-byte project margin", - "((96_000 + 3) // 4) + 8_000", - "CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 32_000", + "max_chars = 179_000", + "max_utf8_bytes = 179_000", + "178,601 characters and UTF-8 bytes", + "max_chars = 180_000", + "max_utf8_bytes = 180_000", + "178,660 characters and UTF-8 bytes", + "((180_000 + 3) // 4) + 8_000", + "CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 53_000", "CODEX_AUTO_COMPACT_LIMIT = 999_999_999", "inline_max_chars = 5_000", "response_max_bytes = 90_000", @@ -73,6 +76,10 @@ def test_decision_pins_ceiling_backstop_pair(decision_text: str) -> None: assert "open_kitchen" in decision_text assert "load_recipe" in decision_text assert "live large-output probe" in decision_text + assert "RESPONSE_BACKSTOP_EXEMPTION_REGISTRY" in decision_text + assert "canonical digest" in decision_text + assert "bundled-recipes-all-modes-2026-07-15/load-recipe" in decision_text + assert "bundled-recipes-all-modes-2026-07-15/open-kitchen" in decision_text def test_decision_records_both_corrections(decision_text: str) -> None: diff --git a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json index cfde161093..aae2277fa2 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json +++ b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json @@ -28,7 +28,7 @@ "tool_output_token_limit": { "constraint": "exact", "expected_type": "int", - "expected_value": 32000 + "expected_value": 53000 } } } diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index c034bf212b..070a87538f 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -25,7 +25,7 @@ ErrorKind, ) from autoskillit.config import OutputBudgetConfig -from autoskillit.core import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, pkg_root +from autoskillit.core import RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, pkg_root from autoskillit.execution.backends._codex_config import ( CODEX_TOOL_OUTPUT_TOKEN_LIMIT, ensure_codex_mcp_registered, @@ -764,7 +764,7 @@ def _assert_large_output_probe(output: _LargeOutputProbe) -> None: _TRANSPORT_TRUNCATION_MARKERS, ) assert len(_OPEN_KITCHEN_TERMINAL_SENTINEL.encode("utf-8")) < ( - OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"].max_utf8_bytes ) diff --git a/tests/execution/backends/test_codex_config.py b/tests/execution/backends/test_codex_config.py index 3a4fef03ca..09a8bc85ee 100644 --- a/tests/execution/backends/test_codex_config.py +++ b/tests/execution/backends/test_codex_config.py @@ -9,7 +9,7 @@ from autoskillit.core import ( CODEX_MCP_ENV_FORWARD_VARS, HEADLESS_AUTO_GATE_ENV_VAR, - OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, ) from autoskillit.execution.backends import ensure_codex_mcp_registered from autoskillit.execution.backends._codex_config import ( @@ -27,15 +27,18 @@ pytestmark = [pytest.mark.layer("execution"), pytest.mark.small] -def test_codex_tool_output_limit_is_derived_from_open_kitchen_budget() -> None: +def test_codex_tool_output_limit_is_derived_from_largest_measured_exemption() -> None: """The four-byte relation is Codex's coarse truncation heuristic, not tokenization. This ceiling is a blast-radius damage bound; producer guards and output spilling remain the load-bearing protections. """ - assert OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == 96_000 - assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == ((OPEN_KITCHEN_OUTPUT_BUDGET_BYTES + 3) // 4) + 8_000 - assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == 32_000 + max_bytes = max( + definition.max_utf8_bytes for definition in RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.values() + ) + assert max_bytes == 180_000 + assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == ((max_bytes + 3) // 4) + 8_000 + assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == 53_000 def test_response_backstop_fires_below_codex_transport_ceiling() -> None: diff --git a/tests/execution/backends/test_probe_cache.py b/tests/execution/backends/test_probe_cache.py index edafa64021..bc6c809c63 100644 --- a/tests/execution/backends/test_probe_cache.py +++ b/tests/execution/backends/test_probe_cache.py @@ -9,6 +9,7 @@ from autoskillit.core import ( OUTPUT_DISCIPLINE_BLOCK_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, ) from autoskillit.execution.backends._probe_cache import ( _SCHEMA_VERSION, @@ -169,7 +170,8 @@ def test_returns_none_on_naive_timestamp(self, tmp_path: Path) -> None: def test_probe_policy_identity_uses_output_discipline_authorities() -> None: assert PROBE_POLICY_IDENTITY == ( - f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}" + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" + f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}" ) diff --git a/tests/hooks/test_hook_sync.py b/tests/hooks/test_hook_sync.py index 2d2f12ad2d..5784699f73 100644 --- a/tests/hooks/test_hook_sync.py +++ b/tests/hooks/test_hook_sync.py @@ -46,6 +46,51 @@ def test_hook_config_path_single_source_of_truth(): ) +def test_response_spill_schema_mirror_matches_server_contract(): + """The standalone formatter's trust schema must exactly mirror the producer.""" + from autoskillit.hooks.formatters.pretty_output_hook import ( + _RESPONSE_SPILL_METADATA_KEY, + _RESPONSE_SPILL_METADATA_KEYS, + _RESPONSE_SPILL_REASONS, + _RESPONSE_SPILL_SCHEMA_DIGEST, + _RESPONSE_SPILL_SCHEMA_VERSION, + ) + from autoskillit.server._response_budget import ( + RESPONSE_SPILL_METADATA_KEY, + RESPONSE_SPILL_METADATA_KEYS, + RESPONSE_SPILL_REASONS, + RESPONSE_SPILL_SCHEMA_DIGEST, + RESPONSE_SPILL_SCHEMA_VERSION, + ) + + assert _RESPONSE_SPILL_METADATA_KEY == RESPONSE_SPILL_METADATA_KEY + assert _RESPONSE_SPILL_METADATA_KEYS == RESPONSE_SPILL_METADATA_KEYS + assert _RESPONSE_SPILL_REASONS == RESPONSE_SPILL_REASONS + assert _RESPONSE_SPILL_SCHEMA_VERSION == RESPONSE_SPILL_SCHEMA_VERSION + assert _RESPONSE_SPILL_SCHEMA_DIGEST == RESPONSE_SPILL_SCHEMA_DIGEST + + +def test_response_backstop_exemption_mirror_matches_core_registry(): + """The standalone formatter mirror must match the canonical measured registry.""" + from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + ) + from autoskillit.hooks.formatters.pretty_output_hook import ( + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, + ) + + canonical = { + tool_name: definition._asdict() + for tool_name, definition in RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.items() + } + assert _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY == canonical + assert ( + _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST == RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + ) + + def test_quota_guard_deny_trigger_sync(): """QUOTA_GUARD_DENY_TRIGGER must be identical in core and quota_guard.""" from autoskillit.core import QUOTA_GUARD_DENY_TRIGGER diff --git a/tests/infra/AGENTS.md b/tests/infra/AGENTS.md index 835c4b1bad..f4a2da877f 100644 --- a/tests/infra/AGENTS.md +++ b/tests/infra/AGENTS.md @@ -43,13 +43,14 @@ CI/CD configuration, security, guard coverage, and release sanity tests. | `test_manifest_directory_completeness.py` | Manifest directory completeness: validates each manifest pattern's test directory list includes all dependent test directories | | `test_mcp_health_advisor.py` | Tests for mcp_health_advisor PreToolUse hook | | `test_open_kitchen_guard.py` | Phase 2 tests: open_kitchen_guard PreToolUse hook | +| `test_output_budget_evidence.py` | Fixture-isolated validation of incremental and complete Output Budget Protocol remediation evidence manifests | | `test_planner_gh_discovery_guard.py` | Tests for the planner_gh_discovery_guard PreToolUse hook | | `test_pr_create_guard.py` | Tests for the pr_create_guard PreToolUse hook | | `test_pretty_output_formatters.py` | Tests: pretty_output per-tool named formatters | -| `test_pretty_output_generic_and_wrap.py` | Tests: pretty_output generic formatter and Claude Code double-wrap | -| `test_pretty_output_hook_infra.py` | Tests: pretty_output hook infrastructure, fail-open, and coverage contracts | +| `test_pretty_output_generic_and_wrap.py` | Generic formatter losslessness, artifact-backed reduction, and Claude Code double-wrap tests | +| `test_pretty_output_hook_infra.py` | Formatter infrastructure, spill-metadata trust, recovery-notice, fail-open, and coverage contracts | | `test_pretty_output_integration.py` | End-to-end schema consistency tests for the pretty_output hook | -| `test_pretty_output_recipe.py` | Tests: pretty_output token/timing, load_recipe, list_recipes, open_kitchen, deduplication | +| `test_pretty_output_recipe.py` | Recipe formatter, exemption measurement/metadata parity, and deduplication contracts | | `test_probe_scripts.py` | Tests for CI-facing probe canary shell scripts (post-probe-failure.sh, create-probe-canary-issue.sh) — syntax, executable bit, and env-var validation | | `test_pyproject_bounds.py` | Tests for pyproject.toml version lower bounds | | `test_recipe_read_guard.py` | Tests for the recipe_read_guard PreToolUse hook — blocks recipe/skill/agent file reads | diff --git a/tests/infra/test_output_budget_evidence.py b/tests/infra/test_output_budget_evidence.py new file mode 100644 index 0000000000..28376af29e --- /dev/null +++ b/tests/infra/test_output_budget_evidence.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import json +from pathlib import Path +from types import ModuleType + +import pytest + +pytestmark = pytest.mark.small + +SCRIPT = Path(__file__).parents[2] / "scripts" / "validate_output_budget_evidence.py" + + +def _load_validator() -> ModuleType: + spec = importlib.util.spec_from_file_location("output_budget_evidence", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +validator = _load_validator() + + +def _write_evidence(repo_root: Path, name: str, content: str = "passed\n") -> dict[str, str]: + path = repo_root / "evidence" / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return { + "gate_log_path": path.relative_to(repo_root).as_posix(), + "gate_log_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + } + + +def _gate(repo_root: Path, name: str, sha: str, command: str = "task test-all") -> dict: + return { + "command": command, + "status": "pass", + "summary": f"{name} passed", + "gate_tested_sha": sha, + **_write_evidence(repo_root, f"{name}.log"), + } + + +def _manifest(repo_root: Path) -> dict: + historical = [] + for phase, sha in enumerate(validator.HISTORICAL_PHASE_SHAS, start=1): + historical.append( + { + "phase": phase, + "phase_commit_sha": sha, + "bound_to_commit": False, + "summary": "Passing summary without embedded command or SHA provenance", + **_write_evidence(repo_root, f"historical-{phase}.log"), + } + ) + baseline_sha = validator.HISTORICAL_PHASE_SHAS[-1] + return { + "schema_version": 1, + "implementation_baseline_sha": baseline_sha, + "historical_context": historical, + "baseline_regression": _gate( + repo_root, + "baseline-regression", + baseline_sha, + command="task test-check", + ), + "phases": [], + } + + +def _complete_manifest(repo_root: Path) -> dict: + manifest = _manifest(repo_root) + for phase, digit in enumerate("5678", start=1): + sha = digit * 40 + manifest["phases"].append( + { + "phase": phase, + "phase_commit_sha": sha, + "gate_tested_sha": sha, + "gates": [ + _gate(repo_root, f"phase-{phase}", sha), + _gate( + repo_root, + f"phase-{phase}-pre-commit", + sha, + command="pre-commit run --all-files", + ), + *( + [ + _gate( + repo_root, + "phase-3-codex-parse", + sha, + command="task test-codex-config-parse", + ) + ] + if phase == 3 + else [] + ), + ], + } + ) + + head_sha = "8" * 40 + manifest["audit_base_sha"] = "0" * 40 + manifest["audit_head_sha"] = head_sha + manifest["final_gates"] = [ + _gate(repo_root, "final-tests", head_sha), + _gate( + repo_root, + "final-pre-commit", + head_sha, + command="pre-commit run --all-files", + ), + ] + manifest["probes"] = { + name: { + "status": "pass", + "command": f"task {name}", + "summary": f"{name} passed", + "gate_tested_sha": ( + manifest["phases"][2]["phase_commit_sha"] + if name == "installed_codex_parse" + else head_sha + ), + **_write_evidence(repo_root, f"{name}.log"), + } + for name in validator.REQUIRED_PROBES + } + + postcondition_path = repo_root / "evidence" / "closure.json" + postcondition_path.write_text( + json.dumps( + { + "data": { + "repository": { + "issue3938": {"state": "CLOSED", "body": "## Closure\nDone"}, + "issue4253": {"state": "CLOSED", "body": "## Closure\nDone"}, + "pr4259": { + "state": "MERGED", + "mergeCommit": {"oid": "9" * 40}, + }, + } + } + } + ) + ) + historical_closure = repo_root / "evidence" / "closure-history.json" + historical_closure.write_text("{}") + manifest["closure"] = { + "closure_pr_merge_sha": "9" * 40, + "historical_artifacts": [ + { + "path": historical_closure.relative_to(repo_root).as_posix(), + "response_content_sha256": hashlib.sha256( + historical_closure.read_bytes() + ).hexdigest(), + } + ], + "postcondition": { + "path": postcondition_path.relative_to(repo_root).as_posix(), + "response_content_sha256": hashlib.sha256(postcondition_path.read_bytes()).hexdigest(), + }, + } + return manifest + + +def test_incremental_accepts_empty_ordered_prefix(tmp_path: Path) -> None: + validator.validate_manifest(_manifest(tmp_path), tmp_path, mode="incremental") + + +@pytest.mark.parametrize("count", [1, 2, 3, 4]) +def test_incremental_accepts_each_ordered_phase_prefix(tmp_path: Path, count: int) -> None: + manifest = _complete_manifest(tmp_path) + manifest["phases"] = manifest["phases"][:count] + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_rejects_out_of_order_phase(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + manifest["phases"] = [manifest["phases"][1]] + + with pytest.raises(validator.EvidenceValidationError, match="ordered prefix"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_rejects_phase_gate_sha_mismatch(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + manifest["phases"][0]["gate_tested_sha"] = "a" * 40 + + with pytest.raises(validator.EvidenceValidationError, match="must equal gate_tested_sha"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_rejects_unexpected_historical_baseline(tmp_path: Path) -> None: + manifest = _manifest(tmp_path) + manifest["historical_context"][0]["phase_commit_sha"] = "a" * 40 + + with pytest.raises(validator.EvidenceValidationError, match="audited baseline"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_requires_phase_pre_commit_gate(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + manifest["phases"] = manifest["phases"][:1] + manifest["phases"][0]["gates"] = manifest["phases"][0]["gates"][:1] + + with pytest.raises(validator.EvidenceValidationError, match="pre-commit"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_rejects_stale_evidence_hash(tmp_path: Path) -> None: + manifest = _manifest(tmp_path) + manifest["historical_context"][0]["gate_log_sha256"] = "0" * 64 + + with pytest.raises(validator.EvidenceValidationError, match="mismatch"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_incremental_rejects_missing_evidence_file(tmp_path: Path) -> None: + manifest = _manifest(tmp_path) + manifest["historical_context"][0]["gate_log_path"] = "evidence/missing.log" + + with pytest.raises(validator.EvidenceValidationError, match="does not exist"): + validator.validate_manifest(manifest, tmp_path, mode="incremental") + + +def test_complete_requires_all_four_phases(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + manifest["phases"].pop() + + with pytest.raises(validator.EvidenceValidationError, match="all four phases"): + validator.validate_manifest(manifest, tmp_path, mode="complete") + + +def test_complete_requires_final_gate_sha_to_match_audit_head(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + manifest["final_gates"][0]["gate_tested_sha"] = "a" * 40 + + with pytest.raises(validator.EvidenceValidationError, match="must equal"): + validator.validate_manifest(manifest, tmp_path, mode="complete") + + +@pytest.mark.parametrize( + ("probe_name", "expected_fragment"), + [ + ("installed_codex_parse", "phase"), + ("deep_codex", "8" * 40), + ], +) +def test_complete_requires_probe_sha_binding( + tmp_path: Path, probe_name: str, expected_fragment: str +) -> None: + manifest = _complete_manifest(tmp_path) + manifest["probes"][probe_name]["gate_tested_sha"] = "a" * 40 + + with pytest.raises(validator.EvidenceValidationError, match="must equal") as exc_info: + validator.validate_manifest(manifest, tmp_path, mode="complete") + if expected_fragment != "phase": + assert expected_fragment in str(exc_info.value) + + +def test_complete_rejects_closure_response_without_closure_section(tmp_path: Path) -> None: + manifest = _complete_manifest(tmp_path) + postcondition = tmp_path / manifest["closure"]["postcondition"]["path"] + response = json.loads(postcondition.read_text()) + response["data"]["repository"]["issue3938"]["body"] = "No closure evidence" + postcondition.write_text(json.dumps(response)) + manifest["closure"]["postcondition"]["response_content_sha256"] = hashlib.sha256( + postcondition.read_bytes() + ).hexdigest() + + with pytest.raises(validator.EvidenceValidationError, match="#3938"): + validator.validate_manifest(manifest, tmp_path, mode="complete") + + +def test_complete_accepts_bound_phase_gate_probe_and_closure_evidence( + tmp_path: Path, +) -> None: + validator.validate_manifest(_complete_manifest(tmp_path), tmp_path, mode="complete") diff --git a/tests/infra/test_pretty_output_generic_and_wrap.py b/tests/infra/test_pretty_output_generic_and_wrap.py index a5e462be53..cf12492027 100644 --- a/tests/infra/test_pretty_output_generic_and_wrap.py +++ b/tests/infra/test_pretty_output_generic_and_wrap.py @@ -2,7 +2,9 @@ from __future__ import annotations +import hashlib import json +from pathlib import Path import pytest @@ -14,6 +16,23 @@ pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] +def _valid_spill_metadata(project_root: Path, artifact_text: str) -> dict[str, object]: + artifact = project_root / ".autoskillit" / "temp" / "responses" / "full.log" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text(artifact_text, encoding="utf-8") + encoded = artifact_text.encode("utf-8") + return { + "schema_version": 1, + "artifact_path": str(artifact.resolve()), + "sha256": hashlib.sha256(encoded).hexdigest(), + "original_utf8_bytes": len(encoded), + "projected_utf8_bytes": 4096, + "omitted_chars": 5000, + "omitted_items": 5, + "reason": "oversized_values", + } + + # PHK-29 def test_fmt_generic_preserves_list_values(): """Generic formatter must render list values, not silently drop them.""" @@ -92,8 +111,8 @@ def test_fmt_generic_pipeline_report_failures_visible(): # PHK-33 -def test_fmt_generic_deeply_nested_truncated(): - """Deeply nested structures must be rendered as truncated compact JSON.""" +def test_fmt_generic_deeply_nested_lossless(): + """Deeply nested structures must be rendered as complete compact JSON.""" deep = {"a": {"b": {"c": {"d": {"e": "deep"}}}}} event = { "tool_name": "mcp__plugin_autoskillit_autoskillit__some_tool", @@ -261,8 +280,8 @@ def test_fmt_generic_list_of_dicts_renders_per_item_not_blob(): assert "... and" not in text, "Output was truncated" -def test_fmt_generic_list_of_dicts_caps_at_20_items(): - """PHK-48: Generic formatter caps list-of-dicts at 20 items with overflow note.""" +def test_fmt_generic_list_of_dicts_renders_all_items_without_artifact(): + """Generic rendering cannot omit list items without a recovery artifact.""" from tests.infra._pretty_output_helpers import _make_event items = [{"key": f"item-{i}", "val": f"value-{i}"} for i in range(25)] @@ -271,8 +290,9 @@ def test_fmt_generic_list_of_dicts_caps_at_20_items(): text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] assert "item-0" in text assert "item-19" in text - assert "item-20" not in text - assert "and 5 more" in text + assert "item-20" in text + assert "item-24" in text + assert "and 5 more" not in text # PHK-49: list-of-dicts must render all fields, not just first 2 @@ -304,15 +324,16 @@ def test_fmt_generic_list_of_dicts_renders_all_fields(): # PHK-50: non-string list items must include ellipsis when truncated -def test_fmt_generic_non_string_list_items_have_ellipsis_when_truncated(): - """_fmt_generic must append '...' when truncating non-string list items.""" +def test_fmt_generic_non_string_list_items_are_lossless_without_artifact(): + """Nested JSON cannot be clipped unless a complete artifact is available.""" from tests.infra._pretty_output_helpers import _make_event big_nested = list(range(2000)) event = _make_event("some_tool", {"items": [big_nested]}) out, _ = _run_hook(event) text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] - assert "..." in text + assert "1999" in text + assert "..." not in text # PHK-51: nested dict/list values render in full when under the raised limit @@ -326,3 +347,43 @@ def test_fmt_generic_nested_dict_value_renders_in_full(): text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] for i in range(20): assert f"key_{i}" in text, f"key_{i} missing from nested dict output" + + +def test_fmt_generic_unspilled_path_crosses_all_former_clip_boundaries(): + """Unspilled generic output retains long strings, all items, and nested JSON.""" + from tests.infra._pretty_output_helpers import _make_event + + long_value = "LONG-HEAD::" + ("x" * 2500) + "::LONG-TAIL" + payload = { + "strings": [f"string-item-{i}" for i in range(25)], + "dict_items": [{"key": f"dict-item-{i}", "value": long_value} for i in range(25)], + "nested": {"long": long_value, "numbers": list(range(600))}, + } + out, _ = _run_hook(_make_event("some_tool", payload)) + text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] + + assert "string-item-24" in text + assert "dict-item-24" in text + assert "::LONG-TAIL" in text + assert "599" in text + assert "... and" not in text + assert "..." not in text + + +def test_fmt_generic_valid_artifact_allows_bounded_projection(tmp_path: Path): + """Trusted spill metadata permits reduction because the notice routes to full data.""" + from tests.infra._pretty_output_helpers import _make_event + + metadata = _valid_spill_metadata(tmp_path, "complete authoritative response") + payload = { + "items": [f"artifact-item-{i}" for i in range(25)], + "_autoskillit_response_spill": metadata, + } + out, _ = _run_hook(_make_event("some_tool", payload), cwd=tmp_path) + text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] + + assert "artifact-item-19" in text + assert "artifact-item-20" not in text + assert "... and 5 more" in text + assert text.count("response_spilled:") == 1 + assert text.count(f"artifact_path: {metadata['artifact_path']}") == 1 diff --git a/tests/infra/test_pretty_output_hook_infra.py b/tests/infra/test_pretty_output_hook_infra.py index cb819c7876..bc6fa593e2 100644 --- a/tests/infra/test_pretty_output_hook_infra.py +++ b/tests/infra/test_pretty_output_hook_infra.py @@ -2,7 +2,9 @@ from __future__ import annotations +import hashlib import json +from pathlib import Path import pytest @@ -35,6 +37,23 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] +def _valid_spill_metadata(project_root: Path, artifact_text: str) -> dict[str, object]: + artifact = project_root / ".autoskillit" / "temp" / "responses" / "full.log" + artifact.parent.mkdir(parents=True, exist_ok=True) + artifact.write_text(artifact_text, encoding="utf-8") + encoded = artifact_text.encode("utf-8") + return { + "schema_version": 1, + "artifact_path": str(artifact.resolve()), + "sha256": hashlib.sha256(encoded).hexdigest(), + "original_utf8_bytes": len(encoded), + "projected_utf8_bytes": 2048, + "omitted_chars": 100, + "omitted_items": 2, + "reason": "oversized_values", + } + + # PHK-1 def test_hook_script_exists(): """pretty_output.py must exist in the hooks directory.""" @@ -89,6 +108,114 @@ def test_hook_fail_open_on_missing_tool_response(): assert out.strip() == "" +def test_valid_spill_metadata_preserves_named_formatter_and_one_notice(tmp_path: Path): + """A trusted artifact keeps normal named dispatch and adds one recovery route.""" + metadata = _valid_spill_metadata(tmp_path, "authoritative run_skill response") + payload = { + "success": True, + "result": "complete", + "session_id": "session-1", + "subtype": "end_turn", + "exit_code": 0, + "needs_retry": False, + "retry_reason": "none", + "stderr": "", + "worktree_path": "/project/worktree", + "_autoskillit_response_spill": metadata, + } + event = { + "tool_name": "mcp__plugin_autoskillit_autoskillit__run_skill", + "tool_response": _wrap_for_claude_code(payload), + } + out, code = _run_hook(event=event, cwd=tmp_path) + assert code == 0 + text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] + + assert "## run_skill" in text + assert "session_id: session-1" in text + assert "worktree_path: /project/worktree" in text + assert text.count("response_spilled:") == 1 + assert text.count(f"artifact_path: {metadata['artifact_path']}") == 1 + + +@pytest.mark.parametrize( + "invalid_case", + [ + "missing_key", + "extra_key", + "schema_version", + "schema_version_bool", + "negative_number", + "numeric_bool", + "invalid_reason", + "non_string_reason", + "relative_path", + "outside_project_temp", + "symlink", + "directory", + "size_mismatch", + "digest_mismatch", + "uppercase_digest", + ], +) +def test_invalid_spill_metadata_remains_lossless_response_data(invalid_case: str, tmp_path: Path): + """Every malformed or untrusted reserved value stays visible and unbounded.""" + artifact_text = "authoritative response" + metadata = _valid_spill_metadata(tmp_path, artifact_text) + if invalid_case == "missing_key": + metadata.pop("reason") + elif invalid_case == "extra_key": + metadata["unexpected"] = "collision" + elif invalid_case == "schema_version": + metadata["schema_version"] = 2 + elif invalid_case == "schema_version_bool": + metadata["schema_version"] = True + elif invalid_case == "negative_number": + metadata["omitted_chars"] = -1 + elif invalid_case == "numeric_bool": + metadata["omitted_items"] = False + elif invalid_case == "invalid_reason": + metadata["reason"] = "other" + elif invalid_case == "non_string_reason": + metadata["reason"] = ["oversized_values"] + elif invalid_case == "relative_path": + metadata["artifact_path"] = ".autoskillit/temp/responses/full.log" + elif invalid_case == "outside_project_temp": + outside = tmp_path / "outside.log" + outside.write_text(artifact_text, encoding="utf-8") + metadata["artifact_path"] = str(outside) + elif invalid_case == "symlink": + link = tmp_path / ".autoskillit" / "temp" / "responses" / "link.log" + link.symlink_to(Path(str(metadata["artifact_path"]))) + metadata["artifact_path"] = str(link) + elif invalid_case == "directory": + metadata["artifact_path"] = str(tmp_path / ".autoskillit" / "temp" / "responses") + elif invalid_case == "size_mismatch": + metadata["original_utf8_bytes"] = len(artifact_text.encode("utf-8")) + 1 + elif invalid_case == "digest_mismatch": + metadata["sha256"] = "0" * 64 + elif invalid_case == "uppercase_digest": + metadata["sha256"] = str(metadata["sha256"]).upper() + + long_tail = "LONG-HEAD::" + ("x" * 2500) + "::LONG-TAIL" + payload = { + "success": True, + "result": long_tail, + "_autoskillit_response_spill": metadata, + } + event = { + "tool_name": "mcp__plugin_autoskillit_autoskillit__run_skill", + "tool_response": _wrap_for_claude_code(payload), + } + out, code = _run_hook(event=event, cwd=tmp_path) + assert code == 0 + text = json.loads(out)["hookSpecificOutput"]["updatedMCPToolOutput"] + + assert "_autoskillit_response_spill:" in text + assert "::LONG-TAIL" in text + assert "response_spilled:" not in text + + # --------------------------------------------------------------------------- # PHK-41: Formatter coverage contract # --------------------------------------------------------------------------- diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 489c396ed4..1e1eed0af4 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -7,11 +7,11 @@ import pytest -from autoskillit.core import OPEN_KITCHEN_OUTPUT_BUDGET_BYTES -from autoskillit.hooks.formatters.pretty_output_hook import ( - _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES, - _format_response, +from autoskillit.core import ( + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, ) +from autoskillit.hooks.formatters.pretty_output_hook import _format_response from tests.infra._pretty_output_helpers import ( REALISTIC_RECIPE_YAML, _make_event, @@ -21,10 +21,20 @@ pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] -def test_open_kitchen_budget_dual_copy_matches_core_authority() -> None: - """The stdlib-only formatter copy must stay equal to the public authority.""" - assert OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == 96_000 - assert _OPEN_KITCHEN_OUTPUT_BUDGET_BYTES == OPEN_KITCHEN_OUTPUT_BUDGET_BYTES +def test_response_backstop_tool_metadata_comes_from_registry() -> None: + from autoskillit.server.tools._serve_helpers import response_backstop_tool_meta + + for tool_name in ("load_recipe", "open_kitchen"): + definition = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY[tool_name] + metadata = response_backstop_tool_meta(tool_name, always_load=tool_name == "open_kitchen") + assert metadata["anthropic/maxResultSizeChars"] == definition.max_chars + assert metadata["autoskillit/responseBackstopMeasurement"] == definition.measurement_id + assert metadata["autoskillit/responseBackstopMaxUtf8Bytes"] == definition.max_utf8_bytes + assert ( + metadata["autoskillit/responseBackstopRegistryDigest"] + == RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST + ) + assert metadata.get("anthropic/alwaysLoad", False) is (tool_name == "open_kitchen") # PHK-15 @@ -963,47 +973,102 @@ def test_compact_recipe_display_preserves_execution_semantics(tmp_path, monkeypa assert remediation_note_checked, "remediation recipe was not exercised by this test" -def test_open_kitchen_payload_warning_uses_formatted_byte_count(capsys): - """When the rendered payload exceeds budget, the stdlib-only warning must report - the tool name, content_hash, actual formatted byte count, and the budget.""" - from autoskillit.hooks.formatters.pretty_output_hook import _fmt_open_kitchen +def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, monkeypatch): + """Measure the same pre-backstop string in characters and UTF-8 bytes.""" + from types import SimpleNamespace - huge_note_body = "x" * 100_000 - content = ( - f"name: huge\nsteps:\n only:\n tool: run_cmd\n note: |\n {huge_note_body}\n" + from autoskillit import __version__ + from autoskillit.recipe import _api_cache, all_validated_recipe_names + from autoskillit.recipe._api_cache import LoadCache + from autoskillit.recipe.repository import DefaultRecipeRepository + from autoskillit.server._misc import strip_ingredients_only_keys + from autoskillit.server.tools._serve_helpers import ( + build_open_kitchen_recipe_payload, + render_served_response, + serve_recipe, ) - data = { - "content": content, - "valid": True, - "suggestions": [], - "version": "0.1.0", - "content_hash": "deadbeef", + + project_root = Path(__file__).resolve().parent.parent.parent + measured_modes: set[tuple[str, str, bool]] = set() + maxima = {"load_recipe": (0, "", ""), "open_kitchen": (0, "", "")} + + for recipe_name in all_validated_recipe_names(project_root): + for mode_name, overrides in _COMPACT_TEST_OVERRIDES.items(): + for ingredients_only in (False, True): + monkeypatch.setattr(_api_cache, "_LOAD_CACHE", LoadCache()) + tool_ctx = SimpleNamespace( + recipes=DefaultRecipeRepository(), + project_dir=project_root, + session_serve_overrides=None, + session_serve_defer_unresolved=False, + ) + result = serve_recipe( + tool_ctx, + recipe_name, + caller_overrides=dict(overrides, source_dir=str(project_root)), + config_default={}, + session_overrides={}, + config_layer={}, + temp_dir=tmp_path, + ) + + for tool_name in ("load_recipe", "open_kitchen"): + payload = dict(result) + if tool_name == "open_kitchen": + payload = build_open_kitchen_recipe_payload(payload, version=__version__) + if ingredients_only: + payload = strip_ingredients_only_keys(payload) + raw = render_served_response(payload) + definition = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY[tool_name] + assert len(raw) <= definition.max_chars, ( + tool_name, + recipe_name, + mode_name, + ingredients_only, + len(raw), + ) + assert len(raw.encode("utf-8")) <= definition.max_utf8_bytes, ( + tool_name, + recipe_name, + mode_name, + ingredients_only, + len(raw.encode("utf-8")), + ) + maxima[tool_name] = max( + maxima[tool_name], + (len(raw.encode("utf-8")), recipe_name, mode_name), + ) + measured_modes.add((tool_name, mode_name, ingredients_only)) + + assert measured_modes == { + (tool_name, mode_name, ingredients_only) + for tool_name in ("load_recipe", "open_kitchen") + for mode_name in _COMPACT_TEST_OVERRIDES + for ingredients_only in (False, True) + } + assert maxima == { + "load_recipe": (178_601, "remediation", "all_truthy"), + "open_kitchen": (178_660, "remediation", "all_truthy"), } - formatted = _fmt_open_kitchen(data, pipeline=False) - captured = capsys.readouterr() - byte_len = len(formatted.encode("utf-8")) - assert byte_len > OPEN_KITCHEN_OUTPUT_BUDGET_BYTES - assert "open_kitchen" in captured.err - assert "content_hash=deadbeef" in captured.err - assert str(byte_len) in captured.err - assert str(OPEN_KITCHEN_OUTPUT_BUDGET_BYTES) in captured.err def test_rendered_open_kitchen_payload_under_budget(tmp_path, monkeypatch): """Regression gate (issue #4253): the fully rendered open_kitchen payload for every runtime-discoverable recipe, under both default and all-truthy ingredient - resolution, must stay at or under the 100,000 UTF-8 byte regression budget — a - margin below the last empirically observed ~100KB Claude Code CLI disk-persistence - gate (measured on CLI 2.1.197). Re-measure after CLI upgrades; this is not a claim - that the external gate is stable. Budget bumped from 96,000 to 100,000 for Part B - of issue #4274: the new ``inter_part_push_pre_remediation`` and - ``verify_ref_push_exhaustion`` steps in ``remediation.yaml`` legitimately grew - the rendered payload (issue #4274 root cause fix).""" + resolution, must stay at or under the measured exemption ceiling from + ``RESPONSE_BACKSTOP_EXEMPTION_REGISTRY`` — a margin below the last empirically + observed ~100KB Claude Code CLI disk-persistence gate (measured on CLI 2.1.197). + Re-measure after CLI upgrades; this is not a claim that the external gate is + stable. Ceiling accommodates growth from issue #4274 Part B: the new + ``inter_part_push_pre_remediation`` and ``verify_ref_push_exhaustion`` steps in + ``remediation.yaml`` legitimately grew the rendered payload (issue #4274 root + cause fix).""" from autoskillit.hooks.formatters.pretty_output_hook import _fmt_open_kitchen from autoskillit.recipe import _api_cache, all_validated_recipe_names, load_and_validate from autoskillit.recipe._api_cache import LoadCache project_root = Path(__file__).resolve().parent.parent.parent + ceiling = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["open_kitchen"].max_utf8_bytes over_budget: list[str] = [] maximum: tuple[int, str, str] = (0, "", "") @@ -1019,11 +1084,8 @@ def test_rendered_open_kitchen_payload_under_budget(tmp_path, monkeypatch): rendered = _fmt_open_kitchen(result, pipeline=False) byte_len = len(rendered.encode("utf-8")) maximum = max(maximum, (byte_len, recipe_name, mode_name)) - if byte_len > OPEN_KITCHEN_OUTPUT_BUDGET_BYTES: - over_budget.append( - f"{recipe_name} ({mode_name}): {byte_len} bytes > " - f"{OPEN_KITCHEN_OUTPUT_BUDGET_BYTES}" - ) + if byte_len > ceiling: + over_budget.append(f"{recipe_name} ({mode_name}): {byte_len} bytes > {ceiling}") max_bytes, max_recipe, max_mode = maximum assert not over_budget, ( diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 62c1e17e03..4a674c1f77 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -119,10 +119,10 @@ def _is_yaml_dump(node: ast.expr) -> bool: # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay - ("src/autoskillit/server/tools/tools_kitchen.py", 258), - ("src/autoskillit/server/tools/tools_kitchen.py", 277), - ("src/autoskillit/server/tools/tools_kitchen.py", 311), - ("src/autoskillit/server/tools/tools_kitchen.py", 1282), + ("src/autoskillit/server/tools/tools_kitchen.py", 269), + ("src/autoskillit/server/tools/tools_kitchen.py", 288), + ("src/autoskillit/server/tools/tools_kitchen.py", 322), + ("src/autoskillit/server/tools/tools_kitchen.py", 1284), # tools_pipeline_tracker.py — tracker_data dict ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166), # tools_status.py — mcp_data dict diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 2ed737a95a..238637f211 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -33,7 +33,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_misc_module.py` | Contract tests: server._misc module | | `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 and projection tests | +| `test_response_backstop.py` | Universal response-budget spill, exact projection, exemption, fail-closed, and telemetry 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 | @@ -82,13 +82,13 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_execution_provider.py` | Tests for provider_extras/profile_name forwarding through run_skill() | | `test_tools_execution_response.py` | Contract tests: MCP tool response fields use correct enum types | | `test_tools_execution_results.py` | Tests for run_skill result shapes, failure paths, timing, flush telemetry, and gate checks | -| `test_tools_execution_spill.py` | Lossless spill contracts for run_cmd and run_python | +| `test_tools_execution_spill.py` | Lossless spill and anchor contracts for execution and dispatch handlers | | `test_tools_execution_routing.py` | Tests for run_skill routing, executor delegation, and session skill management | | `test_tools_execution_step_resolution.py` | Tests for server-side recipe step parameter resolution in run_skill (output_dir, stale_threshold, idle_output_timeout, step_provider auto-filled from cached recipe step definitions) | | `test_tools_execution_backend_mixing.py` | Integration tests for per-step backend mixing in run_skill() — provider-profile and skill-requirement-driven backend_override derivation + structured log emission | | `test_tools_execution_write_prefix.py` | Tests for allowed_write_prefix computation decoupled from read_only | | `test_tools_fleet_reset.py` | Tests for the `reset_dispatch` MCP tool — happy paths, errors, edge cases, force-bypass acceptance (REQ-GUARD-003), resume-gate-state cleanup (REQ-STATE-002) | -| `test_tools_git.py` | Tests for merge_worktree core flow: happy path, test gate, rebase abort, bypass prevention | +| `test_tools_git.py` | Tests for merge_worktree flow, both large-output gates, passing-output drop, rebase abort, and bypass prevention | | `test_tools_git_branch.py` | Tests for create_unique_branch and check_pr_mergeable tools | | `test_tools_git_classify_fix.py` | Tests for classify_fix tool | | `test_tools_git_merge_cleanup.py` | Tests for merge_worktree cleanup reporting and warnings | @@ -153,8 +153,8 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_tools_workspace.py` | Tests for autoskillit server workspace tools | | `test_tools_workspace_spill.py` | Lossless raw-output spill contracts for test_check | | `test_tools_agents.py` | Tests for agent pack registry, MCP resources, and `unlock_agent_pack` | -| `test_track_response_size.py` | Tests for the track_response_size decorator in autoskillit.server._notify | -| `test_wire_compat.py` | Wire compatibility tests | +| `test_track_response_size.py` | Tests for response tracking, non-fatal diagnostics, and universal fail-closed enforcement | +| `test_wire_compat.py` | Registered/advertised/handler/converted response conformance and wire compatibility tests | | `test_backend_ingredient_injection.py` | Tests for backend_capability_overrides injection via open_kitchen, load_recipe, and get_recipe resource | | `test_tools_git_branch_isolation.py` | Integration tests for create_and_publish_branch with clone-isolated origin topology | diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index f1671dff11..4f1cccee37 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -3,13 +3,16 @@ from __future__ import annotations import json +from pathlib import Path import pytest +import structlog from autoskillit.config import OutputBudgetConfig +from autoskillit.core import RESPONSE_BACKSTOP_EXEMPTION_REGISTRY from autoskillit.server._response_budget import ( - RESPONSE_BACKSTOP_EXEMPT_TOOLS, RESPONSE_SPILL_METADATA_KEY, + RESPONSE_SPILL_METADATA_KEYS, enforce_response_budget, ) @@ -64,6 +67,8 @@ def test_oversized_json_preserves_routing_shape_and_full_artifact(tmp_path): metadata = data[RESPONSE_SPILL_METADATA_KEY] assert open(metadata["artifact_path"], encoding="utf-8").read() == original assert metadata["original_utf8_bytes"] == len(original.encode()) + assert set(metadata) == RESPONSE_SPILL_METADATA_KEYS + assert metadata["projected_utf8_bytes"] == len(shaped.encode("utf-8")) def test_plain_text_response_uses_same_type_envelope(tmp_path): @@ -80,6 +85,7 @@ def test_plain_text_response_uses_same_type_envelope(tmp_path): artifact_path = data[RESPONSE_SPILL_METADATA_KEY]["artifact_path"] assert open(artifact_path, encoding="utf-8").read() == original assert len(shaped.encode()) <= _config().response_max_bytes + assert data[RESPONSE_SPILL_METADATA_KEY]["projected_utf8_bytes"] == len(shaped.encode("utf-8")) def test_artifact_failure_is_fail_closed(tmp_path, monkeypatch): @@ -98,8 +104,263 @@ def test_artifact_failure_is_fail_closed(tmp_path, monkeypatch): config=_config(), ) assert secret not in shaped - assert "artifact_write_failed" in shaped + assert "artifact_publication_failed" in shaped -def test_exemption_registry_is_closed(): - assert RESPONSE_BACKSTOP_EXEMPT_TOOLS == frozenset({"open_kitchen", "load_recipe"}) +@pytest.mark.parametrize("kind", ["json", "plain"]) +def test_projected_utf8_bytes_is_exact_with_multibyte_digit_boundaries( + tmp_path, monkeypatch, kind +): + from autoskillit.server import _response_budget + + artifact = tmp_path / "fixed-α-artifact.log" + monkeypatch.setattr( + _response_budget, + "_artifact_path", + lambda _artifact_dir, _tool_name: artifact, + ) + original = ( + json.dumps({"success": True, "result": "界" * 10_000}, ensure_ascii=False) + if kind == "json" + else "界" * 10_000 + ) + config = OutputBudgetConfig( + inline_max_chars=197, + head_chars=90, + tail_chars=90, + response_max_bytes=1001, + ) + + first = enforce_response_budget( + original, + tool_name="deterministic_tool", + artifact_dir=tmp_path, + config=config, + ) + second = enforce_response_budget( + original, + tool_name="deterministic_tool", + artifact_dir=tmp_path, + config=config, + ) + + assert first == second + assert isinstance(first, str) + metadata = json.loads(first)[RESPONSE_SPILL_METADATA_KEY] + assert metadata["projected_utf8_bytes"] == len(first.encode("utf-8")) + assert len(first.encode("utf-8")) <= config.response_max_bytes + + +def test_minimal_projection_has_exact_bytes_and_omission_aggregates(tmp_path, monkeypatch): + from autoskillit.server import _response_budget + + monkeypatch.setattr( + _response_budget, + "_artifact_path", + lambda _artifact_dir, _tool_name: tmp_path / "fixed.log", + ) + original_data = {f"route_{index}": "界" * 120 for index in range(8)} + original = json.dumps(original_data, ensure_ascii=False) + config = OutputBudgetConfig( + inline_max_chars=64, + head_chars=32, + tail_chars=32, + response_max_bytes=620, + ) + + shaped = enforce_response_budget( + original, + tool_name="minimal_tool", + artifact_dir=tmp_path, + config=config, + ) + repeated = enforce_response_budget( + original, + tool_name="minimal_tool", + artifact_dir=tmp_path, + config=config, + ) + + assert isinstance(shaped, str) + assert shaped == repeated + metadata = json.loads(shaped)[RESPONSE_SPILL_METADATA_KEY] + assert metadata["reason"] == "minimal_projection" + assert metadata["omitted_chars"] == 8 * 120 + assert metadata["omitted_items"] == 0 + assert metadata["artifact_path"] == str((tmp_path / "fixed.log").resolve()) + assert metadata["projected_utf8_bytes"] == len(shaped.encode("utf-8")) + + +def test_reserved_metadata_collision_fails_closed_with_complete_artifact(tmp_path): + original = json.dumps({RESPONSE_SPILL_METADATA_KEY: {"forged": True}, "secret": "x" * 10_000}) + shaped = enforce_response_budget( + original, + tool_name="collision_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + assert isinstance(shaped, str) + failure = json.loads(shaped) + assert failure["error"] == "response_budget_irreducible_shape" + assert Path(failure["artifact_path"]).read_text() == original + assert "x" * 100 not in shaped + + +def test_missing_context_preserves_small_and_fails_closed_for_large(): + small = "small" + assert ( + enforce_response_budget( + small, + tool_name="missing_context_tool", + artifact_dir=None, + config=_config(), + ) + == small + ) + secret = "secret-path-/private/project" * 1000 + shaped = enforce_response_budget( + secret, + tool_name="missing_context_tool", + artifact_dir=None, + config=_config(), + ) + assert secret not in shaped + assert "/private/project" not in shaped + assert "context_unavailable" in shaped + + +def test_nonserializable_result_fails_closed_and_emits_exact_failure(tmp_path): + result = {"secret": object()} + with structlog.testing.capture_logs() as logs: + shaped = enforce_response_budget( + result, + tool_name="nonserializable_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + assert shaped["success"] is False + event = next(log for log in logs if log["event"] == "response_budget_failure") + assert {key for key in event if key not in {"event", "log_level", "logger"}} == { + "tool_name", + "cause", + "original_utf8_bytes", + } + assert event["cause"] == "serialization_failed" + + +def test_spill_and_failure_telemetry_is_exact_and_path_free(tmp_path, monkeypatch): + from autoskillit.server import _response_budget + + secret_path = "/private/audit/secrets.log" + + def fail_publication(*_args, **_kwargs): + raise OSError(secret_path) + + monkeypatch.setattr(_response_budget, "atomic_write", fail_publication) + with structlog.testing.capture_logs() as logs: + shaped = enforce_response_budget( + "x" * 10_000, + tool_name="tøøl/" + "x" * 100, + artifact_dir=tmp_path, + config=_config(), + ) + + assert secret_path not in shaped + assert secret_path not in repr(logs) + event = next(log for log in logs if log["event"] == "response_budget_failure") + assert {key for key in event if key not in {"event", "log_level", "logger"}} == { + "tool_name", + "cause", + "original_utf8_bytes", + } + assert event["cause"] == "artifact_publication_failed" + assert event["tool_name"].isascii() + assert len(event["tool_name"]) <= 64 + + +def test_telemetry_failure_is_nonfatal(tmp_path, monkeypatch): + from autoskillit.server import _response_budget + + monkeypatch.setattr( + _response_budget.logger, + "info", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("telemetry failed")), + ) + shaped = enforce_response_budget( + "x" * 10_000, + tool_name="telemetry_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + assert isinstance(shaped, str) + assert Path(json.loads(shaped)[RESPONSE_SPILL_METADATA_KEY]["artifact_path"]).exists() + + +def test_exemption_overage_fails_closed_and_does_not_spill(tmp_path): + exemption = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["load_recipe"] + original = "x" * (exemption.max_chars + 1) + + shaped = enforce_response_budget( + original, + tool_name="load_recipe", + artifact_dir=tmp_path, + config=_config(), + ) + + assert original not in shaped + assert "internal_invariant_failed" in shaped + assert list(tmp_path.iterdir()) == [] + + +def test_successful_spill_and_exemption_events_have_exact_path_free_payloads( + tmp_path, monkeypatch +): + from autoskillit.server import _response_budget + + exemption = RESPONSE_BACKSTOP_EXEMPTION_REGISTRY["load_recipe"] + events: list[tuple[str, dict]] = [] + monkeypatch.setattr( + _response_budget, + "_emit_response_budget_event", + lambda event, **payload: events.append((event, payload)), + ) + enforce_response_budget( + "measured recipe response", + tool_name="load_recipe", + artifact_dir=tmp_path, + config=_config(), + ) + shaped = enforce_response_budget( + "x" * 10_000, + tool_name="spill_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + exemption_event = next( + payload for event, payload in events if event == "response_budget_exemption" + ) + assert set(exemption_event) == { + "tool_name", + "measurement_id", + "original_chars", + "original_utf8_bytes", + "max_chars", + "max_utf8_bytes", + } + assert exemption_event["measurement_id"] == exemption.measurement_id + assert exemption_event["max_chars"] == exemption.max_chars + assert exemption_event["max_utf8_bytes"] == exemption.max_utf8_bytes + + spill_event = next(payload for event, payload in events if event == "response_budget_spill") + assert set(spill_event) == { + "tool_name", + "original_utf8_bytes", + "projected_utf8_bytes", + } + assert spill_event["projected_utf8_bytes"] == len(shaped.encode("utf-8")) + assert "artifact" not in repr(exemption_event) + assert str(tmp_path) not in repr(spill_event) diff --git a/tests/server/test_tools_execution_spill.py b/tests/server/test_tools_execution_spill.py index 34ac7754d5..ed8ac06dcd 100644 --- a/tests/server/test_tools_execution_spill.py +++ b/tests/server/test_tools_execution_spill.py @@ -3,12 +3,15 @@ from __future__ import annotations import json +import uuid +from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from autoskillit.server._response_budget import RESPONSE_SPILL_METADATA_KEY -from autoskillit.server.tools.tools_execution import run_cmd, run_python +from autoskillit.server.tools.tools_execution import run_cmd, run_python, run_skill +from tests.conftest import _make_result pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -31,6 +34,42 @@ async def test_run_cmd_spills_large_stdout_under_calling_project(tool_ctx_kitche assert data["exit_code"] == 0 +@pytest.mark.anyio +async def test_run_cmd_resolves_absolute_cwd_only_for_spill_anchor( + tool_ctx_kitchen_open, tmp_path +): + project = tmp_path / "project" + project.mkdir() + project_link = tmp_path / "project-link" + project_link.symlink_to(project, target_is_directory=True) + stdout = "x" * 10_000 + subprocess = AsyncMock(return_value=(0, stdout, "")) + + with patch( + "autoskillit.server.tools.tools_execution._run_subprocess", + new=subprocess, + ): + data = json.loads(await run_cmd("bounded-command", str(project_link))) + + assert subprocess.await_args.kwargs["cwd"] == str(project_link) + assert data["stdout_artifact_path"].startswith( + str(project / ".autoskillit" / "temp" / "run_cmd") + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("cwd", ["", "relative-project"]) +async def test_run_cmd_empty_or_relative_cwd_uses_injected_temp_dir(tool_ctx_kitchen_open, cwd): + stdout = "x" * 10_000 + with patch( + "autoskillit.server.tools.tools_execution._run_subprocess", + new=AsyncMock(return_value=(0, stdout, "")), + ): + data = json.loads(await run_cmd("bounded-command", cwd)) + + assert data["stdout_artifact_path"].startswith(str(tool_ctx_kitchen_open.temp_dir / "run_cmd")) + + @pytest.mark.anyio async def test_run_cmd_small_output_shape_is_unchanged(tool_ctx_kitchen_open, tmp_path): with patch( @@ -51,7 +90,7 @@ async def test_run_python_preserves_routing_keys_and_full_json(tool_ctx_kitchen_ ): data = json.loads(await run_python("package.module.callable", work_dir=str(tmp_path))) - assert data["success"] is True + assert data["success"] is True, data assert data["verdict"] == "GO" metadata = data[RESPONSE_SPILL_METADATA_KEY] assert json.loads(open(metadata["artifact_path"], encoding="utf-8").read()) == result @@ -67,3 +106,145 @@ async def test_run_python_small_dict_is_byte_identical(tool_ctx_kitchen_open, tm raw = await run_python("package.module.callable", work_dir=str(tmp_path)) assert raw == json.dumps(result) + + +@pytest.mark.anyio +async def test_decorated_run_skill_preserves_routing_scalars_and_full_artifact( + tool_ctx_kitchen_open, tmp_path, monkeypatch +): + monkeypatch.setattr( + uuid, + "uuid4", + lambda: SimpleNamespace(hex="deadbeef000000000000000000000000"), + ) + tool_ctx_kitchen_open.output_pattern_resolver = None + tool_ctx_kitchen_open.completion_required_resolver = None + tool_ctx_kitchen_open.write_expected_resolver = None + sentinels = ("HEAD-SENTINEL", "MIDDLE-SENTINEL", "TAIL-SENTINEL") + producer_result = ( + sentinels[0] + + ("x" * 30_000) + + sentinels[1] + + ("y" * 30_000) + + sentinels[2] + + "\n%%ORDER_UP::deadbeef%%" + ) + stdout = json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": producer_result, + "session_id": "spill-session", + } + ) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=0, stdout=stdout)) + + raw = await run_skill("/investigate output budget", str(tmp_path)) + data = json.loads(raw) + + assert data["success"] is True, { + key: data.get(key) for key in ("success", "subtype", "retry_reason", "is_error", "result") + } + assert data["is_error"] is False + assert data["needs_retry"] is False + assert data["session_id"] == "spill-session" + assert data["exit_code"] == 0 + metadata = data[RESPONSE_SPILL_METADATA_KEY] + authoritative = open(metadata["artifact_path"], encoding="utf-8").read() + authoritative_data = json.loads(authoritative) + for sentinel in sentinels: + assert sentinel in authoritative_data["result"] + + +@pytest.mark.anyio +async def test_decorated_run_skill_small_result_is_byte_identical( + tool_ctx_kitchen_open, tmp_path, monkeypatch +): + from autoskillit.server import _notify + + monkeypatch.setattr( + uuid, + "uuid4", + lambda: SimpleNamespace(hex="deadbeef000000000000000000000000"), + ) + tool_ctx_kitchen_open.output_pattern_resolver = None + tool_ctx_kitchen_open.completion_required_resolver = None + tool_ctx_kitchen_open.write_expected_resolver = None + original_enforce = _notify.enforce_response_budget + observed: dict[str, str] = {} + + def capture_identity(result, **kwargs): + observed["input"] = result + shaped = original_enforce(result, **kwargs) + observed["output"] = shaped + return shaped + + monkeypatch.setattr(_notify, "enforce_response_budget", capture_identity) + stdout = json.dumps( + { + "type": "result", + "subtype": "success", + "is_error": False, + "result": "small result\n%%ORDER_UP::deadbeef%%", + "session_id": "small-session", + } + ) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=1)) + tool_ctx_kitchen_open.runner.push(_make_result(returncode=0, stdout=stdout)) + + raw = await run_skill("/investigate output budget", str(tmp_path)) + + assert raw == observed["input"] == observed["output"] + + +@pytest.mark.anyio +async def test_decorated_dispatch_preserves_routing_scalars_artifact_and_small_identity( + tool_ctx_kitchen_open, monkeypatch +): + from autoskillit.server.tools import tools_fleet_dispatch + + monkeypatch.setattr(tools_fleet_dispatch, "_require_fleet", lambda _name: None) + monkeypatch.setattr( + tools_fleet_dispatch, + "find_caller_session_id", + lambda **_kwargs: None, + ) + large_envelope = json.dumps( + { + "success": True, + "dispatch_status": "success", + "dispatch_id": "dispatch-1", + "dispatched_session_id": "child-1", + "reason": "HEAD-SENTINEL" + ("x" * 120_000) + "TAIL-SENTINEL", + } + ) + large_outcome = SimpleNamespace(to_envelope=lambda: large_envelope) + execute = AsyncMock(return_value=SimpleNamespace(outcome=large_outcome)) + monkeypatch.setattr(tools_fleet_dispatch, "execute_dispatch", execute) + + raw = await tools_fleet_dispatch.dispatch_food_truck(recipe="probe", task="spill") + data = json.loads(raw) + + assert data["success"] is True + assert data["dispatch_status"] == "success" + assert data["dispatch_id"] == "dispatch-1" + assert data["dispatched_session_id"] == "child-1" + metadata = data[RESPONSE_SPILL_METADATA_KEY] + assert open(metadata["artifact_path"], encoding="utf-8").read() == large_envelope + + small_envelope = json.dumps( + { + "success": True, + "dispatch_status": "success", + "dispatch_id": "dispatch-small", + } + ) + small_outcome = SimpleNamespace(to_envelope=lambda: small_envelope) + execute.return_value = SimpleNamespace(outcome=small_outcome) + + assert ( + await tools_fleet_dispatch.dispatch_food_truck(recipe="probe", task="small") + == small_envelope + ) diff --git a/tests/server/test_tools_git.py b/tests/server/test_tools_git.py index dc4a194ebd..5479eb1b9e 100644 --- a/tests/server/test_tools_git.py +++ b/tests/server/test_tools_git.py @@ -63,8 +63,10 @@ async def test_merge_worktree_merges_on_green(self, tool_ctx_kitchen_open, tmp_p tool_ctx_kitchen_open.runner.push( _make_result(0, "", "") ) # git status --porcelain (clean) + huge_passing_stdout = "P" * 100_000 + "\n= 100 passed =" + huge_passing_stderr = "W" * 100_000 tool_ctx_kitchen_open.runner.push( - _make_result(0, "PASS\n= 100 passed =", "") + _make_result(0, huge_passing_stdout, huge_passing_stderr) ) # pre-rebase test-check tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) # git fetch tool_ctx_kitchen_open.runner.push( @@ -75,7 +77,7 @@ async def test_merge_worktree_merges_on_green(self, tool_ctx_kitchen_open, tmp_p ) # git log --merges (no merge commits — step 5.6) tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) # git rebase tool_ctx_kitchen_open.runner.push( - _make_result(0, "PASS\n= 100 passed =", "") + _make_result(0, huge_passing_stdout, huge_passing_stderr) ) # post-rebase test-check tool_ctx_kitchen_open.runner.push( _make_result(0, "dev\n", "") @@ -93,6 +95,10 @@ async def test_merge_worktree_merges_on_green(self, tool_ctx_kitchen_open, tmp_p assert result["cleanup_succeeded"] is True assert result["worktree_removed"] is True assert result["branch_deleted"] is True + assert "test_stdout" not in result + assert "test_stderr" not in result + assert "raw_output_artifact_path" not in result + assert not (wt / ".autoskillit" / "temp" / "merge_worktree").exists() # Verify merge command cwd is the main_repo (/repo) merge_call = next( args @@ -237,7 +243,55 @@ async def test_gate_failure_spills_large_output_losslessly( result = json.loads(await merge_worktree(str(wt), "dev")) assert result["failed_step"] == MergeFailedStep.TEST_GATE artifact = Path(result["raw_output_artifact_path"]) + assert artifact.is_relative_to(wt / ".autoskillit" / "temp") raw = json.loads(artifact.read_text()) assert raw == {"stdout": large_stdout, "stderr": large_stderr} assert "raw test output spilled" in result["test_stdout"] assert "raw test output spilled" in result["test_stderr"] + preview_bound = ( + tool_ctx_kitchen_open.config.output_budget.head_chars + + tool_ctx_kitchen_open.config.output_budget.tail_chars + + 512 + ) + assert len(result["test_stdout"]) <= preview_bound + assert len(result["test_stderr"]) <= preview_bound + + @pytest.mark.anyio + async def test_post_rebase_gate_failure_spills_large_output_losslessly( + self, tool_ctx_kitchen_open, tmp_path + ): + wt = tmp_path / "worktree" + wt.mkdir() + (wt / ".git").write_text("gitdir: /repo/.git/worktrees/wt") + + tool_ctx_kitchen_open.runner.push(_make_result(0, "/repo/.git/worktrees/wt\n", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "impl-branch\n", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "= 100 passed =", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "abc123\n", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) + tool_ctx_kitchen_open.runner.push(_make_result(0, "", "")) + large_stdout = "F" * 100_000 + "\n= 3 failed, 97 passed =" + large_stderr = "E" * 100_000 + tool_ctx_kitchen_open.runner.push(_make_result(1, large_stdout, large_stderr)) + + result = json.loads(await merge_worktree(str(wt), "dev")) + + assert result["failed_step"] == MergeFailedStep.POST_REBASE_TEST_GATE + artifact = Path(result["raw_output_artifact_path"]) + assert artifact.is_relative_to(wt / ".autoskillit" / "temp") + assert json.loads(artifact.read_text()) == { + "stdout": large_stdout, + "stderr": large_stderr, + } + assert "raw test output spilled" in result["test_stdout"] + assert "raw test output spilled" in result["test_stderr"] + preview_bound = ( + tool_ctx_kitchen_open.config.output_budget.head_chars + + tool_ctx_kitchen_open.config.output_budget.tail_chars + + 512 + ) + assert len(result["test_stdout"]) <= preview_bound + assert len(result["test_stderr"]) <= preview_bound diff --git a/tests/server/test_tools_kitchen_gate_hook_config.py b/tests/server/test_tools_kitchen_gate_hook_config.py index 6e84bf2f16..7f53cca858 100644 --- a/tests/server/test_tools_kitchen_gate_hook_config.py +++ b/tests/server/test_tools_kitchen_gate_hook_config.py @@ -61,6 +61,7 @@ async def test_open_kitchen_writes_hook_config_json(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) mock_ctx = _make_mock_ctx() mock_ctx.project_dir = tmp_path + mock_ctx.temp_dir = tmp_path / "configured-response-temp" mock_ctx.config.quota_guard.short_window_threshold = 85.0 mock_ctx.config.quota_guard.long_window_threshold = 98.0 mock_ctx.config.quota_guard.long_window_patterns = ["weekly", "sonnet", "opus"] @@ -95,6 +96,7 @@ async def test_open_kitchen_writes_hook_config_json(tmp_path, monkeypatch): # disabled is always written by _quota_guard_hook_payload # MagicMock.enabled is truthy by default, so disabled must be False assert data["quota_guard"]["disabled"] is False + assert data["response_temp_root"] == str(mock_ctx.temp_dir.resolve()) # Confirm kitchen_id rename: hook config must contain 'kitchen_id' (not 'pipeline_id') assert "kitchen_id" in data, ( "hook config must contain 'kitchen_id' after rename from 'pipeline_id'" diff --git a/tests/server/test_track_response_size.py b/tests/server/test_track_response_size.py index cf186d8338..84d8282c7a 100644 --- a/tests/server/test_track_response_size.py +++ b/tests/server/test_track_response_size.py @@ -3,10 +3,12 @@ from __future__ import annotations import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import structlog +from autoskillit.config import OutputBudgetConfig from autoskillit.pipeline.mcp_response import DefaultMcpResponseLog pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -131,3 +133,114 @@ async def bad_handler(): assert data["exit_code"] == -1 assert data["subtype"] == "tool_exception" assert "user_visible_message" in data + + @pytest.mark.anyio + async def test_nonserializable_result_fails_closed_instead_of_returning_original(self): + from autoskillit.server._notify import track_response_size + + original = {"private": object()} + + @track_response_size("nonserializable_tool") + async def fake_handler(): + return original + + with patch("autoskillit.server._notify._get_ctx_or_none", return_value=None): + result = await fake_handler() + + assert result is not original + assert result["success"] is False + assert "serialization_failed" in result["error"] + + @pytest.mark.anyio + async def test_response_log_failure_is_nonfatal_and_does_not_log_exception_path(self): + from autoskillit.server._notify import track_response_size + + response_log = MagicMock() + response_log.record.side_effect = OSError("/private/project/response.json") + ctx = MagicMock( + response_log=response_log, + config=MagicMock(mcp_response=MagicMock(alert_threshold_tokens=0)), + ) + + @track_response_size("small_tool") + async def fake_handler(): + return "small" + + with ( + patch("autoskillit.server._notify._get_ctx_or_none", return_value=ctx), + structlog.testing.capture_logs() as logs, + ): + result = await fake_handler() + + assert result == "small" + assert "/private/project" not in repr(logs) + assert any(log["event"] == "track_response_size_telemetry_failed" for log in logs) + + @pytest.mark.anyio + async def test_notification_failure_is_nonfatal_and_does_not_log_exception_path(self): + from autoskillit.server._notify import track_response_size + + class FakeContext: + pass + + response_log = MagicMock() + response_log.record.return_value = True + ctx = MagicMock( + response_log=response_log, + config=MagicMock(mcp_response=MagicMock(alert_threshold_tokens=0)), + ) + + @track_response_size("notified_tool") + async def fake_handler(_mcp_ctx): + return "small" + + with ( + patch("autoskillit.server._notify._get_ctx_or_none", return_value=ctx), + patch("fastmcp.Context", FakeContext), + patch( + "autoskillit.server._notify._notify", + new=AsyncMock(side_effect=RuntimeError("/private/project/session.json")), + ), + structlog.testing.capture_logs() as logs, + ): + result = await fake_handler(FakeContext()) + + assert result == "small" + assert "/private/project" not in repr(logs) + assert any(log["event"] == "track_response_size_notification_failed" for log in logs) + + @pytest.mark.anyio + async def test_unexpected_enforcement_failure_is_bounded_and_centrally_emitted(self): + from autoskillit.server._notify import track_response_size + + tool_name = "/private/tool/" + "x" * 200 + 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), + ), + ) + + @track_response_size(tool_name) + async def fake_handler(): + return "small" + + with ( + patch("autoskillit.server._notify._get_ctx_or_none", return_value=ctx), + patch( + "autoskillit.server._notify.enforce_response_budget", + side_effect=RuntimeError("/private/project/enforcement.log"), + ), + patch("autoskillit.server._response_budget.logger.info") as log_info, + ): + result = await fake_handler() + + assert len(result.encode("utf-8")) <= 200 + assert "/private/project" not in result + assert "/private/project" not in repr(log_info.call_args_list) + event = next( + call for call in log_info.call_args_list if call.args == ("response_budget_failure",) + ) + assert event.kwargs["cause"] == "internal_invariant_failed" + assert event.kwargs["original_utf8_bytes"] == len(b"small") diff --git a/tests/server/test_wire_compat.py b/tests/server/test_wire_compat.py index a335134d72..110566199d 100644 --- a/tests/server/test_wire_compat.py +++ b/tests/server/test_wire_compat.py @@ -1,10 +1,27 @@ from __future__ import annotations -from unittest.mock import MagicMock +import hashlib +import json +import typing +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock import pytest +from fastmcp.client import Client +from fastmcp.exceptions import ToolError from fastmcp.server.middleware import MiddlewareContext - +from fastmcp.tools.base import ToolResult +from fastmcp.tools.function_tool import FunctionTool +from mcp.types import CallToolRequestParams, TextContent + +from autoskillit.server._response_conformance import ( + _SCHEMA_NONCONFORMING_FAILURE, + _WRAPPED_STRING_OUTPUT_SCHEMA, + ResponseConformanceDecision, + ResponseConformanceMiddleware, + _converted_result_conforms, + decide_response_conformance, +) from autoskillit.server._wire_compat import ClaudeCodeCompatMiddleware pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -193,3 +210,232 @@ async def call_next(context): result = await mw(ctx, call_next) assert result[0].annotations is not None assert result[0].annotations.readOnlyHint is True + + +class TestResponseConformanceDecisionTable: + @pytest.mark.parametrize( + ("inputs", "expected"), + [ + ( + (False, True, True, True), + ResponseConformanceDecision.BYPASS_NON_FUNCTION_TOOL, + ), + ( + (True, False, True, True), + ResponseConformanceDecision.BYPASS_HANDLER_TYPE, + ), + ( + (True, True, False, True), + ResponseConformanceDecision.BYPASS_REGISTERED_SCHEMA, + ), + ( + (True, True, True, True), + ResponseConformanceDecision.CONFORMING, + ), + ( + (True, True, True, False), + ResponseConformanceDecision.REWRITE_NONCONFORMING, + ), + ], + ) + def test_decision_table(self, inputs, expected): + assert ( + decide_response_conformance( + is_function_tool=inputs[0], + handler_returns_exact_str=inputs[1], + registered_schema_is_wrapped_string=inputs[2], + converted_result_conforms=inputs[3], + ) + is expected + ) + + +class TestResponseConformanceMiddleware: + @pytest.mark.anyio + async def test_registered_advertised_and_handler_contracts(self, kitchen_enabled): + from autoskillit.server import mcp + + registered = await mcp.get_tool("kitchen_status") + assert isinstance(registered, FunctionTool) + assert registered.output_schema == _WRAPPED_STRING_OUTPUT_SCHEMA + assert typing.get_type_hints(registered.fn)["return"] is str + + async with Client(mcp) as client: + advertised = {tool.name: tool for tool in await client.list_tools()} + + assert advertised["kitchen_status"].outputSchema is None + + @pytest.mark.anyio + async def test_all_registered_string_tools_are_conforming_decision_entries(self): + from autoskillit.core import ALL_VISIBILITY_TAGS + from autoskillit.server import mcp + + mcp._transforms.clear() + try: + mcp.enable(tags=set(ALL_VISIBILITY_TAGS)) + visible_tools = await mcp.list_tools() + assert visible_tools + + for visible_tool in visible_tools: + registered = await mcp.get_tool(visible_tool.name) + assert isinstance(registered, FunctionTool), visible_tool.name + handler_returns_str = typing.get_type_hints(registered.fn).get("return") is str + schema_is_wrapped = registered.output_schema == _WRAPPED_STRING_OUTPUT_SCHEMA + converted = registered.convert_result("registry-sweep") + converted_conforms = isinstance( + registered.output_schema, dict + ) and _converted_result_conforms(converted, registered.output_schema) + assert ( + decide_response_conformance( + is_function_tool=True, + handler_returns_exact_str=handler_returns_str, + registered_schema_is_wrapped_string=schema_is_wrapped, + converted_result_conforms=converted_conforms, + ) + is ResponseConformanceDecision.CONFORMING + ), visible_tool.name + finally: + mcp._transforms.clear() + + @pytest.mark.anyio + async def test_middleware_is_appended_once_after_wire_compat(self): + from autoskillit.server import mcp + + names = [type(middleware).__name__ for middleware in mcp.middleware] + assert names.count("ResponseConformanceMiddleware") == 1 + assert names.index("ResponseConformanceMiddleware") == ( + names.index("ClaudeCodeCompatMiddleware") + 1 + ) + + @pytest.mark.anyio + async def test_valid_converted_string_result_passes_through( + self, kitchen_enabled, monkeypatch + ): + from autoskillit.server import mcp + + payload = '{"success":true,"kind":"spill_or_exemption"}' + original_run = FunctionTool.run + + async def run(tool, arguments): + if tool.name == "kitchen_status": + return tool.convert_result(payload) + return await original_run(tool, arguments) + + monkeypatch.setattr(FunctionTool, "run", run) + async with Client(mcp) as client: + result = await client.call_tool("kitchen_status", {}) + + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == payload + assert result.structured_content == {"result": payload} + + @pytest.mark.anyio + async def test_nonconforming_conversion_is_replaced_without_original( + self, kitchen_enabled, monkeypatch + ): + from autoskillit.server import mcp + + original_run = FunctionTool.run + + async def run(tool, arguments): + if tool.name == "kitchen_status": + return ToolResult( + content=[TextContent(type="text", text="unrecoverable-original")], + structured_content={"result": "different-value"}, + ) + return await original_run(tool, arguments) + + monkeypatch.setattr(FunctionTool, "run", run) + async with Client(mcp) as client: + result = await client.call_tool("kitchen_status", {}) + + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == _SCHEMA_NONCONFORMING_FAILURE + assert "unrecoverable-original" not in result.content[0].text + assert result.structured_content == {"result": _SCHEMA_NONCONFORMING_FAILURE} + + @pytest.mark.anyio + async def test_real_client_boundary_spills_and_recovers_complete_producer_result( + self, + kitchen_enabled, + tool_ctx_kitchen_open, + monkeypatch, + tmp_path, + ): + from autoskillit.server import mcp + from autoskillit.server._response_budget import RESPONSE_SPILL_METADATA_KEY + from autoskillit.server.tools import tools_execution + + sentinels = ("HEAD-SENTINEL", "MIDDLE-SENTINEL", "TAIL-SENTINEL") + payload = { + "success": True, + "verdict": "GO", + "result": ( + sentinels[0] + ("x" * 30_000) + sentinels[1] + ("y" * 30_000) + sentinels[2] + ), + } + authoritative = json.dumps(payload) + monkeypatch.setattr( + tools_execution, + "_import_and_call", + AsyncMock(return_value=payload), + ) + + async with Client(mcp) as client: + result = await client.call_tool( + "run_python", + { + "callable": "probe.module.callable", + "work_dir": str(tmp_path), + }, + ) + + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + final_text = result.content[0].text + assert len(final_text.encode("utf-8")) <= ( + tool_ctx_kitchen_open.config.output_budget.response_max_bytes + ) + assert result.structured_content == {"result": final_text} + metadata = json.loads(final_text)[RESPONSE_SPILL_METADATA_KEY] + artifact = Path(metadata["artifact_path"]) + assert artifact.read_text() == authoritative + assert metadata["sha256"] == hashlib.sha256(authoritative.encode()).hexdigest() + for sentinel in sentinels: + assert sentinel in artifact.read_text() + + @pytest.mark.anyio + async def test_call_next_is_awaited_exactly_once(self, kitchen_enabled): + from autoskillit.server import mcp + + middleware = ResponseConformanceMiddleware(mcp) + registered = await mcp.get_tool("kitchen_status") + assert isinstance(registered, FunctionTool) + converted = registered.convert_result("valid") + call_next = AsyncMock(return_value=converted) + context = MiddlewareContext( + message=CallToolRequestParams(name="kitchen_status", arguments={}), + method="tools/call", + type="request", + ) + + assert await middleware.on_call_tool(context, call_next) is converted + call_next.assert_awaited_once_with(context) + + @pytest.mark.anyio + async def test_unknown_tool_not_found_is_unchanged(self): + from autoskillit.server import mcp + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="Unknown tool: 'not_a_real_tool'"): + await client.call_tool("not_a_real_tool", {}) + + @pytest.mark.anyio + async def test_disabled_tool_not_found_is_unchanged(self): + from autoskillit.server import mcp + + async with Client(mcp) as client: + with pytest.raises(ToolError, match="Unknown tool: 'kitchen_status'"): + await client.call_tool("kitchen_status", {}) diff --git a/tests/test_test_filter_core_cascade.py b/tests/test_test_filter_core_cascade.py index b82bff66f4..d41de6d038 100644 --- a/tests/test_test_filter_core_cascade.py +++ b/tests/test_test_filter_core_cascade.py @@ -217,6 +217,7 @@ def test_type_constants_registries_cascade(self) -> None: "cli", "config", "core", + "execution", "pipeline", "recipe", "server", @@ -671,7 +672,7 @@ def test_type_constants_features_narrow_cascade(self, tmp_path: Path) -> None: ) def test_type_constants_registries_narrow_cascade(self, tmp_path: Path) -> None: - """_type_constants_registries → narrow cascade of 7 dirs.""" + """_type_constants_registries → narrow cascade of 8 dirs.""" tests_root = self._make_tests_root(tmp_path, self.ALL_DIRS) result = build_test_scope( changed_files={"src/autoskillit/core/types/_type_constants_registries.py"}, @@ -680,9 +681,18 @@ def test_type_constants_registries_narrow_cascade(self, tmp_path: Path) -> None: ) assert result is not None dir_names = {p.name for p in result} - for pkg in ["core", "cli", "config", "pipeline", "recipe", "server", "workspace"]: + for pkg in [ + "core", + "cli", + "config", + "execution", + "pipeline", + "recipe", + "server", + "workspace", + ]: assert pkg in dir_names, f"_type_constants_registries cascade should include {pkg}" - for excluded in ["execution", "fleet", "migration", "planner", "hooks"]: + for excluded in ["fleet", "migration", "planner", "hooks"]: assert excluded not in dir_names, ( f"_type_constants_registries cascade should not include {excluded}" ) From bc357c3ca49737135cb9b5be31b748443c7ffde6 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 09:12:15 -0700 Subject: [PATCH 06/30] test: route output budget evidence changes --- .autoskillit/test-filter-manifest.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.autoskillit/test-filter-manifest.yaml b/.autoskillit/test-filter-manifest.yaml index 697c36fde9..52def2ee5f 100644 --- a/.autoskillit/test-filter-manifest.yaml +++ b/.autoskillit/test-filter-manifest.yaml @@ -169,6 +169,9 @@ tests/execution/backends/fixtures/codex_ndjson/*.json: .autoskillit/config.yaml: - infra/ +.autoskillit/evidence/output-budget-remediation/**/*: + - infra/ + .autoskillit/recipes/*.yaml: - recipe/ From 54cb7430c2880658c8e96f1f3804ca511e13c101 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 09:36:04 -0700 Subject: [PATCH 07/30] fix: close JSONL command guard bypasses --- .../output-budget-remediation/manifest.json | 26 +- .../phase1-pre-commit-016f88931.log | 14 + .../phase1-task-test-all-016f88931.log | 443 ++++++++++++++++++ .../hooks/guards/output_budget_guard.py | 45 +- tests/hooks/test_output_budget_guard.py | 129 +++++ tests/infra/test_release_sanity.py | 10 +- 6 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 .autoskillit/evidence/output-budget-remediation/phase1-pre-commit-016f88931.log create mode 100644 .autoskillit/evidence/output-budget-remediation/phase1-task-test-all-016f88931.log diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json index 5b8ebdd9c6..a4903ccb7f 100644 --- a/.autoskillit/evidence/output-budget-remediation/manifest.json +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -43,7 +43,31 @@ "gate_log_path": "temp/test-2026-07-15_224649.txt", "gate_log_sha256": "110cc8fc22aa832efcc461166f3c95052ec39fabc33ac95410fe8ac2697c0a16" }, - "phases": [], + "phases": [ + { + "phase": 1, + "phase_commit_sha": "016f88931997347705586c00834d9ef8b6e4003c", + "gate_tested_sha": "016f88931997347705586c00834d9ef8b6e4003c", + "gates": [ + { + "command": "task test-all", + "status": "pass", + "summary": "30912 passed, 611 skipped, 55 xfailed", + "gate_tested_sha": "016f88931997347705586c00834d9ef8b6e4003c", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase1-task-test-all-016f88931.log", + "gate_log_sha256": "ab8f5a0c0958e78d23c9075eaabd773ba76f3eff93ff8d8f97e3b5aa27501c34" + }, + { + "command": "pre-commit run --all-files", + "status": "pass", + "summary": "all configured hooks passed", + "gate_tested_sha": "016f88931997347705586c00834d9ef8b6e4003c", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase1-pre-commit-016f88931.log", + "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" + } + ] + } + ], "closure": { "historical_mutation_transcript": "non-reconstructible", "historical_artifacts": [ diff --git a/.autoskillit/evidence/output-budget-remediation/phase1-pre-commit-016f88931.log b/.autoskillit/evidence/output-budget-remediation/phase1-pre-commit-016f88931.log new file mode 100644 index 0000000000..6ced427ffc --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase1-pre-commit-016f88931.log @@ -0,0 +1,14 @@ +ruff format..............................................................Passed +ruff.....................................................................Passed +mypy.....................................................................Passed +uv lock --check..........................................................Passed +Block generated config files from being committed....(no files to check)Skipped +doc count accuracy.......................................................Passed +Check version consistency across artifacts...............................Passed +Check MCP tool readOnlyHint annotations..............(no files to check)Skipped +Check sub-AGENTS.md file table completeness..............................Passed +Check contract card freshness............................................Passed +Check __init__.pyi stub format...........................................Passed +Check __init__.pyi stub symbol completeness..............................Passed +check for merge conflicts................................................Passed +Detect hardcoded secrets.................................................Passed diff --git a/.autoskillit/evidence/output-budget-remediation/phase1-task-test-all-016f88931.log b/.autoskillit/evidence/output-budget-remediation/phase1-task-test-all-016f88931.log new file mode 100644 index 0000000000..a6b9477ac3 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase1-task-test-all-016f88931.log @@ -0,0 +1,443 @@ +bringing up nodes... +bringing up nodes... + +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 1%] +.......................................s...................sss.s....ss.s [ 1%] +sssssssssssssss.ss.........s....ss.....sssssssssss...ss.s........s.....s [ 1%] +s....ssssss.sssssssss................................................... [ 1%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +.................................................s...................... [ 10%] +........................................................................ [ 11%] +........................................................................ [ 11%] +........................................................................ [ 11%] +........................................................................ [ 11%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 15%] +........................................................................ [ 15%] +........................................................................ [ 15%] +..s...s..s.....s..s.s..s..s.s..s........................................ [ 15%] +........................................................................ [ 15%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +............................s.....s..................................... [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +..........................s............................................. [ 23%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 25%] +........................................................................ [ 25%] +..............................................................ssssssssss [ 25%] +ss.ssssssssssssssssssss.ssssssssssssssssss.ssssssssssssssssss.ssssssssss [ 25%] +ssssssss.sssssssssssssssss.sssssssssssss.s..ssssssssssssssss.sssss...... [ 25%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +....................................................sss................. [ 27%] +.......................................................................x [ 27%] +........................................................................ [ 27%] +........................................................................ [ 27%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 32%] +........................................................................ [ 32%] +.....................................................s.................. [ 32%] +........................................................................ [ 32%] +........................................................................ [ 33%] +........................................................................ [ 33%] +.............s.......................................................... [ 33%] +........................................................................ [ 33%] +........................................................................ [ 33%] +......................................................s................. [ 34%] +........................................................................ [ 34%] +........................................................................ [ 34%] +........................................................................ [ 34%] +....................ss.s.sss.sssssssssssssssssssssssssssss.............. [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 44%] +........................................................................ [ 44%] +........................................................................ [ 44%] +........................................................................ [ 44%] +xx.x.x....x.x...xx..x.x................................................. [ 44%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 47%] +........................................................................ [ 47%] +..................................................sss......s.ss...sss... [ 47%] +s.ss...............................s.....ss...s............ss..ss....... [ 47%] +...s.........s..........s....s...s...ss....s.........s.s...........s.... [ 48%] +..................s......ss....s..s........................s....s...s... [ 48%] +s.........s....s....s....s.........s...........s...........s............ [ 48%] +..........s.s........s.........s...s.................................... [ 48%] +............s......ss...............s...........s......s......s......... [ 49%] +.........s...ss......s....s....s..........s............................. [ 49%] +s.....................ss.s......s......s................................ [ 49%] +s........s.............................................................. [ 49%] +........................................................................ [ 49%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 52%] +........................................................................ [ 52%] +..............ss......................................................x. [ 52%] +........x...x.x.x....x...xx.xxxxxxx.x.......xxxxx....................... [ 52%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 56%] +................................................s....s.................. [ 56%] +........................................................................ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 58%] +........................................................................ [ 58%] +...............s..................................s..................... [ 58%] +........................................................................ [ 58%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 61%] +........................................................................ [ 61%] +.............................................................s.......... [ 61%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 63%] +........................................................................ [ 63%] +......................................................xx................ [ 63%] +..................x..x...........................xxx..x..x....xx...x.... [ 63%] +.x.x.x.................................................................. [ 64%] +........................................................................ [ 64%] +........................................................................ [ 64%] +........................................................................ [ 64%] +........................................................................ [ 64%] +........................................................................ [ 65%] +........................................................................ [ 65%] +........................................................................ [ 65%] +........................................................................ [ 65%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 67%] +.....................................................................x.. [ 67%] +x..x.................................................................... [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 70%] +........................................................................ [ 70%] +.................................................ssss.sssssssssssss.sss. [ 70%] +ss.s...sssss.s.s.s.sssssssssssssssssssss.s..s.sss.ss.ss.ss.sssssssssssss [ 70%] +sss.ss..s.ss.ss...sss..ssss..sss..ss.sssssssssssssss.................... [ 70%] +........................................................................ [ 71%] +........................................................................ [ 71%] +........................................................................ [ 71%] +........................................................................ [ 71%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 78%] +........................................................................ [ 78%] +.....ssss.ss.ss.s.ss..ss..sss.s.s.s.ss.s.s.s.s.ss.s...s..s...s.s.s.s.ss. [ 78%] +.s.s.............................................................s.s.ss. [ 78%] +......s.s...s.s.s..s.s.s.s.s.s............s.s.....ss.s.s..ss.s.s.s...s.. [ 79%] +s..ss.s.s.s.s.ss.s...................................................... [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +......................................ssssssssssssssss.................. [ 81%] +.....................s.s................................................ [ 81%] +................................................s....................... [ 81%] +........................................................................ [ 81%] +........................................................................ [ 82%] +..............................................................s.ss...s.s [ 82%] +..s.ss.s.ss.s.s.ss.s.sss.s.s.s.s.s....s.s.sssssss...sssssssss..s........ [ 82%] +........................................................................ [ 82%] +........................................................................ [ 82%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 84%] +........................................................................ [ 84%] +........................................................................ [ 84%] +..ss.................................................................... [ 84%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........s...............s............................................... [ 88%] +........................................................................ [ 88%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 90%] +...................................................s.s.................. [ 90%] +.........s.......s.s....................s.s............s......s..s...... [ 90%] +s...................ss.................s.s.............ss............... [ 90%] +........................................................................ [ 90%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 94%] +........................................................s............... [ 94%] +........................................................................ [ 94%] +........................................................................ [ 94%] +........................................................................ [ 95%] +............................................xx.......................... [ 95%] +.............................xx.xsss.................................... [ 95%] +........................................................................ [ 95%] +........................................................................ [ 95%] +........................................................................ [ 96%] +........................................................................ [ 96%] +...............................................................s........ [ 96%] +........................................................................ [ 96%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +......................................... [100%] +30912 passed, 611 skipped, 55 xfailed, 2166 warnings in 200.54s (0:03:20) diff --git a/src/autoskillit/hooks/guards/output_budget_guard.py b/src/autoskillit/hooks/guards/output_budget_guard.py index e0c02ce9ba..2b669e8889 100644 --- a/src/autoskillit/hooks/guards/output_budget_guard.py +++ b/src/autoskillit/hooks/guards/output_budget_guard.py @@ -37,7 +37,7 @@ _DEFAULT_SHELL_MAX_INLINE_BYTES = 12_000 _JSONL_PRODUCERS: frozenset[str] = frozenset({"cat", "rg", "grep", "sed", "awk", "jq"}) _GLOB_CHARS = frozenset("*?[]{}") -_RISKY_SURFACE_RE = re.compile(r"\b(?:rg|grep|cat|sed|awk|jq|find)\b") +_RISKY_SURFACE_RE = re.compile(r"\b(?:rg|grep|cat|sed|awk|jq|find|wc)\b") _RG_FLAGS_WITH_VALUE: frozenset[str] = frozenset( { @@ -196,8 +196,30 @@ def _is_plain_cat(args: list[str], targets: list[str]) -> bool: return bool(targets) and remaining == targets -def _is_wc_lines(args: list[str], targets: list[str]) -> bool: - return bool(targets) and all(arg in {"-l", "--lines"} or arg in targets for arg in args) +def _wc_line_operands(args: list[str]) -> tuple[bool, bool, list[str]]: + """Return line-mode, option validity, and every operand after wc parsing.""" + line_mode = False + valid_options = True + operands: list[str] = [] + options_ended = False + for arg in args: + if not options_ended and arg == "--": + options_ended = True + elif not options_ended and arg in {"-l", "--lines"}: + line_mode = True + elif not options_ended and arg.startswith("-") and arg != "-": + valid_options = False + else: + operands.append(arg) + return line_mode, valid_options, operands + + +def _is_jsonl_operand(raw: str) -> bool: + return Path(raw).suffix.lower() == ".jsonl" + + +def _is_dynamic_operand(raw: str) -> bool: + return raw == "-" or "$" in raw or any(char in raw for char in _GLOB_CHARS) def _producer_classifier( @@ -230,8 +252,21 @@ def _producer_classifier( else: profiles.append((False, False)) - if verb == "wc" and targets and _is_wc_lines(args, targets): - profiles.append((True, True)) + if verb == "wc": + line_mode, valid_options, operands = _wc_line_operands(args) + jsonl_derived = any(_is_jsonl_operand(arg) for arg in operands) + ambiguous = not operands or any(_is_dynamic_operand(arg) for arg in operands) + if line_mode and jsonl_derived: + intrinsically_bounded = ( + valid_options + and all(_is_jsonl_operand(arg) for arg in operands) + and _literal_small_jsonl_files(operands, cwd, small_file_max_bytes) + ) + profiles.append((intrinsically_bounded, intrinsically_bounded)) + elif line_mode and ambiguous: + profiles.append((False, False)) + elif jsonl_derived: + profiles.append((False, False)) if verb == "find" and args and _looks_like_directory(args[0], cwd): profiles.append(("-quit" in args, False)) diff --git a/tests/hooks/test_output_budget_guard.py b/tests/hooks/test_output_budget_guard.py index b0e6788366..7f0c59e29f 100644 --- a/tests/hooks/test_output_budget_guard.py +++ b/tests/hooks/test_output_budget_guard.py @@ -1,15 +1,47 @@ """Focused behavior tests for the output-budget PreToolUse guard.""" +# ruff: noqa: E501 -- verbatim incident commands intentionally preserve long lines. + from __future__ import annotations import io import json +import shutil from contextlib import redirect_stdout +from pathlib import Path import pytest pytestmark = [pytest.mark.layer("hooks"), pytest.mark.small] +INCIDENT_LOG_SEARCH = r"""for base in /home/talon/.local/share/autoskillit/logs \ + /home/talon/Library/Application\ Support/autoskillit/logs; do + if [ -d "$base" ]; then + rg -n -F \ + -e '019f669e-dce8-72a0-9d40-c564681bd145' \ + -e '019f66ba-4c32-7800-b8cf-787d47abaf61' \ + -e 'remediation-4226' \ + "$base/sessions.jsonl" "$base" \ + --glob '*.jsonl' \ + --glob '!codex-sessions/**' 2>/dev/null | + head -n 200 + fi +done""" + +INCIDENT_RESOLVE_REVIEW_SEARCH = """nl -ba src/autoskillit/skills_extended/resolve-review/SKILL.md | + sed -n '1,300p' && +rg -n \ + "resolve-review|expected_output_patterns|verdict" \ + src/autoskillit/skills_extended/resolve-review \ + src/autoskillit/recipes \ + src/autoskillit/recipe \ + tests/contracts \ + tests/server \ + --glob '*.yaml' \ + --glob '*.md' \ + --glob '*.json' \ + --glob '*.py'""" + def _build_event(command: str, *, run_cmd: bool = False) -> dict: key = "cmd" if run_cmd else "command" @@ -63,6 +95,16 @@ def test_reads_both_command_input_keys(run_cmd, monkeypatch, tmp_path): assert _is_denied(_run_hook(_build_event("rg pat src/", run_cmd=run_cmd), monkeypatch)) +@pytest.mark.parametrize("command", [INCIDENT_LOG_SEARCH, INCIDENT_RESOLVE_REVIEW_SEARCH]) +@pytest.mark.parametrize("run_cmd", [False, True]) +def test_denies_verbatim_incident_commands_with_concrete_rewrite(command, run_cmd, monkeypatch): + output = _run_hook(_build_event(command, run_cmd=run_cmd), monkeypatch) + assert _is_denied(output) + reason = json.loads(output)["hookSpecificOutput"]["permissionDecisionReason"] + assert "2>&1 | head -c 4000" in reason + assert ".autoskillit/temp/" in reason + + @pytest.mark.parametrize( "command", [ @@ -118,12 +160,99 @@ def test_small_jsonl_exception_is_narrow(monkeypatch, tmp_path): ) +@pytest.mark.parametrize("line_flag", ["-l", "--lines"]) +def test_wc_lines_allows_literal_in_project_jsonl_files(line_flag, monkeypatch, tmp_path): + first = tmp_path / "first.jsonl" + second = tmp_path / "second.jsonl" + plain = tmp_path / "plain.txt" + first.write_bytes(b"x" * 2_000) + second.write_bytes(b"y" * 3_000) + plain.write_text("plain\n") + + assert _run_hook(_build_event(f"wc {line_flag} {first.name}"), monkeypatch) == "" + assert _run_hook(_build_event(f"wc {line_flag} {plain.name}"), monkeypatch) == "" + assert ( + _run_hook( + _build_event(f"wc {line_flag} -- {first.name} {second.name}"), + monkeypatch, + ) + == "" + ) + + +def test_wc_lines_denies_nonliteral_or_unsafe_jsonl_operands(monkeypatch, tmp_path): + small = tmp_path / "small.jsonl" + small.write_text("{}\n") + plain = tmp_path / "plain.txt" + plain.write_text("plain\n") + outside = tmp_path.parent / f"{tmp_path.name}-outside.jsonl" + outside.write_text("{}\n") + symlink = tmp_path / "link.jsonl" + symlink.symlink_to(small) + oversized = tmp_path / "oversized.jsonl" + oversized.write_bytes(b"x" * 5_001) + aggregate_a = tmp_path / "aggregate-a.jsonl" + aggregate_b = tmp_path / "aggregate-b.jsonl" + aggregate_a.write_bytes(b"a" * 2_501) + aggregate_b.write_bytes(b"b" * 2_500) + + commands = [ + "wc -l missing.jsonl", + f"wc -l {outside}", + f"wc -l {symlink.name}", + "wc -l *.jsonl", + "wc -l -", + "wc -l", + "wc -l $TARGET.jsonl", + f"wc -l {small.name} {plain.name}", + f"wc -l --bytes {small.name}", + f"wc -c {small.name}", + f"wc -l {oversized.name}", + f"wc --lines {aggregate_a.name} {aggregate_b.name}", + ] + for command in commands: + assert _is_denied(_run_hook(_build_event(command), monkeypatch)), command + + +@pytest.mark.parametrize( + "command", + [ + "wc -l missing.jsonl 2>&1 | head -c 4000", + "wc --lines *.jsonl 1>.autoskillit/temp/wc.out 2>.autoskillit/temp/wc.err", + ], +) +def test_wc_lines_unsafe_operands_can_use_complete_byte_bound(command, monkeypatch, tmp_path): + (tmp_path / ".autoskillit" / "temp").mkdir(parents=True) + assert _run_hook(_build_event(command), monkeypatch) == "" + + def test_large_jsonl_is_denied(monkeypatch, tmp_path): path = tmp_path / "large.jsonl" path.write_text('{"value": "' + ("x" * 5_100) + '"}\n') assert _is_denied(_run_hook(_build_event(f"cat {path.name}"), monkeypatch)) +def test_real_large_jsonl_fixture_is_exercised_through_guard(monkeypatch, tmp_path): + source = Path(__file__).parents[1] / "fixtures" / "codex" / "large_embedded_payload_v1.jsonl" + target = tmp_path / source.name + shutil.copyfile(source, target) + assert target.stat().st_size > 5_000 + + for command in ( + f"rg -n payload {target.name}", + f"cat {target.name}", + f"jq -r .payload {target.name}", + ): + assert _is_denied(_run_hook(_build_event(command), monkeypatch)), command + assert ( + _run_hook( + _build_event(f"jq -r .payload {target.name} 2>&1 | head -c 4000"), + monkeypatch, + ) + == "" + ) + + def test_symlink_jsonl_cannot_use_small_file_exception(monkeypatch, tmp_path): target = tmp_path / "target.jsonl" target.write_text("{}\n") diff --git a/tests/infra/test_release_sanity.py b/tests/infra/test_release_sanity.py index 34d0aca8b5..87e5589a6f 100644 --- a/tests/infra/test_release_sanity.py +++ b/tests/infra/test_release_sanity.py @@ -8,6 +8,7 @@ pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] REPO_ROOT = Path(__file__).parent.parent.parent +_PERSONAL_HOME_PATH_FIXTURES = frozenset({Path("tests/hooks/test_output_budget_guard.py")}) def test_no_personal_home_paths_in_test_files(): @@ -17,9 +18,14 @@ def test_no_personal_home_paths_in_test_files(): tests_dir = REPO_ROOT / "tests" hits = [] for py_file in tests_dir.rglob("*.py"): + relative_path = py_file.relative_to(REPO_ROOT) for lineno, line in enumerate(py_file.read_text(errors="replace").splitlines(), 1): - if personal_prefix in line and not line.strip().startswith("#"): - hits.append(f"{py_file.relative_to(REPO_ROOT)}:{lineno}: {line.rstrip()}") + if ( + personal_prefix in line + and not line.strip().startswith("#") + and relative_path not in _PERSONAL_HOME_PATH_FIXTURES + ): + hits.append(f"{relative_path}:{lineno}: {line.rstrip()}") assert hits == [], "Personal home paths found in tests:\n" + "\n".join(hits) From 033fed64c197e546c01c8f6cdc4808089e97c421 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 09:52:50 -0700 Subject: [PATCH 08/30] test: require installed Codex config parsing --- .../output-budget-remediation/manifest.json | 23 + .../phase2-pre-commit-a1a129d37.log | 14 + .../phase2-task-test-all-a1a129d37.log | 443 ++++++++++++++++++ .github/workflows/conformance-probes.yml | 3 + Taskfile.yml | 6 + tests/execution/AGENTS.md | 2 +- .../backends/test_cli_conformance_probes.py | 31 -- .../backends/test_codex_interactive.py | 51 ++ tests/infra/AGENTS.md | 4 +- .../infra/test_conformance_probes_workflow.py | 23 + tests/infra/test_taskfile.py | 9 + 11 files changed, 575 insertions(+), 34 deletions(-) create mode 100644 .autoskillit/evidence/output-budget-remediation/phase2-pre-commit-a1a129d37.log create mode 100644 .autoskillit/evidence/output-budget-remediation/phase2-task-test-all-a1a129d37.log diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json index a4903ccb7f..97eb8faddb 100644 --- a/.autoskillit/evidence/output-budget-remediation/manifest.json +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -66,6 +66,29 @@ "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" } ] + }, + { + "phase": 2, + "phase_commit_sha": "a1a129d3719087a828d3a2352ff66477ee0aef48", + "gate_tested_sha": "a1a129d3719087a828d3a2352ff66477ee0aef48", + "gates": [ + { + "command": "task test-all", + "status": "pass", + "summary": "30924 passed, 611 skipped, 55 xfailed", + "gate_tested_sha": "a1a129d3719087a828d3a2352ff66477ee0aef48", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase2-task-test-all-a1a129d37.log", + "gate_log_sha256": "c5492d564a04f212a8910b3978219ff6fef3d1ad428264610541674d7a9cba6f" + }, + { + "command": "pre-commit run --all-files", + "status": "pass", + "summary": "all configured hooks passed", + "gate_tested_sha": "a1a129d3719087a828d3a2352ff66477ee0aef48", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase2-pre-commit-a1a129d37.log", + "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" + } + ] } ], "closure": { diff --git a/.autoskillit/evidence/output-budget-remediation/phase2-pre-commit-a1a129d37.log b/.autoskillit/evidence/output-budget-remediation/phase2-pre-commit-a1a129d37.log new file mode 100644 index 0000000000..6ced427ffc --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase2-pre-commit-a1a129d37.log @@ -0,0 +1,14 @@ +ruff format..............................................................Passed +ruff.....................................................................Passed +mypy.....................................................................Passed +uv lock --check..........................................................Passed +Block generated config files from being committed....(no files to check)Skipped +doc count accuracy.......................................................Passed +Check version consistency across artifacts...............................Passed +Check MCP tool readOnlyHint annotations..............(no files to check)Skipped +Check sub-AGENTS.md file table completeness..............................Passed +Check contract card freshness............................................Passed +Check __init__.pyi stub format...........................................Passed +Check __init__.pyi stub symbol completeness..............................Passed +check for merge conflicts................................................Passed +Detect hardcoded secrets.................................................Passed diff --git a/.autoskillit/evidence/output-budget-remediation/phase2-task-test-all-a1a129d37.log b/.autoskillit/evidence/output-budget-remediation/phase2-task-test-all-a1a129d37.log new file mode 100644 index 0000000000..abfe160df1 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase2-task-test-all-a1a129d37.log @@ -0,0 +1,443 @@ +bringing up nodes... +bringing up nodes... + +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 1%] +....................................s.....................s.ss.s....ssss [ 1%] +ss.ssssssssss.ssss......s....ss.....ssss.sssssss..s.s.s......s.....ss... [ 1%] +.sssssss.ssssssss....................................................... [ 1%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 11%] +........................................................................ [ 11%] +.........................................s.............................. [ 11%] +........................................................................ [ 11%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 15%] +........................................................................ [ 15%] +................................................................s..s.... [ 15%] +s.....s..ss..s..s...s.s................................................. [ 15%] +........................................................................ [ 15%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +............................................................s.....s..... [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +..........................................s............................. [ 23%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 25%] +........................................................................ [ 25%] +........................................................................ [ 25%] +.sssss.ssssssssssssssssss.sssssssssssssssssss.ssssssssssssssssss.sssssss [ 25%] +ss.ssssssssss.sssssssssss.ssssssssss.sssssssss.sssssss.ss.ssssss.ssss.ss [ 25%] +ssssssss................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 27%] +..ss.s....................................x............................. [ 27%] +........................................................................ [ 27%] +........................................................................ [ 27%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 32%] +........................................................................ [ 32%] +...........................s............................................ [ 32%] +........................................................................ [ 32%] +........................................................................ [ 33%] +........................................................................ [ 33%] +........................................................................ [ 33%] +........................................s............................... [ 33%] +........................................................................ [ 33%] +............................................................s........... [ 34%] +........................................................................ [ 34%] +........................................................................ [ 34%] +........................................................................ [ 34%] +.............................sssssssssssssssssssssssssssssssssss........ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 44%] +........................................................................ [ 44%] +........................................................................ [ 44%] +..................................................x.x.xx......xx...x.x.x [ 44%] +.x...................................................................... [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 47%] +........................................................................ [ 47%] +..........................................................sss.......s.s. [ 47%] +s......sss....sss....................................................... [ 47%] +............s....ss...s...........ss.ss.........s.....s......s..s.s..ss. [ 48%] +.s.....s.s.........s.....................s..........s.s......s....s..... [ 48%] +....................s...s...s...s...............s.......s........s....s. [ 48%] +........s........s.......s...............ss....s.....s..s............... [ 48%] +.............................s...ss...........s.........s......s......s. [ 49%] +.................s...ss.....s.....s....s.........s...................... [ 49%] +........s.....................ss.s......s......s........................ [ 49%] +.......s........s....................................................... [ 49%] +........................................................................ [ 49%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 52%] +........................................................................ [ 52%] +......................ss................................................ [ 52%] +...............x.......x..xxx..x.xx..xxxxxxxx.......xxxxx............... [ 52%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 56%] +.................................................s....s................. [ 56%] +........................................................................ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 57%] +...........................s.................s.......................... [ 57%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 61%] +.....s.................................................................. [ 61%] +........................................................................ [ 61%] +........................................................................ [ 61%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 63%] +........................................................................ [ 63%] +........................................................................ [ 63%] +........................................................................ [ 63%] +...........................................................xx........... [ 64%] +.................................................x.....x................ [ 64%] +..........................xxx...x...x....xx...x...........xx.x.......... [ 64%] +........................................................................ [ 64%] +........................................................................ [ 64%] +........................................................................ [ 65%] +........................................................................ [ 65%] +........................................................................ [ 65%] +.............................x...x...x.................................. [ 65%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 70%] +........................................................................ [ 70%] +........................................................................ [ 70%] +........................................................................ [ 70%] +....................................................ssss.ss.s.s.s.s.s.s. [ 71%] +ssssssss.sss..sssss.s.s.s.sssss.ssssssssssssssss.s..s.sss.ss.ss.s.s.ssss [ 71%] +ssssssssssss.ss..sss.ss....sss..ssss..sss..sssssssssssss.ssss........... [ 71%] +........................................................................ [ 71%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +....................................................................ss.s [ 77%] +s.s.ss.ss...s..s....s.s.s.ss.s.s..s.s.ssssssss.ss.s.sssss.ss....ssss.... [ 78%] +ss..s.s.s.ssssss.....ss.sss.s.s.s.s.s.s.s.....................ssss.sssss [ 78%] +.s...................................................................... [ 78%] +........................................................................ [ 78%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +....................................................ssssssssssssssss.... [ 80%] +................................s.s..................................... [ 81%] +........................................................................ [ 81%] +...................................................................s.... [ 81%] +........................................................................ [ 81%] +........................................................................ [ 82%] +..............................................sss.ss..sssssss.ssssssssss [ 82%] +ss....s.s.ssssss.s...sssssss.ss..s...................................... [ 82%] +........................................................................ [ 82%] +........................................................................ [ 82%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 84%] +........................................................................ [ 84%] +........................................................................ [ 84%] +......................................................ss................ [ 84%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 87%] +..................................ss.................................... [ 87%] +........................................................................ [ 87%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................s..........s.................... [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 90%] +.......s......s.s...........ss.......s...s.s....s................ss..... [ 90%] +...........s..s...........s.s........................................... [ 90%] +........................................................................ [ 90%] +........................................................................ [ 90%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 94%] +........................................................................ [ 94%] +............................................................s........... [ 94%] +........................................................................ [ 94%] +........................................................................ [ 95%] +........................................................................ [ 95%] +........................................................................ [ 95%] +........................................................................ [ 95%] +...........................................................x.x.......... [ 95%] +........................................................................ [ 96%] +...........................................xxxsss....................... [ 96%] +........................................................................ [ 96%] +........................................................................ [ 96%] +...s.................................................................... [ 97%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +..................................................... [100%] +30924 passed, 611 skipped, 55 xfailed, 2170 warnings in 195.66s (0:03:15) diff --git a/.github/workflows/conformance-probes.yml b/.github/workflows/conformance-probes.yml index 563013d99c..478af9ae1f 100644 --- a/.github/workflows/conformance-probes.yml +++ b/.github/workflows/conformance-probes.yml @@ -67,6 +67,9 @@ jobs: fi echo "cli_version=${CLI_VERSION}" >> "$GITHUB_OUTPUT" + - name: Validate installed Codex config parsing + run: task test-codex-config-parse + - name: Export output discipline policy identity id: resolve-policy run: | diff --git a/Taskfile.yml b/Taskfile.yml index 52019569a0..be6878fe11 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -100,6 +100,12 @@ tasks: export AUTOSKILLIT_TEST_FILTER="${AUTOSKILLIT_TEST_FILTER:-aggressive}" task test-check + test-codex-config-parse: + desc: Validate generated interactive config overrides with the installed Codex CLI + cmds: + - | + PYTEST_TEST_PATHS="tests/execution/backends/test_codex_interactive.py" task test-all + coverage-audit: desc: "Run pytest with coverage collection and compare against AST-derived function map" deps: [install-worktree, _tmpdir-setup] diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index 7ed4b959f8..9bc3bd82c0 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -149,7 +149,7 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_claude_stream_parser.py` | Tests for ClaudeStreamParser | | `test_backend_registry.py` | Tests for backend registry | | `test_codex_backend.py` | Tests for CodexFlags, CodexBackend protocol conformance, headless/resume command builders, skill session cmd config adapter, food truck cmd builder | -| `test_codex_interactive.py` | Parametrized structural validation of CodexBackend.build_interactive_cmd — resume variants, model, system_prompt suppression, add_dirs | +| `test_codex_interactive.py` | Parametrized structural validation of CodexBackend.build_interactive_cmd plus the required installed-CLI parse gate for exact fresh config overrides | | `test_codex_session_locator.py` | Tests for CodexSessionLocator: locate_session walk, read_session decompression, codex_home constructor-field priority over env var, protocol conformance | | `test_codex_env_policy.py` | Tests for CodexEnvPolicy three-layer scrub | | `test_codex_mcp_registration.py` | Tests for ensure_codex_mcp_registered: file creation, TOML fields, idempotency, foreign section preservation, dir creation | diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index 070a87538f..566eda543f 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -78,41 +78,10 @@ reason=_CLAUDE_CODE_SKIP_REASON, ) -_skip_unless_codex_config_parse_probe = pytest.mark.skipif( - not os.environ.get("CODEX_CONFIG_PARSE_PROBE") or not shutil.which("codex"), - reason="Set CODEX_CONFIG_PARSE_PROBE=1 and have 'codex' on PATH to run the config probe", -) - _PROBE_BACKEND = "codex" _CANARY_TITLE_PREFIX = "[Canary] codex conformance probe" -@_skip_unless_codex_config_parse_probe -def test_installed_codex_parses_multiline_developer_instructions(tmp_path: Path) -> None: - """Guard the exact interactive ``-c`` TOML value accepted by installed Codex.""" - from autoskillit.core import OUTPUT_DISCIPLINE_DIGEST - from autoskillit.execution.backends._codex_config import _format_toml_value - - caller_prompt = 'caller "prompt"\nwith a second line and \\ path' - combined = f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}" - override = f"developer_instructions={_format_toml_value(combined)}" - env = dict(os.environ) - env["CODEX_HOME"] = str(tmp_path / "codex-home") - Path(env["CODEX_HOME"]).mkdir() - - result = subprocess.run( # noqa: S603 - ["codex", "-c", override, "doctor", "--json"], - capture_output=True, - text=True, - timeout=20, - env=env, - ) - - assert result.stdout, result.stderr - config_check = json.loads(result.stdout)["checks"]["config.load"] - assert config_check["status"] == "ok", config_check - - class _CodexProbeOutput(NamedTuple): events: list[dict] config_dict: dict diff --git a/tests/execution/backends/test_codex_interactive.py b/tests/execution/backends/test_codex_interactive.py index 31a0f1f2d4..b427b40fe8 100644 --- a/tests/execution/backends/test_codex_interactive.py +++ b/tests/execution/backends/test_codex_interactive.py @@ -1,5 +1,10 @@ from __future__ import annotations +import json +import os +import shutil +import subprocess +import tempfile import tomllib from pathlib import Path @@ -136,6 +141,52 @@ def test_system_prompt_override_is_valid_toml_with_quotes_and_newlines(self) -> ) assert _developer_instructions(spec) == (f"{caller_prompt}\n\n{OUTPUT_DISCIPLINE_DIGEST}") + def test_installed_codex_parses_exact_fresh_config_overrides(self, tmp_path: Path) -> None: + binary = shutil.which("codex") + if binary is None: + pytest.skip("installed Codex CLI is absent") + + caller_prompt = ( + 'caller """ prompt = "quoted"\n[features]\npath = C:\\temp\\$HOME\n# literal text' + ) + spec = CodexBackend().build_interactive_cmd( + system_prompt=caller_prompt, + resume_spec=NoResume(), + ) + config_pairs: list[str] = [] + for index, value in enumerate(spec.cmd[:-1]): + if value == CodexFlags.CONFIG_OVERRIDE: + config_pairs.extend(spec.cmd[index : index + 2]) + + overrides = config_pairs[1::2] + assert len(config_pairs) == 4 + assert len(overrides) == 2 + assert {value.partition("=")[0] for value in overrides} == { + "developer_instructions", + "features.image_generation", + } + assert caller_prompt in _developer_instructions(spec) + + project_temp = Path(__file__).resolve().parents[3] / ".autoskillit" / "temp" + project_temp.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="codex-config-parse-", dir=project_temp + ) as codex_home: + env = dict(os.environ) + env["CODEX_HOME"] = codex_home + result = subprocess.run( # noqa: S603 + [binary, *config_pairs, "doctor", "--json"], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=20, + ) + + assert result.stdout, result.stderr + config_check = json.loads(result.stdout)["checks"]["config.load"] + assert config_check["status"] == "ok", config_check + class TestCodexInteractiveCmdAddDirs: def test_single_dir_produces_add_dir_pair(self) -> None: diff --git a/tests/infra/AGENTS.md b/tests/infra/AGENTS.md index f4a2da877f..7321c3d446 100644 --- a/tests/infra/AGENTS.md +++ b/tests/infra/AGENTS.md @@ -25,7 +25,7 @@ CI/CD configuration, security, guard coverage, and release sanity tests. | `test_docs_critical_rules.py` | Tests that `AGENTS.md` (and a few physical-`CLAUDE.md` integrity checks) contain required critical rules (FRICT-1B-3, FRICT-3A-1, FRICT-5-2, FRICT-7-1) | | `test_command_guard_completeness.py` | Structural meta-test: command-inspecting guards must cover all command-executing tools | | `test_command_guard_verb_position.py` | Structural ratchet: command-inspecting guards must not perform raw substring membership against shell command text — guards must tokenize evaluated payloads and compare verb/argument positions | -| `test_conformance_probes_workflow.py` | Structural tests for conformance-probes.yml workflow — triggers, permissions, SHA pinning, cache gate, post-failure wiring | +| `test_conformance_probes_workflow.py` | Structural tests for conformance-probes.yml workflow — triggers, permissions, SHA pinning, installed Codex parse gate, cache gate, post-failure wiring | | `test_coverage_audit.py` | Tests for scripts/compare-coverage-ast.py — AST extraction and coverage comparison | | `test_dependency_pins.py` | Dependency pin guards (REQ-DEP-001, REQ-DEP-002) — pytest 9.x, networkx bounds | | `test_docstring_labels.py` | Tests for correct docstring layer labels across the codebase | @@ -70,7 +70,7 @@ CI/CD configuration, security, guard coverage, and release sanity tests. | `test_skill_cmd_check.py` | Unit tests for the skill_cmd_check PreToolUse hook | | `test_skill_load_guard.py` | Tests for guards/skill_load_guard.py PreToolUse hook — denies native tools until Skill called | | `test_skill_command_guard.py` | Tests for the skill_command_guard PreToolUse hook | -| `test_taskfile.py` | Taskfile structural tests | +| `test_taskfile.py` | Taskfile structural tests, including supported delegation for the installed Codex config-parse gate | | `test_testmon_eval.py` | Testmon eval tests | | `test_token_summary_core.py` | Tests: token_summary_appender core — early-exit, happy path, session filtering, efficiency table | | `test_token_summary_filters.py` | Tests: token_summary_appender unit helpers (_canonical, _humanize, _format_table, _unwrap_mcp_response), order_id isolation, and config key migration | diff --git a/tests/infra/test_conformance_probes_workflow.py b/tests/infra/test_conformance_probes_workflow.py index 69a1fe47de..8790abba62 100644 --- a/tests/infra/test_conformance_probes_workflow.py +++ b/tests/infra/test_conformance_probes_workflow.py @@ -130,6 +130,29 @@ def test_version_fallback_to_unknown(self, workflow: dict, job_name: str) -> Non class TestCacheGate: + def test_codex_config_parse_gate_precedes_cache_and_live_probe(self, workflow: dict) -> None: + steps = workflow["jobs"]["codex-probe"]["steps"] + parse_index, parse_step = next( + (index, step) + for index, step in enumerate(steps) + if step.get("name") == "Validate installed Codex config parsing" + ) + restore_index = next( + index + for index, step in enumerate(steps) + if "cache/restore" in (step.get("uses") or "") + ) + live_index = next( + index + for index, step in enumerate(steps) + if step.get("name") == "Run Codex conformance probes" + ) + + assert parse_step.get("run") == "task test-codex-config-parse" + assert "if" not in parse_step + assert parse_step.get("continue-on-error") is not True + assert parse_index < restore_index < live_index + @pytest.mark.parametrize("job_name", ["codex-probe", "claude-probe"]) def test_exports_output_discipline_policy_identity( self, workflow: dict, job_name: str diff --git a/tests/infra/test_taskfile.py b/tests/infra/test_taskfile.py index a619f0bf75..3fea9a5fe2 100644 --- a/tests/infra/test_taskfile.py +++ b/tests/infra/test_taskfile.py @@ -152,6 +152,15 @@ def test_compile_recipes_has_status_block(self): assert "status" in task, "compile-recipes must have a status: block" assert task["status"], "compile-recipes status: block must not be empty" + def test_codex_config_parse_target_uses_supported_test_gate(self) -> None: + data = self._load() + task = data["tasks"]["test-codex-config-parse"] + commands = "\n".join(str(command) for command in task.get("cmds", [])) + assert 'PYTEST_TEST_PATHS="tests/execution/backends/test_codex_interactive.py"' in commands + assert "task test-all" in commands + assert "python -m pytest" not in commands + assert re.search(r"(^|\s)pytest(?:\s|$)", commands) is None + def test_taskfile_pytest_paths_exist() -> None: """All pytest file paths in Taskfile.yml must exist.""" From 1d630fae2d5f8736d2810c080ff839c8e60416ab Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 11:04:01 -0700 Subject: [PATCH 09/30] test: record phase 3 gate evidence --- .../output-budget-remediation/manifest.json | 31 ++ .../phase3-codex-config-parse-a261f23fb.log | 5 + .../phase3-pre-commit-a261f23fb.log | 14 + .../phase3-task-test-all-a261f23fb.log | 443 ++++++++++++++++++ 4 files changed, 493 insertions(+) create mode 100644 .autoskillit/evidence/output-budget-remediation/phase3-codex-config-parse-a261f23fb.log create mode 100644 .autoskillit/evidence/output-budget-remediation/phase3-pre-commit-a261f23fb.log create mode 100644 .autoskillit/evidence/output-budget-remediation/phase3-task-test-all-a261f23fb.log diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json index 97eb8faddb..83fe9c953c 100644 --- a/.autoskillit/evidence/output-budget-remediation/manifest.json +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -89,6 +89,37 @@ "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" } ] + }, + { + "phase": 3, + "phase_commit_sha": "a261f23fb9f19337710ff9423cb89e0f780c784f", + "gate_tested_sha": "a261f23fb9f19337710ff9423cb89e0f780c784f", + "gates": [ + { + "command": "task test-codex-config-parse", + "status": "pass", + "summary": "23 passed with installed Codex CLI", + "gate_tested_sha": "a261f23fb9f19337710ff9423cb89e0f780c784f", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase3-codex-config-parse-a261f23fb.log", + "gate_log_sha256": "dc842555a8bc071f11cd589d796c4f2406dd704c0fcb20659b74ca6ec12dd62b" + }, + { + "command": "task test-all", + "status": "pass", + "summary": "30929 passed, 611 skipped, 55 xfailed", + "gate_tested_sha": "a261f23fb9f19337710ff9423cb89e0f780c784f", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase3-task-test-all-a261f23fb.log", + "gate_log_sha256": "9ea5c4e6f2ac4b2b0c591b1f4e15d1bde51c84a28da2592482b43fbb125b73bb" + }, + { + "command": "pre-commit run --all-files", + "status": "pass", + "summary": "all configured hooks passed", + "gate_tested_sha": "a261f23fb9f19337710ff9423cb89e0f780c784f", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase3-pre-commit-a261f23fb.log", + "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" + } + ] } ], "closure": { diff --git a/.autoskillit/evidence/output-budget-remediation/phase3-codex-config-parse-a261f23fb.log b/.autoskillit/evidence/output-budget-remediation/phase3-codex-config-parse-a261f23fb.log new file mode 100644 index 0000000000..7f5aa7e366 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase3-codex-config-parse-a261f23fb.log @@ -0,0 +1,5 @@ +bringing up nodes... +bringing up nodes... + +....................... [100%] +23 passed in 2.37s diff --git a/.autoskillit/evidence/output-budget-remediation/phase3-pre-commit-a261f23fb.log b/.autoskillit/evidence/output-budget-remediation/phase3-pre-commit-a261f23fb.log new file mode 100644 index 0000000000..6ced427ffc --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase3-pre-commit-a261f23fb.log @@ -0,0 +1,14 @@ +ruff format..............................................................Passed +ruff.....................................................................Passed +mypy.....................................................................Passed +uv lock --check..........................................................Passed +Block generated config files from being committed....(no files to check)Skipped +doc count accuracy.......................................................Passed +Check version consistency across artifacts...............................Passed +Check MCP tool readOnlyHint annotations..............(no files to check)Skipped +Check sub-AGENTS.md file table completeness..............................Passed +Check contract card freshness............................................Passed +Check __init__.pyi stub format...........................................Passed +Check __init__.pyi stub symbol completeness..............................Passed +check for merge conflicts................................................Passed +Detect hardcoded secrets.................................................Passed diff --git a/.autoskillit/evidence/output-budget-remediation/phase3-task-test-all-a261f23fb.log b/.autoskillit/evidence/output-budget-remediation/phase3-task-test-all-a261f23fb.log new file mode 100644 index 0000000000..1ec8c1fee0 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase3-task-test-all-a261f23fb.log @@ -0,0 +1,443 @@ +bringing up nodes... +bringing up nodes... + +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 0%] +........................................................................ [ 1%] +.................................s..................sss.s.....ssssssssss [ 1%] +ssss.s.s.ss.s.s.....s....ss....sssssssssss...ss.s......s.....ss....sssss [ 1%] +ssss.ssssss............................................................. [ 1%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 2%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 3%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 4%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 5%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 6%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 7%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 8%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 9%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 10%] +........................................................................ [ 11%] +........................................................................ [ 11%] +..............................................................s......... [ 11%] +........................................................................ [ 11%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 12%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 13%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 14%] +........................................................................ [ 15%] +........................................................................ [ 15%] +........................................................................ [ 15%] +........................................................s..s..s....s.s.. [ 15%] +s..s.s..s..s............................................................ [ 15%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 16%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 17%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 18%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 19%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 20%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +........................................................................ [ 21%] +..............................................................s....s.... [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 22%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +........................................................................ [ 23%] +..............................................s......................... [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 24%] +........................................................................ [ 25%] +........................................................................ [ 25%] +.................................sss.ssssssssssssssss.ssssssssssssss.sss [ 25%] +ssssssssssss.sssssssssssssss.ssssssssssssss.sssssssssssssss.ssssssssssss [ 25%] +ss.ssssssssss.ss.ssssssssssssss.ssssss.................................. [ 25%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +........................................................................ [ 26%] +...............................................................x........ [ 27%] +................................................sss..................... [ 27%] +........................................................................ [ 27%] +........................................................................ [ 27%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 28%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 29%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 30%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 31%] +........................................................................ [ 32%] +.................................................s...................... [ 32%] +........................................................................ [ 32%] +........................................................................ [ 32%] +........................................................................ [ 33%] +........................................................................ [ 33%] +.................................s...................................... [ 33%] +........................................................................ [ 33%] +........................................................................ [ 33%] +........................................................................ [ 34%] +........................................................................ [ 34%] +..............................................s......................... [ 34%] +........................................................................ [ 34%] +........sssssssssssssssssssssssssssssssssss............................. [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 35%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 36%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 37%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 38%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 39%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 40%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 41%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 42%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 43%] +........................................................................ [ 44%] +........................................................................ [ 44%] +.............................x.x..x.x......x.x...xxx.x.................. [ 44%] +........................................................................ [ 44%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 45%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 46%] +........................................................................ [ 47%] +........................................................................ [ 47%] +..................................................................sss... [ 47%] +...sss....s.ss...sss............................s....ss....s............ [ 47%] +s.s...s.s..........s.......s......s..s..s.ss.s...s.s.......s............ [ 48%] +....s..........ss.....s....s....................................s.....s. [ 48%] +....s.....s..........s....s....s....s.........s........s.......s........ [ 48%] +......ss.....s.....s..s................................................. [ 48%] +..........s......s.s..............s..........s.......s......s........... [ 48%] +................s.....s.s......s....s....s.........s.................... [ 49%] +........s.......................ss.s......s.......s..................... [ 49%] +.........s.......s...................................................... [ 49%] +........................................................................ [ 49%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 50%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 51%] +........................................................................ [ 52%] +....................................................................ss.. [ 52%] +........................................................................ [ 52%] +.................x...x.xxx..x.xxxxxxxxxx......xxxxx..................... [ 52%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 53%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 54%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 55%] +........................................................................ [ 56%] +...........................................s...............s............ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 56%] +........................................................................ [ 57%] +......................s.................s............................... [ 57%] +........................................................................ [ 57%] +........................................................................ [ 57%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 58%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 59%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 60%] +........................................................................ [ 61%] +.........s.............................................................. [ 61%] +........................................................................ [ 61%] +........................................................................ [ 61%] +........................................................................ [ 61%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 62%] +........................................................................ [ 63%] +........................................................................ [ 63%] +........................................................................ [ 63%] +........................................................................ [ 63%] +........................................................................ [ 64%] +....................................................xx.................. [ 64%] +..............x..x........................xxx..x..x...xx...x......xx...x [ 64%] +........................................................................ [ 64%] +........................................................................ [ 64%] +........................................................................ [ 65%] +..........................................................x...x..x...... [ 65%] +........................................................................ [ 65%] +........................................................................ [ 65%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 66%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 67%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 68%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 69%] +........................................................................ [ 70%] +........................................................................ [ 70%] +........................................................................ [ 70%] +........................................................................ [ 70%] +.................................................................ssss.ss [ 71%] +ssssssssssssss..sss..sssss.s.s.s.sssssssssssssss.ssssss.s...s.sss.ss.ss. [ 71%] +ss.ssssssss.s.s.s.s.s.s..s.s...s.s......s..s.s...s.s.........s.s.s.....s [ 71%] +.s.s..s.....s.s.s.....s.s.s.s..s.s.s.s.s.s.s.s.s.s.sss.................. [ 71%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 72%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 73%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 74%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 75%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 76%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +........................................................................ [ 77%] +...................................s.ssssssss.ss.s.ssssss.s.ssssss.sss.. [ 77%] +s.s...ss..ss.s.ss.s.......s.ss.s.......s.s...ss.ss.s.ss.s.s.......s.s... [ 78%] +.s.s.s.s..ss.ss.s.s..s..s.s.sss.s.ss.s.................................. [ 78%] +........................................................................ [ 78%] +........................................................................ [ 78%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 79%] +........................................................................ [ 80%] +........................................................................ [ 80%] +........................................................................ [ 80%] +.......................................................ssssssssssssssss. [ 80%] +....................................s.s................................. [ 81%] +........................................................................ [ 81%] +........................................................................ [ 81%] +..................................................................s..... [ 81%] +........................................................................ [ 82%] +............................................................sss...ss.sss [ 82%] +ssss.ssssssssss.ss...ssss.sssss..sss.ssssss....s........................ [ 82%] +........................................................................ [ 82%] +........................................................................ [ 82%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 83%] +........................................................................ [ 84%] +........................................................................ [ 84%] +........................................................................ [ 84%] +........................................................................ [ 84%] +.......................................s.s.............................. [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 85%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 86%] +........................................................................ [ 87%] +...................................................s.s.................. [ 87%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 87%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................................................ [ 88%] +........................................................................ [ 88%] +...................................................s..........s......... [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 89%] +........................................................................ [ 90%] +........................s....ss..........s.s......s...s.s....s.......... [ 90%] +..s.s.........ss.......ss............................................... [ 90%] +........................................................................ [ 90%] +........................................................................ [ 90%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 91%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 92%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 93%] +........................................................................ [ 94%] +........................................................................ [ 94%] +........................................................................ [ 94%] +........................................................s............... [ 94%] +........................................................................ [ 95%] +........................................................................ [ 95%] +........................................................................ [ 95%] +........................................................................ [ 95%] +.............................................x.x........................ [ 95%] +........................................................................ [ 96%] +.x.xxs.ss............................................................... [ 96%] +........................................................................ [ 96%] +........................................................................ [ 96%] +........................................................................ [ 97%] +........................................................................ [ 97%] +...............................................................s........ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 97%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 98%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +........................................................................ [ 99%] +.......................................................... [100%] +30929 passed, 611 skipped, 55 xfailed, 2167 warnings in 195.82s (0:03:15) From 8c9295350f0313e8c4944455fdd31bf935fbdcce Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 21:33:21 -0700 Subject: [PATCH 10/30] feat: complete live delivery probes and closure evidence Add generated Codex child delivery probe (T13) with spawn/wait/rollout linkage assertions, strengthen deep-investigate E2E (T14) with workflow event normalization proving completed agent waves, inter-batch synthesis, post-report D6 validators, and Claude 200K provenance. Register generated agent TOMLs in session config. Add Taskfile smoke target. Raise investigate spawn ceiling to 16 with wave completion ordering contracts. Index fresh GraphQL closure postcondition for issues #4253/#3938 and PR #4259. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../output-budget-remediation/manifest.json | 12 +- .../phase4-closure-postcondition.json | 34 ++ Taskfile.yml | 45 ++ src/autoskillit/execution/backends/AGENTS.md | 2 +- .../execution/backends/_probe_cache.py | 11 +- src/autoskillit/execution/backends/codex.py | 39 ++ .../skills_extended/investigate/SKILL.md | 16 +- tests/arch/test_subpackage_isolation.py | 6 +- tests/execution/AGENTS.md | 2 +- .../backends/_conformance_assertions.py | 88 ++++ .../backends/test_cli_conformance_probes.py | 196 ++++++++- .../execution/backends/test_codex_backend.py | 27 ++ .../test_codex_deterministic_conformance.py | 30 +- tests/execution/backends/test_probe_cache.py | 9 +- tests/infra/AGENTS.md | 2 +- tests/infra/test_taskfile.py | 13 + tests/server/AGENTS.md | 2 +- tests/server/test_output_budget_e2e.py | 391 +++++++++++++++++- tests/skills/AGENTS.md | 2 +- tests/skills/test_investigate_contracts.py | 10 +- .../test_investigate_deep_mode_contracts.py | 42 ++ 21 files changed, 929 insertions(+), 50 deletions(-) create mode 100644 .autoskillit/evidence/output-budget-remediation/phase4-closure-postcondition.json diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json index 83fe9c953c..5c3b9dff9a 100644 --- a/.autoskillit/evidence/output-budget-remediation/manifest.json +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -177,6 +177,16 @@ "path": ".autoskillit/temp/apply_phase4_issue_closures.sh", "response_content_sha256": "56f8b56c6bc48a4f1dc9af8b98c5e1efedaeeae836beae5d6e2b44765e3fe25e" } - ] + ], + "postcondition": { + "path": ".autoskillit/evidence/output-budget-remediation/phase4-closure-postcondition.json", + "response_content_sha256": "991db41b843f32c540d4827c6dcd1a026a293ab1e1a4e802a347e5ae140ab096", + "query": "aliased GraphQL read for issues #4253/#3938 and PR #4259", + "assertions": { + "issue_4253": "CLOSED with Closure section", + "issue_3938": "CLOSED with Closure section", + "pr_4259": "MERGED at 1e2670ec7" + } + } } } diff --git a/.autoskillit/evidence/output-budget-remediation/phase4-closure-postcondition.json b/.autoskillit/evidence/output-budget-remediation/phase4-closure-postcondition.json new file mode 100644 index 0000000000..e97ef98f0d --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase4-closure-postcondition.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "generated_at": "2026-07-17T00:28:38.856Z", + "query": "aliased GitHub GraphQL read for issues #4253/#3938 and PR #4259", + "repository": "TalonT-Org/AutoSkillit", + "response": { + "repository": { + "issue4253": { + "number": 4253, + "state": "CLOSED", + "title": "open_kitchen response exceeds Claude Code MCP output cap — step graph truncated from orchestrator context, causing out-of-order step execution (review_approach before rectify)", + "url": "https://github.com/TalonT-Org/AutoSkillit/issues/4253", + "body": "## Hard Requirements\n\n1. **A working control over the harness output-persistence gate is required, verified — not assumed.** Live experiment (2026-07-14) proved `MAX_MCP_OUTPUT_TOKENS=50000` is delivered to wrapper-launched sessions yet does **not** govern Claude Code 2.1.197's disk-persistence gate — persistence still fired at 97.9KB with the override present. Split this requirement into: (1a) env-presence verification at launch (`required_env=` wiring — see Aggravating Defect); (1b) a behavioral canary (send an oversized synthetic MCP payload, assert inline delivery) run periodically/in CI; (1c) identify a real harness knob for the persistence gate or file an upstream Claude Code request — until one exists, the gate is an immovable ceiling.\n2. **There can be no effective cap on `open_kitchen` delivery.** When the recipe payload is truncated, the orchestrator loses the step graph and everything downstream breaks silently.\n3. **The ~100KB rendered `open_kitchen` response is itself suspicious and needs scrutiny.** Strategy resolution: the **primary** fix is keeping the payload under the gate with headroom (budget + slimming, enforced by regression test); **defense-in-depth** is restructuring the agent-visible output head so the step-flow chain survives any 2KB preview; the upstream knob (1c) is the long-term fix.\n\n## Root Cause\n\nCommit `2dc0ad819` (#4244, Hash-Bound Closure Authority, 2026-07-13 21:39:57 UTC) grew `remediation.yaml` by exactly +555 bytes (105,528 → 106,083), pushing the rendered `open_kitchen` payload (99,693 → 100,458 bytes) over Claude Code's output-persistence gate. The commit shipped in **v0.10.866** (between the v0.10.865 bump at 21:10:25 and the v0.10.866 bump at 21:46:56; the #4242/#4243 commit at 21:03:32 made zero byte change to the recipe). The gate sits empirically between 99,693 and ~100,250 bytes (~100KB; CLI 2.1.197 identical on both sides of the boundary — the payload crossed the gate, the gate did not move). The measured/persisted text is the **pretty-formatted** output produced by the `pretty_output_hook` PostToolUse formatter, not raw JSON.\n\nPost-crossing, orchestrators receive only `Full output saved to: …` plus a 2,241-byte preview that ends right after the recipe `summary:` line — **which omits `review_approach` entirely** (jumps `rectify > dry-walkthrough`). The full recipe enters context only once, via the formatter-hook attachment, *before* the user states the task; re-opens deliver only the misleading preview (attachment deduplicated; verified by token accounting: +33K tokens on first delivery, +2.2K on re-open). Orchestrators then reconstruct the step's position from salient wrong cues (ingredient description \"Research approaches first\", user's \"You must run Review approach\") and fire `review_approach` immediately after `create_and_publish_branch` with a bare issue URL (no plan path).\n\n## Aggravating Defects (validated by live experiment + code trace)\n\n1. **The existing defense is a false belief.** `src/autoskillit/execution/backends/_backend_cmd_builder_base.py:34-38` sets `MAX_MCP_OUTPUT_TOKENS=50000` with a comment claiming it prevents \"open_kitchen() responses from being persisted to a file instead of returned inline\" (`SHARED_BASELINE_ENV` at lines 46-51, merged by `build_interactive_cmd()` at `claude.py:455`). The env var **is delivered** (live env probe; passing arch test `test_interactive_cmd_has_baseline_env`) — but the persistence gate ignores it (live reproduction: 97.9KB persisted with the override present). The comment must be corrected; no env knob currently raises the gate.\n2. **The formatter deliberately withholds the ordering diagram from agents.** `hooks/formatters/_fmt_recipe.py:38,184` strips the ASCII flow diagram from agent-visible output (\"user sees it in terminal preview; agent doesn't need it\") — so no orchestrator ever sees the one rendering that unambiguously shows `review_approach`'s position, regardless of recipe. `_fmt_recipe_body` (~95-108) also leads with the full raw YAML dump, guaranteeing any preview shows only the (incomplete) summary line.\n3. **Env-verification hygiene gap (secondary; not the truncation cause):** interactive order sessions never pass `required_env` — three `_launch_cook_session` call sites (`cli/session/_session_order.py:157,199,326`) omit it, while fleet's `build_food_truck_cmd` hardcodes `required=ORCHESTRATOR_SESSION_REQUIRED_ENV` (`claude.py:698`); `CODEX_INTERACTIVE_REQUIRED_ENV` omits `MAX_MCP_OUTPUT_TOKENS` entirely.\n\n## Impact / Occurrences\n\n- 45/45 pipelines ordered correctly 2026-06-10 → 2026-07-13 16:34 (last good: kitchen `8697cc77`, v0.10.864, full inline recipe delivery, real plan path). Mechanically reproduced from `sessions.jsonl`.\n- 5/5 deviant after 2026-07-13 22:39:\n\n| First deviant call (UTC) | Kitchen | Issue | Version | skill_command argument |\n|---|---|---|---|---|\n| 2026-07-13 22:39 | e21e0aa1 | #4233 | 0.10.866 | issue URL (no plan) |\n| 2026-07-14 03:08 | fe274bd5 | #4236 | 0.10.868 | issue URL (no plan) |\n| 2026-07-14 03:09 | 51c5fd78 | #4222 | 0.10.868 | issue URL (no plan) |\n| 2026-07-14 14:03 | bda48ba9 | #4229 | 0.10.870 | issue URL (no plan) |\n| 2026-07-14 14:28 | f6413880 | #4228 | 0.10.870 | issue URL (no plan) |\n\n- All five orchestrators ran `claude-opus-4-6` — read directly from the five Claude Code session transcripts (the child review-approach sessions log their own configured models, fable/sonnet — a different field; do not conflate). Identical user phrasing succeeded on Jul 12–13 pre-boundary (user prompting ruled out; two pre-boundary opus sessions also ordered correctly under full delivery, on CLI 2.1.170).\n- Child `review-approach` sessions improvised from the bare issue URL and reported success — the deviation was laundered green in `sessions.jsonl`. In #4229 the corrective post-rectify re-run failed on rate limit and was skipped via `on_context_limit → dry_walkthrough` (`remediation.yaml:327`), so the plan was never approach-reviewed at all. One deviant session later confabulated a false recipe order; another admitted the error unprompted. `audit_impl` did NOT mis-fire in the 2/5 kitchens that reached it (only 2/5 progressed far enough to observe downstream ordering) — the failure is review_approach-specific (its description literally says \"first\").\n\n## Why Nothing Stopped It\n\nAll code-level ordering enforcement is structurally dead in every real session mode:\n\n- `record_pipeline_step(op=\"init\")` is never called by anything — no instruction exists anywhere (fleet prompts, sous-chef, recipes, skills all silent) — so:\n- `_check_pipeline_deps` (`server/tools/tools_execution.py:210-244`) fails open (no order_id → None; no tracker file → None; no deps entry → None);\n- `step_name=\"\"` bypasses both the lock check and the dependency check entirely (`tools_execution.py:665-676`; the unresolved-step_name fallback at :699 re-checks locks only, never deps) — pre-existing open issue **#3452**;\n- `hooks/guards/pipeline_step_guard.py` is advisory-only from birth (`permissionDecision` always \"allow\"; single commit in its entire history);\n- `hooks/pipeline_step_post_hook.py` is dead behind the same order_id/tracker gate and writes only on `success: true` (no \"failed\" state exists in the tracker);\n- `hooks/session_start_hook.py:65-80` garbage-collects tracker files that nothing ever creates.\n\nThis was a deliberate scope decision: #3266 lists routing/ordering enforcement as a Non-Goal (\"tracker is observational + advisory\"); motivating issue #1083 remains open. sous-chef discipline targets step-**skipping**, not premature execution — there is no \"verify the previous step's on_success target before each run_skill\" rule. `review-approach` SKILL.md has no plan-path input contract (its \"conversation context\" fallback masks the error as success).\n\n## Remediation Direction (validated + adversarially corrected)\n\na. **Payload ceiling, not env raise:** `MAX_MCP_OUTPUT_TOKENS` does not govern the persistence gate — raising it fixes nothing. Add a regression test + runtime check keeping the rendered open_kitchen payload under the ~100KB gate with headroom (**the v0.10.870 payload is already over it** — a live call persisted at 97.9KB today). Correct the stale comment at `_backend_cmd_builder_base.py:34-38`. Pursue an upstream harness knob (1c). Hygiene: wire `required_env=ORCHESTRATOR_SESSION_REQUIRED_ENV` into the three `_session_order.py` call sites and add `MAX_MCP_OUTPUT_TOKENS` to `CODEX_INTERACTIVE_REQUIRED_ENV` (env-presence verification only — explicitly not a truncation fix).\nb. **Restructure the agent-visible output head** — the component is `hooks/formatters/_fmt_recipe.py::_fmt_recipe_body` (~95-108): lead with the step-flow chain (and stop stripping the diagram — revisit the \"agent doesn't need it\" assumption at lines 38/184) before the raw YAML dump, so ordering survives any 2KB preview. This is distinct from raw tool_result assembly (`recipe/_api.py:599`, `tools_kitchen.py`) — name both surfaces in the plan.\nc. **Fix misleading surfaces:** make the `summary:` line complete in **both** `remediation.yaml` and `.autoskillit/recipes/remediation-fable.yaml` (include `(review_approach?)` — `implementation.yaml`'s already does); reword \"Research approaches first\" in all 4 files carrying it (`remediation.yaml:63`, `implementation.yaml:29`, `implementation-groups.yaml:24`, `.autoskillit/recipes/remediation-fable.yaml:57` — no tests assert the literal string); add a summary-vs-graph semantic validation rule. Recipe YAML edits require `task regen-contracts && task compile-recipes`.\nd. **Server-authoritative, self-arming ordering enforcement** — auto-init the pipeline tracker at `open_kitchen` from `ctx.active_recipe_steps` (routing fields confirmed retained at runtime — `tools_kitchen.py:802-806`, `recipe/schema.py:112-147`; graph extractor exists at `recipe/_analysis_graph.py:156-267`). **Phase A co-requisites (all required, or the fix ships inert):**\n - **Key unification:** the tracker MUST be keyed by the same resolution `_check_pipeline_deps` reads (`order_id` param / `AUTOSKILLIT_DISPATCH_ID` env — `tools_execution.py:213`, `tools_pipeline_tracker.py:77`). Interactive sessions have neither today: either mint & propagate an order_id at `open_kitchen`, or extend the check to fall back to a kitchen-scoped key. A kitchen_id-keyed tracker with an order_id-keyed check reproduces the exact dead-mechanism failure this issue exists to close.\n - **Re-init semantics:** `_handle_init` currently hard-errors if the tracker exists (`tools_pipeline_tracker.py:128-138`), but `open_kitchen` is called 2+ times per session by design (deferred-override re-call). Auto-init must be idempotent/merging, never destructive of in-flight completion state.\n - **Multi-pipeline scoping:** `run_mode: parallel` (remediation.yaml:131-136) runs N issues in one kitchen — reuse the per-pipeline `locked_steps[pipeline_id]` overlay precedent (`tools_kitchen.py:1198-1200`), one tracker per pipeline.\n - **Close the `step_name=\"\"` bypass (#3452):** require step_name when `active_recipe_steps` is non-empty, or extend the unresolved-name branch to deny on unresolvable deps as it already does for locks.\n - **Joint gating for optional steps:** steps following an optional step must gate on that step's completion *when its ingredient is enabled* — otherwise the #4229 pattern (`review_approach` skipped via `on_context_limit → dry_walkthrough`) still ships a never-reviewed plan even with Phase A in place.\n - Curated hard prerequisites first (`review_approach` requires `rectify`/`make_plan` — loop-free, single-edge); full ancestor-reachability with cycle exemption is **Phase B**, a separate design pass (SCC detection, per-iteration status semantics; must not deadlock `test → check_test_fix_loop → assess → test` or `next_or_done → dry_walkthrough` loops). Enforcement applies to `run_skill` steps only.\ne. **Plan-path input contract on `review-approach`** so out-of-order invocation fails loudly instead of improvising.\nf. **Ordering telemetry** over `sessions.jsonl` (REVIEW_BEFORE_PLAN detection is a one-pass check; the five occurrences above were found mechanically).\n\n### Design note: soft-boundary enforcement via forced cognitive engagement\n\nFor loop re-entry (the orchestrator legitimately going back to a step), prefer a **named, explicit action** over silent permission: to re-run an already-completed step, the orchestrator must first invoke an explicit reset (e.g. `record_pipeline_step(op=\"restart_from\", step=X)` or `reset_steps A..Z`). **This op is net-new machinery** — `record_pipeline_step` currently accepts only `init`/`status` (`tools_pipeline_tracker.py:104-109`) — and its spec must define what resets (the named step only vs. cascade to downstream dependents) and how backward-vs-forward is computed (static prerequisite table, since Phase A has no ordinal tracking). The act of issuing a deliberately-named reset command forces the orchestrator to cognitively register that it is going backward — a soft boundary that prevents accidental out-of-order execution without hard-blocking legitimate retry/remediation loops. Note the tracker's lack of a \"failed\" state is safe for Phase A (dependency checks read the *prerequisite's* status, and failed-then-retried steps simply remain \"pending\"), but state this reasoning in the implementation plan. Hook-based advisory enforcement on `run_skill` steps (per #3266's design) composes: the hook warns, the server requires the explicit reset for backward transitions, and forward out-of-order transitions are denied outright.\n\n## Related Issues\n\n- #3266 (open, staged) — pipeline step completion tracker, hook-driven step validation: the enforcement scaffolding this issue must arm.\n- #3452 (open, staged) — `step_name=''` defeats both enforcement layers: must be closed as a Phase A co-requisite.\n- #1083 (open) — orchestrator step-skipping structural enforcement: motivating scenario, still unresolved.\n- #1320 (closed) — prior truncation-family incident: open_kitchen named-recipe path truncated sous-chef to 1 of 26 sections. Same failure genre (harness output limit silently starving the orchestrator of instructions); fixed at a different layer.\n- #324 (closed) — list_recipes output truncated by pretty_output hook.\n- #4212 (open) — fleet L2 run_skill omits order_id (adjacent order_id-plumbing defect).\n- #3175 (closed) — review_approach silently pruned (opposite failure mode).\n- #386 (open) — FastMCP ResponseLimitingMiddleware research.\n\nThis is the fourth member of the \"orchestrator diverges from recipe\" failure family — flagged for `/rectify` architectural immunity after resolution.\n\n---\n\n*Validation round 2 (2026-07-14): body corrected in place after two adversarial verification passes plus live experiments. Key changes: retracted \"override not reaching sessions\" (disproven — override delivered, gate independent of it); corrected `_backend_cmd_builder_base.py` line cites (34-38/46-51) and version bracketing (shipped in v0.10.866); added formatter diagram-stripping finding, `#3452` step_name bypass, Phase A key-unification/re-init/parallel-scoping/joint-gating co-requisites, and `_fmt_recipe.py` as a required-change component.*\n\n\n## Investigation\n\n\n> Prior investigation completed interactively. Full validated report below (root cause analysis, evidence, confidence levels).\n\n# Investigation: review_approach Step Executed Out of Order in Remediation Pipelines\n\n**Date:** 2026-07-14\n**Scope:** Why orchestrator sessions ran the `review_approach` step before `investigate`/`rectify` produced the plan it reviews; why every ordering-enforcement mechanism failed; whether this regressed and when; prior occurrences.\n**Mode:** Deep Analysis\n\n## Summary\n\nBetween 2026-06-10 and 2026-07-13 16:34, all 45 logged pipelines with a `review_approach` step executed it correctly (after `rectify`/`make_plan`, with a real plan path). Starting 2026-07-13 22:39, five consecutive interactive remediation orders (issues #4233, #4236, #4222, #4229, #4228) executed `review_approach` immediately after `create_and_publish_branch`, passing a bare GitHub issue URL instead of a plan path — a 0% → 100% flip at a sharp boundary. The recipe YAML was correct the whole time (`rectify → on_success: review_approach` in both `remediation.yaml` and `.autoskillit/recipes/remediation-fable.yaml`). The proximate trigger is a **silent payload-delivery regression**: the `open_kitchen` response grew past Claude Code's large-tool-output persistence threshold between AutoSkillit v0.10.864 and v0.10.866 (same CLI version 2.1.197 on both sides), so orchestrators stopped receiving the recipe step graph inline and instead received a 2,241-byte preview whose only ordering content is the top-of-file `summary:` line — **which omits `review_approach` entirely**. With the authoritative graph effectively out of attention, orchestrators reconstructed the step's position from salient-but-wrong cues (the ingredient description \"Research approaches first\", the user's emphatic \"You must run Review approach\") and fired it early. Nothing could stop them: every code-level ordering enforcement mechanism is structurally dead in all real session modes, by original design (never armed, advisory-only, fail-open). The deviation was then laundered into green pipelines because the `review-approach` skill happily runs without a plan argument and reports success.\n\n## Root Cause\n\nLayered causal chain (all layers required):\n\n1. **Architectural fragility (latent since inception):** Step ordering is enforced *only* by the orchestrator LLM voluntarily reading the recipe YAML delivered through `open_kitchen`. Recipes are prompts, not programs (`src/autoskillit/recipes/AGENTS.md`). Every code-level backstop is structurally inert (see Affected Components). There is no verification that the ordering authority (the step graph) was actually delivered readably to the model, and no runtime check that a `run_skill` call matches the declared routing.\n\n2. **Proximate trigger (the regression, 2026-07-13 21:03–21:46 UTC):** The rendered `open_kitchen` payload crossed Claude Code's MCP large-output persistence threshold. Measured: v0.10.864 session received **99,693 bytes fully inline** in the tool_result (session `f0b1ab5d`, zero \"Full output saved to\" markers); v0.10.866+ sessions received **100,458-byte payloads persisted to `tool-results/*.txt`** with a 2,241-byte inline stub (\"Full output saved to: … Preview (first 2KB): …\"). Same Claude Code version (2.1.197) both sides — the threshold did not move; the payload grew over it. Contributing growth: commit `2dc0ad819` (#4244, Hash-Bound Closure Authority) added exactly +555 bytes of `closure_authority_*` ingredients to `remediation.yaml` (105,528 → 106,083 bytes raw) in that window. The #4242/#4243 commit (`56ae370b0`) made zero byte change to the recipe file; the remaining +210B of rendered-payload growth (rendered +765B vs raw +555B) is unattributed to any commit — most plausibly rendering-overhead variance, as no commit in the window touches the serving path. Nothing in CI or tests watches this payload size.\n\n **Aggravating discovery (adversarial validation, corrected by live experiment):** the codebase already anticipated this exact failure. `src/autoskillit/execution/backends/_backend_cmd_builder_base.py:34-38` sets `MAX_MCP_OUTPUT_TOKENS=50000` with the comment \"preventing open_kitchen() responses from being persisted to a file instead of returned inline\" (`SHARED_BASELINE_ENV` at lines 46-51, merged in `claude.py:455` by `build_interactive_cmd()`). **Live experiment (2026-07-14, this investigation): in a wrapper-launched session with `MAX_MCP_OUTPUT_TOKENS=50000` verified present in the environment, an `open_kitchen(remediation)` call was still persisted to file at 97.9KB with a 2KB preview.** The override IS delivered (also confirmed by passing arch test `test_interactive_cmd_has_baseline_env`) — but **the CLI 2.1.197 disk-persistence gate is not governed by `MAX_MCP_OUTPUT_TOKENS` at all**. The gate sits empirically between 99,693 and ~100,250 bytes (consistent with a fixed ~100,000-byte threshold; token-vs-byte basis undetermined). The code comment asserts protection that does not exist — a stale belief about harness behavior. No known env knob raises the persistence gate; payload size is currently the only lever, and the v0.10.870 bundled remediation payload is already over it.\n\n3. **The truncated preview is actively misleading:** The 2KB preview ends at `recipe_version:` — its only ordering content is the `summary:` line: `bootstrap_clone > (claim_and_resolve?) > (create_and_publish?) > investigate > rectify > dry-walkthrough > implement > …`, which **omits `review_approach`**. So the freshest recipe signal at decision time actively teaches an order in which `review_approach` has no position, inviting the model to slot it wherever other cues suggest.\n\n4. **Residual full-graph delivery is weak and mistimed:** In deviant sessions the full recipe still entered context exactly once — as PostToolUse formatter-hook stdout (`attachment` entry, 103,462 bytes; token accounting: context 28,471 → 61,554 immediately after the second of three `open_kitchen` calls — the first produced no result/growth — before the user had even stated the task). On the second `open_kitchen` (with overrides, moments before pipeline start) the identical attachment was not re-injected (+2.2K tokens only — preview only). At the decision point the correct graph sat ~37K tokens back, delivered pre-task, while the misleading preview was ~3K tokens back. In the v0.10.864 good session, by contrast, the full graph arrived inline in the tool_result of *every* `open_kitchen` call (context grew +23K and +40K across the two calls).\n\n5. **Position reconstruction from salient cues:** With the graph out of effective attention, orchestrators placed the step using: the ingredient description **\"Research approaches first\"** (`remediation.yaml:62-64`) — echoed near-verbatim in the deviant narrations (\"Now running the review_approach step — researching approaches first\", 3 of 5 sessions); the user's \"Turn review_approach on … You must run Review approach\"; and sous-chef's instruction to act on ingredient intent immediately after `open_kitchen` (lock_ingredients guidance) with no \"locking ≠ scheduling\" counterweight. Session `09948c1d` later *confabulated* a false recipe order (\"create_and_publish → review_approach → investigate → rectify … correct per the recipe YAML\") — precisely the order you'd reconstruct from summary-line + \"first\" cues.\n\n6. **Deviation laundering:** `skills_extended/review-approach/SKILL.md` has no input contract — Step 1 permits deriving research targets from \"the report, plan, **or conversation context**\". Given only an issue URL, child sessions improvised (fetched the issue, read source, ran web research) and exited success with a `review_path`. `sessions.jsonl` records success; no anomaly flagged. In #4229 the \"corrective\" re-run after `rectify` then failed on rate-limit and was skipped via `on_context_limit → dry_walkthrough` — so the plan was never approach-reviewed at all. 3 of 5 deviant sessions never self-corrected.\n\n**Model factor (contributing, not discriminating):** All 5 deviant sessions ran orchestrator model `claude-opus-4-6`. However, opus-4-6 sessions ordered correctly under full inline delivery (Jun 11 `31bd1461`, Jul 3 `d71122ea`), corroborated by user testimony (\"in the past I had always used opus 4.6 and didn't have this problem\"). Most other pre-boundary sessions ran `claude-fable-5`. Under truncated delivery, opus-4-6 fails 5/5; there are no post-boundary fable-5 orders to test the converse. Truncation is the necessary enabler; model susceptibility shapes the failure rate given truncation.\n\n## Affected Components\n\n- `src/autoskillit/server/tools/tools_kitchen.py` + `server/tools/_serve_helpers.py` + `recipe/_api.py:599` — open_kitchen serving; returns full raw recipe YAML as `content`; no payload-size guard [SUPPORTED]\n- `src/autoskillit/recipes/remediation.yaml:3-5` — `summary:` line omits `review_approach` (identical in `.autoskillit/recipes/remediation-fable.yaml`) [SUPPORTED]\n- `src/autoskillit/recipes/remediation.yaml:62-64` — ingredient description \"Research approaches first\" (temporal-cue hazard, echoed in deviant narrations) [SUPPORTED]\n- `src/autoskillit/server/tools/tools_execution.py:210-244` — `_check_pipeline_deps` fails open without order_id (214) / tracker file (220) / deps entry (228) [SUPPORTED]\n- `src/autoskillit/server/tools/tools_pipeline_tracker.py` — `record_pipeline_step(op=\"init\")`: sole tracker creator; LLM-provided dependency graph; **zero instructions anywhere tell any orchestrator to call it** (fleet prompts, sous-chef, recipes, skills all silent) [SUPPORTED]\n- `src/autoskillit/hooks/guards/pipeline_step_guard.py` — advisory-only from birth (`permissionDecision` always \"allow\"); no-ops without the same never-created tracker [SUPPORTED]\n- `src/autoskillit/hooks/pipeline_step_post_hook.py` — completion marker; dead behind identical order_id/tracker gate [SUPPORTED]\n- `src/autoskillit/hooks/session_start_hook.py:65-80` — tracker TTL garbage collector (deletes >24h trackers); nothing initializes them [SUPPORTED]\n- `src/autoskillit/skills/sous-chef/SKILL.md` — \"recipe step graph is the sole authority\" (~line 781) targets step-*skipping*; no rule requires verifying the previous step's `on_success` target before each `run_skill`; ingredient-locking section mandates action \"immediately after open_kitchen\" [SUPPORTED]\n- `src/autoskillit/skills_extended/review-approach/SKILL.md` — no plan-path input contract; \"conversation context\" fallback masks missing-plan invocations [SUPPORTED]\n- `src/autoskillit/recipe/diagrams.py` + `.autoskillit/recipes/diagrams/` — bundled `remediation` has a correct ASCII flow diagram; local `remediation-fable` has none. All five incidents loaded the bundled recipe (`sessions.jsonl` `recipe_name`), and **the formatter strips the diagram from agent-visible output anyway** (`hooks/formatters/_fmt_recipe.py:38,184` — \"agent doesn't need it\"), so no orchestrator ever sees the one rendering that unambiguously shows step order [SUPPORTED]\n- `src/autoskillit/hooks/formatters/_fmt_recipe.py` — `_fmt_recipe_body` (~95-108) leads agent-visible output with the full raw YAML dump (summary line at top, step graph buried); strips `diagram`; this formatted text is what the harness measures, persists, and previews [SUPPORTED]\n- `hooks/formatters/pretty_output_hook.py` — its stdout attachment is the only full-graph delivery path post-truncation; injected once pre-task, deduplicated on re-open [SUPPORTED by token accounting; injection mechanics NEEDS-EVIDENCE at harness level]\n\n## Data Flow\n\nCorrect design intent: `open_kitchen` → recipe YAML into orchestrator context → orchestrator walks `on_success`/`on_result` chain → each `run_skill(step_name=…)` optionally validated by ingredient locks (skip-state only) and `_check_pipeline_deps` (dead) → child session executes skill → PostToolUse hooks record.\n\nActual deviant flow: `open_kitchen` (payload > threshold) → tool_result = pointer + 2KB preview (summary line without review_approach); full YAML lands once as formatter-hook attachment before the task exists → user task arrives → `lock_ingredients(review_approach=true)` → bootstrap/claim/branch → orchestrator reconstructs order from preview + \"Research approaches first\" + \"must run\" → `run_skill(step_name=\"review_approach\", skill_command=\"/autoskillit:review-approach \")` → server checks: ingredient lock passes (value truthy), dep check returns None (no order_id, no tracker) → child improvises from issue, succeeds → deviation recorded as success.\n\n## Test Gap Analysis\n\n- **No payload-size regression test:** nothing asserts the rendered `open_kitchen` response stays under the harness persistence threshold (or that the step graph appears in the first N bytes). The regression shipped invisibly in a commit that touched only ingredient definitions.\n- **No enforcement-liveness test:** unit tests exercise `_check_pipeline_deps`/`record_pipeline_step` *given* a tracker, but no integration test asserts any production flow ever creates one. A mechanism that is never armed passes all its tests.\n- **No summary/graph consistency check:** recipe validation (semantic rules in `recipe/rules/`) never validates that the `summary:` line agrees with the step graph, so a wrong-by-omission summary is undetectable.\n- **No skill input contract:** `review-approach` has no required-argument check, so out-of-order invocation produces success, not a failure signal.\n- **No ordering telemetry:** `sessions.jsonl` captures step_name + timestamps per kitchen — the 5 deviations were mechanically detectable (this investigation found them with one awk pass) but nothing monitors it (`anomaly_count` unused for this; `analyze_tool_sequences` doesn't check recipe order).\n\n## Similar Patterns\n\n- Step **skipping** (not reordering) was addressed via prose in PR #966 (\"Orchestrator Step-Skipping Immunity\", sous-chef section, 2026-04-15).\n- Step **pruning** via stale overrides was addressed in #3175/PR #3196 (Deferred Resolution re-call of open_kitchen) — verified independent of this failure; it did not create the hazard.\n- Codex auto-compaction losing recipe content was recognized and countered with `recipe_read_guard.py` — the codebase already treats \"model lost the recipe\" as a real threat class, but only for the Codex backend and only against *reading stale copies*, not against truncated initial delivery.\n\n## Design Intent Findings\n\n- **Pipeline tracker suite** (`record_pipeline_step`, `pipeline_step_guard.py`, `_check_pipeline_deps`, `pipeline_step_post_hook.py`): introduced together in `8c821e006` (PR #3404, 2026-05-31) as a deliberately partial, \"observational + advisory\" backstop; design issue #3266 explicitly lists routing/ordering enforcement as a **Non-Goal**; motivating issue #1083 (structural enforcement against orchestrator step-skipping) remains open. Guard was advisory from birth; fail-open branches are original. Dependents: fleet checkpointing reads trackers post-hoc (`fleet/_api.py:939-957`). [SUPPORTED]\n- **sous-chef step discipline** (`2b1e52f78`, PR #966): mandatory-strength prose against skipping/improvisation; never extended to premature execution; routing-fields-are-authoritative sentence exists, but no per-call verification discipline. [SUPPORTED]\n- **Ingredient locks** (#3381, 2026-05-30): skip-state enforcement only; truthy lock ≠ scheduling; unchanged recently. [SUPPORTED]\n- **open_kitchen serving** (`recipe/_api.py`): serves raw YAML verbatim as `content`; summary line and diagram are separate, unvalidated renderings. [SUPPORTED]\n\n## Historical Context\n\n- First occurrence of this failure mode: 2026-07-13 22:39 (issue #4233). Zero occurrences in 45 prior review_approach pipelines back to 2026-06-10 (log horizon; 59 total review_approach child-session records in sessions.jsonl, earliest 2026-06-10).\n- No prior /investigate report on step ordering exists (`.autoskillit/temp/investigate/` and project logs checked). No GitHub issue captures this defect; adjacent issues: #3175 (pruning, closed), #3266 (tracker design, open), #1083 (step-skipping enforcement, open).\n- Prior fixes were insufficient because each targeted a different failure mode (skipping, pruning, Codex compaction) and none enforce ordering or guarantee graph delivery.\n- **This is a recurring pattern** (fourth member of the \"orchestrator diverges from recipe\" family) — consider running `/rectify` for architectural immunity after resolving the immediate issue.\n\n## External Research\n\n- Lost-in-the-middle attention (U-shaped context utilization) explains under-attention to a step graph buried mid-payload: https://arxiv.org/pdf/2510.10276\n- Claude Code instruction-sequencing adherence issues (models chaining into next actions despite explicit ordering instructions): https://github.com/anthropics/claude-code/issues/25652, https://github.com/anthropics/claude-code/issues/45697\n- Task-graph enforcement rationale for agent pipelines (models follow \"most natural completion\" over declared graphs): https://trilogyai.substack.com/p/how-to-fix-your-ai-agents-keep-cutting\n- Claude Code persists oversized MCP tool outputs to `tool-results/*.txt` with a ~2KB inline preview. Empirical gate bracket: 99,693 bytes inline vs ~100,250 bytes persisted on CLI 2.1.197 (~100KB). Live experiment proved the gate operates INDEPENDENTLY of `MAX_MCP_OUTPUT_TOKENS` — persistence occurred with the 50,000 override verified present in env. [SUPPORTED by direct experiment]\n\n## Scope Boundary\n\n**Investigated:** remediation/implementation recipe ordering declarations; all enforcement code paths (guards, server checks, tracker, hooks); sous-chef and review-approach skill prompts; open_kitchen serving/rendering; both deviant orchestrator transcripts (token-level context accounting) plus 8 good sessions; full sessions.jsonl history sweep; git history of every enforcement component; recipe payload sizes across the version boundary; external literature.\n\n**Resolved post-report by live experiment:** the `MAX_MCP_OUTPUT_TOKENS=50000` override IS reaching wrapper-launched sessions (env probe confirmed) — the persistence gate simply is not governed by it. **Not yet explored:** whether any harness knob controls the persistence gate (upstream question); dedup semantics for hook attachments (harness internals; inferred empirically); whether fable-5 would also fail under truncated delivery (no post-boundary fable orders exist); fleet-dispatch sessions' exposure to the same truncation (dispatch prompt sizes not measured).\n\n**Validation corrections applied (post-report validators):** `implementation.yaml` is *smaller* than `remediation.yaml` (102,287 vs 106,083 bytes raw; rendered ≈96.9KB by the measured ratio) — comfortably under the threshold, not at imminent risk as originally drafted. Its `summary:` line also already includes `(review-approach?)`, so the summary omission is remediation-specific. Additionally: `audit_impl` did NOT mis-fire in the 2 of 5 deviant kitchens that reached it (fe274bd5, 51c5fd78) — the early-fire failure is review_approach-specific (its description literally says \"first\"), not a property of all optional steps; and only 2 of 5 deviant kitchens progressed far enough to observe downstream step ordering at all.\n\n## Confidence Levels\n\n- Sharp onset boundary 2026-07-13 16:34 → 22:39; 5/5 deviants after, 45/45 correct before — SUPPORTED (mechanical sweep of sessions.jsonl).\n- Payload crossed persistence threshold between v0.10.864 and v0.10.866; inline-full vs preview-only delivery — SUPPORTED (transcript measurements, same CLI version both sides).\n- Preview's summary line omits review_approach; description says \"Research approaches first\"; deviant narrations echo it — SUPPORTED (verbatim quotes).\n- All code enforcement structurally dead in every real mode — SUPPORTED (file:line audit + git history).\n- Full graph entered deviant contexts once pre-task via hook attachment, deduped on re-open — SUPPORTED by token accounting; exact harness injection/dedup mechanics NEEDS-EVIDENCE.\n- Opus-4-6 susceptibility vs fable-5 robustness under truncation — NEEDS-EVIDENCE (confounded; n=2 opus-correct pre-boundary, no fable post-boundary).\n- User prompting style as trigger — UNSUPPORTED (identical phrasing produced correct runs Jul 12–13 under full delivery).\n\n## Recommendations\n\n**Primary (single) recommendation: make step-ordering enforcement server-authoritative and self-arming — stop depending on LLM attention for sequencing.**\n\nThe server already holds the authoritative graph: `ctx.active_recipe_steps` is populated from `recipe_obj.steps` (`tools_kitchen.py:802-806`) as full `RecipeStep` dataclasses retaining `on_success`/`on_failure`/`on_result`/`skip_when_false` (`recipe/schema.py:112-147`; `_preflight.py:29-35` filters keys, not fields), and a routing-edge extractor already exists (`recipe/_analysis_graph.py:156-267`, `_extract_routing_edges`/`_build_step_graph` with `skip_when_false` bypass and loop back-edge handling). Verified implementable with no loader change. At `open_kitchen` time, auto-initialize the pipeline tracker from that graph (keyed by kitchen_id; no LLM-provided dependencies, no order_id prerequisite), and have `run_skill`'s `_check_pipeline_deps` consult it by default for recipe-resolved `step_name`s. Out-of-order calls then fail with the existing \"DEPENDENCY UNMET\" envelope regardless of what the model believes. This converts the entire existing (currently dead) enforcement suite into a live gate for every session mode — including `pipeline_step_guard.py`, which starts working \"for free\" since it reads the same tracker path.\n\nShip in two explicitly separated phases (validator-refined):\n- **Phase A (low risk, ship first):** curated hard prerequisites for loop-free steps — `review_approach` requires `rectify` (or `make_plan` in implementation recipes); `dry_walkthrough` requires a plan-producing step. Single-edge checks, no cycle exposure; directly closes this bug class.\n- **Phase B (separate design pass):** full ancestor-reachability derivation with cycle exemption. Requires SCC detection over the step graph and per-iteration status semantics (the tracker stores one scalar status per step name — no generation counter), so loop steps (`test → check_test_fix_loop → assess → test`, `next_or_done → dry_walkthrough`) don't deadlock. Do not bundle with Phase A.\n\nRequired hardening in the same remediation (same root cause, delivery integrity):\n1. **Payload-delivery guard (corrected by live experiment):** `MAX_MCP_OUTPUT_TOKENS` does NOT govern the persistence gate — raising it fixes nothing here. (a) Treat the ~100KB gate as a hard payload ceiling: regression test + runtime check that the rendered open_kitchen response stays under budget with headroom (the v0.10.870 payload is already over — live call persisted at 97.9KB); (b) correct the stale comment at `_backend_cmd_builder_base.py:34-38`; (c) restructure the agent-visible output head — the component is `hooks/formatters/_fmt_recipe.py::_fmt_recipe_body` (lines ~95-108), which currently leads with the full raw YAML dump and deliberately strips the flow diagram from agent output (lines 38/184: \"agent doesn't need it\") — lead with the step-flow chain so ordering survives any 2KB preview; (d) identify or request an upstream harness knob for the persistence gate; (e) hygiene: wire `required_env=ORCHESTRATOR_SESSION_REQUIRED_ENV` into the three `_launch_cook_session` call sites (`cli/session/_session_order.py:157,199,326` — fleet's `build_food_truck_cmd` already does this at `claude.py:698`) and add `MAX_MCP_OUTPUT_TOKENS` to `CODEX_INTERACTIVE_REQUIRED_ENV`, noting this is env-presence hygiene, not a fix for persistence.\n2. **Fix the misleading surfaces:** make the remediation `summary:` line complete (include optional steps: `… rectify > (review_approach?) > dry-walkthrough …` — note `implementation.yaml`'s summary already correctly includes `(review-approach?)`; the omission is remediation-specific) and add a recipe semantic rule validating summary-vs-graph consistency; reword the `review_approach` ingredient description in **all 4 files carrying \"Research approaches first\"** (`remediation.yaml:63`, `implementation.yaml:29`, `implementation-groups.yaml:24`, `.autoskillit/recipes/remediation-fable.yaml:57` — no tests assert the literal string) to e.g. \"Research solution approaches against the plan (runs after rectify/make-plan)\"; ship a diagram for local recipes or fall back to the bundled recipe's diagram. Mechanical prerequisite: recipe YAML edits require `task regen-contracts && task compile-recipes` (contract-freshness pre-commit hook compares whole-file hashes).\n3. **Input contract on review-approach:** in pipeline context, require a plan-path argument (server-side arg validation for `step_name=review_approach`, or SKILL.md hard precondition) so an out-of-order invocation fails loudly instead of improvising.\n4. **Ordering telemetry:** flag REVIEW_BEFORE_PLAN-style violations in sessions.jsonl analysis (the one-pass awk check from this investigation is directly automatable) so any future bypass is detected within one run, not five.\n\n**Killed alternatives:**\n- *More sous-chef prose / stronger prompts* — killed: this failure family has already burned through three prose-based fixes (#966, #3175, kitchen_rules); prose failed precisely when delivery degraded.\n- *Keep LLM-initialized tracker (status quo design)* — killed: proven dead in 100% of real sessions for 6 weeks; the init instruction does not exist anywhere and adding it just reintroduces LLM-attention dependence.\n- *Pin orchestrator to fable-5* — killed: unproven under truncated delivery, costs quota, and leaves the mechanism unfixed for every other model/backend.\n- *Blocking PreToolUse hook enforcing order* — killed: hooks are stdlib-only subprocesses without the parsed recipe; the server layer already has the graph and the deny path; duplicate enforcement at the wrong layer.\n\n## Breakage Analysis\n\n- Recommendation component: auto-arming `_check_pipeline_deps` (changes behavior of existing `run_skill` gate from never-fires to fires-on-violation).\n - Breakage surface: legitimate non-linear routings must not be misclassified — the dependency derivation must encode the *graph* (including `on_failure`, `on_result` branches, loop-backs like `check_test_fix_loop → assess → test`, re-entries `next_or_done → dry_walkthrough`), not a linear step list; otherwise retry/remediation cycles would deadlock. Steps invoked with suffixes (`-2`) are already canonicalized (`_canonical_step_name`). Resume flows (`resume_session_id`) already bypass the check (`tools_execution.py:673`).\n - Prior reverts: none on this mechanism (single introducing commit, no weakening history).\n - Downstream contracts: fleet checkpointing (`checkpoint_from_tracker`) benefits (trackers finally exist); session_start GC unaffected.\n - Risk level: MEDIUM (graph-derivation correctness for cyclic routes is the main hazard; mitigated by deriving \"unmet dependency\" only from ancestor steps that are neither complete nor skipped nor reachable-again).\n- All other components are additive (tests, validation rules, description text, telemetry): risk LOW.\n\n## Occurrence Table (for reference)\n\n| First deviant call (UTC) | Kitchen | Issue | Version | Model | skill_command argument |\n|---|---|---|---|---|---|\n| 2026-07-13 22:39 | e21e0aa1 | #4233 | 0.10.866 | opus-4-6 | issue URL (no plan) |\n| 2026-07-14 03:08 | fe274bd5 | #4236 | 0.10.868 | opus-4-6 | issue URL (no plan) |\n| 2026-07-14 03:09 | 51c5fd78 | #4222 | 0.10.868 | opus-4-6 | issue URL (no plan) |\n| 2026-07-14 14:03 | bda48ba9 | #4229 | 0.10.870 | opus-4-6 | issue URL (no plan) |\n| 2026-07-14 14:28 | f6413880 | #4228 | 0.10.870 | opus-4-6 | issue URL (no plan) |\n\nLast correct run before boundary: 2026-07-13 16:34, kitchen 8697cc77, v0.10.864, full inline recipe delivery, real plan path.\n\n## Closure record\n\n**Verified:** 2026-07-15\n\n- [PR #4259](https://github.com/TalonT-Org/AutoSkillit/pull/4259) is merged into `develop` as commit [`1e2670ec7b31fe0010d9c487082e0348699b53ff`](https://github.com/TalonT-Org/AutoSkillit/commit/1e2670ec7b31fe0010d9c487082e0348699b53ff), and that commit is present in the current `develop` history.\n- The merged payload fix puts `STEP FLOW` and the flow diagram before compacted recipe content and regression-tests every runtime-discoverable recipe in default and all-truthy modes against a fixed budget below the observed external persistence gate.\n- The merged enforcement fix self-arms kitchen-scoped pipeline dependencies in `open_kitchen`, checks them at `run_skill`, and rejects pipeline `review-approach` calls that lack the required plan-path input.\n- The PR body contained `Closes #4253`, but GitHub did not auto-close this issue because the PR targeted `develop`, while the repository default branch is `main`. This body record documents the verified fix before manual closure.\n" + }, + "issue3938": { + "number": 3938, + "state": "CLOSED", + "title": "Codex Sous-Chef Recipe Context Loss via Auto-Compaction", + "url": "https://github.com/TalonT-Org/AutoSkillit/issues/3938", + "body": "## Summary\n\nThe Codex-backed sous-chef session loses recipe content during long pipeline runs due to Codex's automatic context compaction. After compaction, the sous-chef cannot find recipe step definitions (e.g., `run_diagnostic`'s `skill_command`) and falls back to unauthorized filesystem searches — `run_cmd` with `rg`, `run_python` with recipe loader API — to rediscover the invocation.\n\nThis is a recurrence of the pattern from PR #3736 (commit `358c8a168`) — \"Codex Raw Recipe File Read Bypass.\" The prior fix added prompt-based prohibitions, but those prohibitions are themselves subject to the same compaction that causes the problem.\n\n## Observed Behavior\n\n**Pipeline:** `impl-20260608-081921-178363` (issue #3875, PR #3914)\n\nAfter completing the full pipeline (plan → implement → audit → 3 remediation cycles → PR → review → CI → merge → release_issue), the Codex sous-chef ran:\n\n1. `run_cmd` with `rg -n ... 'pipeline-health|health analyze|...' src tests docs` — searching for the diagnostic skill name\n2. `run_cmd` with `sed` — reading test files looking for the invocation pattern\n3. `run_python` with `autoskillit.recipe.load_recipe` — loading the recipe via Python API\n4. `run_cmd` with `uv run python` — loading the recipe via the project environment\n\nAll unauthorized workarounds for recipe content that should already be in context. Session was manually stopped.\n\n## Root Cause\n\nThree structural factors converge:\n\n1. **Small context window**: Codex operates with ~258,400 tokens (observed from `model_context_window` in session NDJSON log `rollout-2026-06-08T08-24-37-019ea7d5`) vs Claude Code's 1M. Codex auto-compacts at 90% of context window by default (`auto_compact_token_limit()` returns `(context_window * 9) / 10` in `codex-rs/protocol/src/openai_models.rs:428-439`).\n\n2. **Recipe content is conversation-scoped**: The recipe arrives via `open_kitchen` MCP tool result (`tools_kitchen.py:710`) — a tool-use result in conversation history, not in a system prompt. When Codex auto-compacts, this tool result gets summarized. The summary loses the specific `skill_command` values for each step.\n\n3. **Prohibition instructions are also compacted**: The backend supplement text (`_backend_supplement()` in `src/autoskillit/cli/_prompts.py:54-66`) — \"NEVER use run_cmd to read recipe YAML files... call `load_recipe`\" — is injected into the orchestrator prompt as conversation content. It gets compacted away with everything else.\n\n## Why Claude Code Is Unaffected\n\nClaude Code sous-chef sessions have up to 1M tokens. A typical pipeline run does not exhaust this, so compaction does not occur and recipe content persists throughout.\n\n## Required Fixes\n\n### 1. Disable auto-compaction for Codex within autoskillit\n\nSet `model_auto_compact_token_limit = 999999999` in the Codex config.toml at the autoskillit level — not rely on the user's external Codex profile. Without this, pipeline runs will break for any user who hasn't manually disabled auto-compaction in their profile.\n\n**Implementation notes:**\n- The Codex config key is `model_auto_compact_token_limit` (an integer threshold, not a boolean). There is no boolean disable flag — the convention is an astronomically high integer sentinel (`999999999`). Confirmed from `~/.codex/config.toml:8` (user's manual workaround) and `codex-rs/config/src/config_toml.rs:151-156`.\n- The fix belongs in `src/autoskillit/execution/backends/_codex_config.py` inside `ensure_codex_mcp_registered()`. The `_ensure_top_level_key()` helper (line 239) already handles bare top-level scalar insertion and is used for `tool_output_token_limit` — use the same pattern for `model_auto_compact_token_limit`.\n- `_is_autoskillit_registered()` (line 211) must also be updated to check the new key, otherwise re-registration will not detect a missing `model_auto_compact_token_limit` value.\n- Per-session propagation is already handled: `setup_session_dir()` in `codex.py:941` copies `~/.codex/config.toml` to each session directory verbatim.\n- Note: interactive TUI sessions (`build_interactive_cmd`) do NOT go through `setup_session_dir` — they use the global `~/.codex/config.toml` directly, which `ensure_codex_mcp_registered` writes to. Both paths are covered.\n\n### 2. Block `run_cmd` and `run_python` calls to recipe file paths (defense in depth)\n\nAdd a PreToolUse guard hook that structurally blocks unauthorized recipe/skill/agent file access via `run_cmd` and `run_python`. Prompt-based prohibitions alone cannot provide immunity because they are subject to the same compaction that causes the underlying problem.\n\n**Implementation notes:**\n- The guard must intercept both `run_cmd` (inspect `tool_input.cmd` for recipe file paths) and `run_python` (inspect `tool_input.callable` for `autoskillit.recipe.*` imports).\n- Path patterns to block in `run_cmd` (matching the `_backend_supplement` prohibition scope):\n - `(?:\\.autoskillit|src/autoskillit)/recipes/.*\\.ya?ml` — recipe YAML files\n - `src/autoskillit/skills.*/.*SKILL\\.md` — skill definition files\n - `src/autoskillit/agents/.*\\.md` — agent definition files\n- Callable patterns to block in `run_python`: `autoskillit.recipe.load_recipe`, `autoskillit.recipe.*`\n- The existing `recipe_write_advisor.py` is a non-blocking advisory (emits `hookSpecificOutput.message`, never `permissionDecision`) and only fires for Write/Edit tools. The new guard must be a **blocking** guard that emits `permissionDecision: deny`.\n- New guard script must be added to `NEW_SUBDIR_BASENAMES` in `hook_registry.py`.\n- Session scope: should cover both orchestrator and fleet session types. The existing `skill_orchestration_guard.py` already blocks `run_cmd`/`run_python` from skill sessions — the new guard covers the orchestrator tier where those tools are allowed but recipe-path access is not.\n\n## Out of Scope\n\n- **Recipe re-injection after compaction**: Not acceptable — the recipe should never need to be re-injected. The correct fix is preventing compaction from destroying it.\n- **Context budget monitoring**: Being addressed in a separate existing issue related to earlier recipe reloading behavior.\n\n## Evidence\n\n| Item | Value |\n|------|-------|\n| Pipeline | `impl-20260608-081921-178363` |\n| Issue | #3875 |\n| PR | #3914 |\n| Codex sous-chef session | `019ea7d5-f4b6-7b31-a3ac-2211a0b30f52` |\n| Context window | 258,400 tokens |\n| Auto-compact threshold | 90% of context window (default) |\n| Codex config key | `model_auto_compact_token_limit` |\n| Headless sessions | 16 (mixed codex/claude-code backends) |\n| Prior fix | PR #3736 (commit `358c8a168`) |\n| Investigation report | `.autoskillit/temp/investigate/investigation_codex_sous_chef_recipe_context_loss_2026-06-08_131500.md` |\n\n## Affected Components\n\n- `src/autoskillit/execution/backends/_codex_config.py` — must add `model_auto_compact_token_limit = 999999999` via `_ensure_top_level_key()` in `ensure_codex_mcp_registered()`, and update `_is_autoskillit_registered()` to check the new key\n- `src/autoskillit/execution/backends/codex.py:937-983` — `setup_session_dir()` already propagates `~/.codex/config.toml` to per-session dirs (no change needed)\n- `src/autoskillit/hooks/guards/` — needs new blocking guard for recipe/skill/agent file reads via `run_cmd` and `run_python`\n- `src/autoskillit/hook_registry.py` — new guard must be added to `HOOK_REGISTRY` and `NEW_SUBDIR_BASENAMES`\n- `src/autoskillit/cli/_prompts.py:54-66` — `_backend_supplement()` remains as supplementary guidance but is no longer the sole defense\n\n## Closure record\n\n**Verified:** 2026-07-15\n\n- Commit [`87b7f6b569eae909016d36221778b4ca9e9b3264`](https://github.com/TalonT-Org/AutoSkillit/commit/87b7f6b569eae909016d36221778b4ca9e9b3264) from #3947 is present in the current `develop` history.\n- The fix writes and validates `model_auto_compact_token_limit = 999_999_999` for Codex, including stale and corrupt configuration paths; the existing session setup propagates that global configuration into headless session directories.\n- The registered `recipe_read_guard.py` blocks raw recipe-file and recipe-loader recovery attempts and directs the caller to the sanctioned `load_recipe` channel. Configuration and guard regressions cover the missing, stale, corrupt, and deny paths.\n- [ADR-0004](https://github.com/TalonT-Org/AutoSkillit/blob/develop/docs/decisions/0004-recipe-redelivery.md) records the forward obligation to test end-to-end `load_recipe` re-delivery if the unreachable auto-compaction sentinel is ever relaxed. The implemented protections resolve this issue's requested controls; this body record documents verification before manual closure.\n" + }, + "pr4259": { + "number": 4259, + "state": "MERGED", + "title": "Server-authoritative payload delivery for open_kitchen output cap", + "url": "https://github.com/TalonT-Org/AutoSkillit/pull/4259", + "mergedAt": "2026-07-15T01:42:33Z", + "mergeCommit": { + "oid": "1e2670ec7b31fe0010d9c487082e0348699b53ff" + } + } + } + } +} diff --git a/Taskfile.yml b/Taskfile.yml index be6878fe11..37f7c922fc 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -255,6 +255,51 @@ tasks: echo "Codex smoke test output saved to: $TEST_OUTPUT" exit $PYTEST_EXIT + test-smoke-output-budget-e2e: + desc: Run the credentialed deep-investigate output-budget probe for selected backends + deps: [_tmpdir-setup] + env: + PYTHONDONTWRITEBYTECODE: "1" + OMP_NUM_THREADS: "1" + TMPDIR: "{{.PYTEST_TMPDIR}}" + preconditions: + - sh: test "$CODEX_SMOKE_TEST" = "1" || test "$CLAUDE_CODE_SMOKE_TEST" = "1" + msg: "Set CODEX_SMOKE_TEST=1 or CLAUDE_CODE_SMOKE_TEST=1" + - sh: test "$CODEX_SMOKE_TEST" != "1" || test -n "$OPENAI_API_KEY" || test -n "$CODEX_API_KEY" || test -f "$HOME/.codex/auth.json" + msg: "Codex authentication required" + - sh: test "$CLAUDE_CODE_SMOKE_TEST" != "1" || test -n "$ANTHROPIC_API_KEY" || test -n "$CLAUDE_CODE_OAUTH_TOKEN" || test -f "$HOME/.claude/.credentials.json" + msg: "Claude authentication required" + cmds: + - | + mkdir -p .autoskillit/temp + BACKEND_LABEL="codex" + if [ "$CLAUDE_CODE_SMOKE_TEST" = "1" ]; then + BACKEND_LABEL="claude" + fi + if [ "$CODEX_SMOKE_TEST" = "1" ] && [ "$CLAUDE_CODE_SMOKE_TEST" = "1" ]; then + BACKEND_LABEL="all" + fi + TEST_OUTPUT=".autoskillit/temp/smoke-output-budget-e2e-${BACKEND_LABEL}-$(date +%Y-%m-%d_%H%M%S).txt" + echo "Running deep-investigate output-budget smoke tests" + echo "Output: $TEST_OUTPUT" + + if [[ -d ".venv" ]]; then + PYTEST_CMD=".venv/bin/python -m pytest" + else + PYTEST_CMD="pytest" + fi + + set -o pipefail + set +e + $PYTEST_CMD tests/server/test_output_budget_e2e.py -m smoke -v --tb=short -o "addopts=" --basetemp={{.PYTEST_TMPDIR}} -o "cache_dir={{.PYTEST_CACHEDIR}}" 2>&1 | tee "$TEST_OUTPUT" + PYTEST_EXIT=$? + set +o pipefail + set -e + + echo "" + echo "Output-budget smoke test output saved to: $TEST_OUTPUT" + exit $PYTEST_EXIT + test-smoke-record: desc: Record smoke test scenario by running autoskillit order with recording enabled env: diff --git a/src/autoskillit/execution/backends/AGENTS.md b/src/autoskillit/execution/backends/AGENTS.md index 2337b5f716..ba41e09b78 100644 --- a/src/autoskillit/execution/backends/AGENTS.md +++ b/src/autoskillit/execution/backends/AGENTS.md @@ -12,7 +12,7 @@ IL-1 backend abstraction layer — concrete `CodingAgentBackend` implementations | `_probe_cache.py` | `ProbeResult`, `PROBE_CACHE_TTL`, `read_probe_cache`, `write_probe_cache` — versioned probe result cache | | `claude.py` | `ClaudeCodeBackend` (incl. `validate_session_layout`), `ClaudeEnvPolicy`, `ClaudeSessionLocator`, `ClaudeStreamParser`, `ClaudeResultParser` (prompt utilities moved to `_claude_prompt.py`) | | `_claude_prompt.py` | Prompt injection utilities, session constants (`_ensure_skill_prefix`, `_inject_completion_directive`, `_compose_resume_prompt`, etc.), shared by claude + codex + commands | -| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation via `_generate_agent_tomls`), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | +| `codex.py` | `CodexFlags`, `CodexBackend` (incl. `validate_session_layout`, `setup_session_dir` agent TOML generation and session-config registration), `CodexEnvPolicy`, `CodexSessionLocator` (parse/config moved to `_codex_parse.py` / `_codex_config.py`) | | `_codex_config.py` | TOML serialization with `[[key]]` array-of-tables support, MCP registration (`ensure_codex_mcp_registered`, `_serialize_toml`) | | `_codex_hooks.py` | Codex config.toml hook generation, sync, and upsert (`generate_codex_hooks_config`, `sync_hooks_to_codex_config`) | | `_codex_parse.py` | `CodexStreamParser`, `CodexResultParser`, `_scan_codex_ndjson`, `_CodexParseAccumulator` | diff --git a/src/autoskillit/execution/backends/_probe_cache.py b/src/autoskillit/execution/backends/_probe_cache.py index d6931981fd..5f1ef9d156 100644 --- a/src/autoskillit/execution/backends/_probe_cache.py +++ b/src/autoskillit/execution/backends/_probe_cache.py @@ -7,6 +7,7 @@ from __future__ import annotations +import hashlib from dataclasses import dataclass from datetime import UTC, datetime, timedelta from pathlib import Path @@ -23,9 +24,17 @@ logger = get_logger(__name__) PROBE_CACHE_TTL: timedelta = timedelta(hours=24) +PROBE_SUITE_CONTRACT: tuple[str, ...] = ( + "generated-codex-child-v1", + "deep-investigate-codex-v2", + "deep-investigate-claude-200k-v2", +) +PROBE_SUITE_CONTRACT_DIGEST: str = hashlib.sha256( + "\n".join(PROBE_SUITE_CONTRACT).encode("utf-8") +).hexdigest() PROBE_POLICY_IDENTITY: str = ( f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" - f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}" + f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}-{PROBE_SUITE_CONTRACT_DIGEST}" ) _SCHEMA_VERSION: int = 2 diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 01891e8915..c18d22981c 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -478,6 +478,43 @@ def _generate_agent_tomls(session_dir: Path) -> int: return count +def _register_agent_tomls(session_dir: Path) -> int: + """Register generated agent config layers in the session config.""" + config_path = session_dir / "config.toml" + config_text = config_path.read_text(encoding="utf-8") + config = tomllib.loads(config_text) + configured_agents = config.get("agents", {}) + if not isinstance(configured_agents, dict): + configured_agents = {} + + registrations: list[str] = [] + for agent_path in sorted((session_dir / "agents").glob("*.toml")): + agent = tomllib.loads(agent_path.read_text(encoding="utf-8")) + name = agent.get("name") + description = agent.get("description") + if not isinstance(name, str) or not name or name in configured_agents: + continue + if not isinstance(description, str) or not description: + continue + registrations.extend( + [ + f"[agents.{_format_toml_value(name)}]", + f"description = {_format_toml_value(description)}", + f"config_file = {_format_toml_value(f'agents/{agent_path.name}')}", + "", + ] + ) + + if not registrations: + return 0 + separator = "\n" if config_text.endswith("\n") else "\n\n" + registration_text = "\n".join(registrations) + updated = f"{config_text}{separator}{registration_text}" + tomllib.loads(updated) + atomic_write(config_path, updated) + return len(registrations) // 4 + + def _materialize_profile_skills(session_dir: Path) -> int: """Symlink ~/.codex/skills/ dirs into session_dir/skills/. @@ -1109,6 +1146,8 @@ def setup_session_dir(self, session_dir: Path) -> None: try: _generate_agent_tomls(session_dir) + registered = _register_agent_tomls(session_dir) + logger.debug("codex_agents_registered", count=registered) except Exception: logger.warning("codex_agent_toml_generation_failed", exc_info=True) diff --git a/src/autoskillit/skills_extended/investigate/SKILL.md b/src/autoskillit/skills_extended/investigate/SKILL.md index a71f5f743f..faaa9388d4 100644 --- a/src/autoskillit/skills_extended/investigate/SKILL.md +++ b/src/autoskillit/skills_extended/investigate/SKILL.md @@ -97,7 +97,7 @@ tool **before** beginning any analysis. Use the returned `content` field as the **ALWAYS:** - Use subagents for parallel exploration - Issue all Task calls in a single message to maximize parallelism -- Limit total subagent spawns to 9 across all batches (standard and deep mode). If the investigation requires more exploration vectors, consolidate related questions into fewer, broader subagent prompts rather than spawning additional agents. +- Limit total subagent spawns to 16 across all batches (standard and deep mode). If the investigation requires more exploration vectors, consolidate related questions into fewer, broader subagent prompts rather than spawning additional agents. - Spawn all subagents via `Agent(model="sonnet")` - Write findings as a markdown report with unique name to `{{AUTOSKILLIT_TEMP}}/investigate/` directory (relative to the current working directory) - After writing the investigation report, emit the **absolute path** as a structured output @@ -370,7 +370,7 @@ Launch a minimum of 5 parallel subagents via `Agent(model="sonnet")` covering: - **External research**: Web search for known issues, library bugs, documentation - **Design Intent**: Run `git log --follow`, caller tracing, and architecture doc checks on the mechanisms under investigation — identical to the Standard Mode Design Intent vector but with deep mode evidence standards -Simultaneously with Batch 1 subagents, run historical recurrence check (Step 3.5 Parts A+B) in parallel. After Batch 1 completes, produce inter-batch synthesis: summarize confirmed findings, open questions, and new investigative leads. +Simultaneously with Batch 1 subagents, run historical recurrence check (Step 3.5 Parts A+B) in parallel. Batch 1 is a completed agent wave only after every launch has a terminal result. Before launching Batch 2, emit an assistant progress message beginning `Inter-batch synthesis:` that summarizes confirmed findings, open questions, and new investigative leads. After inter-batch synthesis, run Part C of Step 3.5 conditionally: if Parts A+B found prior history, spawn the conditional analysis subagent. Otherwise skip and proceed to D3. @@ -387,6 +387,8 @@ For each subsequent batch (Batch 2, Batch 3, ...): **Empty batch handling:** If a batch produces no new findings (all subagents report the same conclusions as prior batches), treat it as an early termination signal and proceed to D4. +Deep mode must execute at least two completed agent waves. Do not begin a later wave until every launch in the preceding wave has a terminal result and its `Inter-batch synthesis:` progress message has been emitted. + ### Step D4: Challenge Round Fires when ANY finding across any batch is marked NEEDS-EVIDENCE. @@ -432,7 +434,9 @@ After writing the report (Step 4), spawn 2–3 independent validator subagents v - **Validator 2 — Recommendation soundness**: Assess whether the single recommendation is implementable, safe, and correctly scoped. - **Validator 3 — Gap analysis** (optional, spawn if investigation was complex): Identify what the report does not cover that could be relevant. -If any validator identifies errors or gaps, apply in-place corrections to the report before emitting `investigation_path`. The structured output token is emitted **after** all validation and correction is complete. +Every validator launch must reach a terminal result before emitting `investigation_path`. If any validator identifies errors or gaps, apply in-place corrections to the report before finalizing validation. The structured output token is emitted **after** all validation and correction is complete, as the final line of the final assistant message with no later assistant text. + +Before finalizing validation, check the report's structure directly. It must retain the exact Step 4 headings, including the literal plural heading `## Recommendations`; correct singular or renamed headings in place. Confirm the final assistant message contains the exact report path token only after this structural check and every validator terminal result. ## Subagent Prompt Template @@ -473,7 +477,7 @@ Evidence standards: - Mark finding as SUPPORTED (direct evidence) or NEEDS-EVIDENCE (inferred) This is a research task - DO NOT modify any code. -Report your findings in a structured format with explicit evidence citations. +Report your findings in a structured format with explicit evidence citations, using at most 800 words. ``` ### Deep Analysis Mode Template @@ -497,7 +501,7 @@ Evidence standards: - Mark each finding as SUPPORTED (direct evidence), UNSUPPORTED (contradicted), or NEEDS-EVIDENCE (not yet confirmed) This is a research task - DO NOT modify any code. -Report your findings in a structured format with explicit evidence citations. +Report your findings in a structured format with explicit evidence citations, using at most 800 words. ``` ### Adversarial Subagent Template (Challenge Round) @@ -554,7 +558,7 @@ Gap Analysis Validator tasks: - Identify any areas NOT listed that should have been explored - Report whether the investigation coverage is sufficient for the stated findings -Report your findings in a structured format. If corrections are needed, state them explicitly. +Report your findings in a structured format using at most 600 words. If corrections are needed, state them explicitly. This is a research task - DO NOT modify any code. ``` diff --git a/tests/arch/test_subpackage_isolation.py b/tests/arch/test_subpackage_isolation.py index 706a297336..f77bec47af 100644 --- a/tests/arch/test_subpackage_isolation.py +++ b/tests/arch/test_subpackage_isolation.py @@ -1059,7 +1059,7 @@ def test_data_directories_are_not_python_packages() -> None: "and ambiguous/empty step_name dependency-deny branches (+126 net lines)", ), "execution/backends/codex.py": ( - 1167, + 1210, "REQ-CNST-010-E9: Codex backend — skill_sigil capability threading adds multi-line " "keyword args to _ensure_skill_prefix call sites and _has_prefix guard; " "write_guard_tool_names env injection adds 7 lines to _codex_exec_extras; " @@ -1087,7 +1087,9 @@ def test_data_directories_are_not_python_packages() -> None: "replacing noqa:F841 silent discards (+18 net lines) for T5-P4-A2-WP1" "; github_api_callable field + evidence comment in BackendCapabilities (+2 net lines)" "; output-discipline delivery for fresh interactive sessions and generated agent " - "TOMLs (+12 net lines)", + "TOMLs (+12 net lines)" + "; _register_agent_tomls session-config registration for generated roles " + "(+39 net lines)", ), } diff --git a/tests/execution/AGENTS.md b/tests/execution/AGENTS.md index 9bc3bd82c0..0d9288279e 100644 --- a/tests/execution/AGENTS.md +++ b/tests/execution/AGENTS.md @@ -159,5 +159,5 @@ Subprocess integration, headless session, process lifecycle, and session result | `test_codex_deterministic_conformance.py` | Sealed-enum vocabulary, hook event format, and config.toml schema template conformance tests with --update-fixtures review gate | | `test_cmd_builder.py` | CmdBuilder ordering invariant and CmdSpec origin tests | | `test_coding_agent_backend_conformance.py` | Parametrized conformance tests for all CodingAgentBackend implementations via BackendContractBase | -| `test_cli_conformance_probes.py` | Live backend CLI conformance probes: schema checks plus isolated output-budget deny round trips with ProbeCache and shared error discrimination | +| `test_cli_conformance_probes.py` | Live backend CLI conformance probes: schema checks, isolated output-budget deny round trips, and generated Codex child delivery/linkage with ProbeCache and shared error discrimination | | `test_probe_cache.py` | Versioned probe-cache tests: CLI/policy identity invalidation, TTL, schema, and write preservation | diff --git a/tests/execution/backends/_conformance_assertions.py b/tests/execution/backends/_conformance_assertions.py index bece5ba25f..610e809a9b 100644 --- a/tests/execution/backends/_conformance_assertions.py +++ b/tests/execution/backends/_conformance_assertions.py @@ -7,6 +7,7 @@ from __future__ import annotations import hashlib +import json from pathlib import Path from autoskillit.core.types._type_enums import CodexEventType @@ -163,3 +164,90 @@ def assert_terminal_sentinel_preserved( ) observed_markers = [marker for marker in truncation_markers if marker in delivered_text] assert not observed_markers, f"transport truncation markers present: {observed_markers}" + + +def assert_generated_child_delivery( + parent_events: list[dict], + child_events: list[dict], + *, + parent_id: str, + agent_role: str, + output_discipline_digest: str, +) -> None: + """Assert a generated Codex role reached one completed, linked child session.""" + function_calls = [ + event.get("payload", {}) + for event in parent_events + if event.get("type") == "response_item" + and event.get("payload", {}).get("type") == "function_call" + ] + call_outputs = { + str(payload.get("call_id", "")): payload.get("output", "") + for event in parent_events + if event.get("type") == "response_item" + and (payload := event.get("payload", {})).get("type") == "function_call_output" + } + + spawn_calls = [call for call in function_calls if call.get("name") == "spawn_agent"] + assert len(spawn_calls) == 1, f"expected one spawn_agent call, got {len(spawn_calls)}" + spawn = spawn_calls[0] + spawn_args = json.loads(str(spawn.get("arguments", "{}"))) + assert spawn_args.get("agent_type") == agent_role + spawn_output = json.loads(str(call_outputs.get(str(spawn.get("call_id", "")), "{}"))) + agent_id = spawn_output.get("agent_id") + assert isinstance(agent_id, str) and agent_id, "spawn_agent returned no agent_id" + + wait_calls = [call for call in function_calls if call.get("name") == "wait_agent"] + matching_waits = [] + for wait_call in wait_calls: + wait_args = json.loads(str(wait_call.get("arguments", "{}"))) + if wait_args.get("targets") == [agent_id]: + matching_waits.append(wait_call) + assert matching_waits, f"no wait_agent call targeted spawned child {agent_id}" + wait_outputs = [ + str(call_outputs.get(str(wait_call.get("call_id", "")), "")) + for wait_call in matching_waits + ] + assert any('"completed"' in output for output in wait_outputs), ( + f"wait_agent never reported child {agent_id} completed" + ) + + child_session_metas = [ + event.get("payload", {}) for event in child_events if event.get("type") == "session_meta" + ] + linked_children = [ + meta + for meta in child_session_metas + if (meta.get("forked_from_id") or meta.get("parent_thread_id")) == parent_id + ] + assert len(linked_children) == 1, ( + f"expected one child linked to {parent_id}, got {len(linked_children)}" + ) + child = linked_children[0] + assert child.get("id") == agent_id + assert child.get("id") != parent_id + assert (child.get("forked_from_id") or child.get("parent_thread_id")) == parent_id + assert child.get("agent_role") == agent_role + base_instructions = child.get("base_instructions", {}) + assert isinstance(base_instructions, dict) + base_text = base_instructions.get("text", "") + developer_blocks = [] + for event in child_events: + payload = event.get("payload", {}) + if ( + event.get("type") != "response_item" + or payload.get("type") != "message" + or payload.get("role") != "developer" + ): + continue + content = payload.get("content", []) + if isinstance(content, str): + developer_blocks.append(content) + elif isinstance(content, list): + developer_blocks.extend( + str(block.get("text", "")) for block in content if isinstance(block, dict) + ) + developer_text = "\n".join(developer_blocks) + assert output_discipline_digest in base_text or output_discipline_digest in developer_text, ( + "generated child instructions omitted output discipline digest" + ) diff --git a/tests/execution/backends/test_cli_conformance_probes.py b/tests/execution/backends/test_cli_conformance_probes.py index 566eda543f..2c8582f25a 100644 --- a/tests/execution/backends/test_cli_conformance_probes.py +++ b/tests/execution/backends/test_cli_conformance_probes.py @@ -12,6 +12,7 @@ import os import shutil import subprocess +import tomllib from collections.abc import Callable from datetime import UTC, datetime from pathlib import Path @@ -25,7 +26,11 @@ ErrorKind, ) from autoskillit.config import OutputBudgetConfig -from autoskillit.core import RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, pkg_root +from autoskillit.core import ( + OUTPUT_DISCIPLINE_DIGEST, + RESPONSE_BACKSTOP_EXEMPTION_REGISTRY, + pkg_root, +) from autoskillit.execution.backends._codex_config import ( CODEX_TOOL_OUTPUT_TOKEN_LIMIT, ensure_codex_mcp_registered, @@ -37,10 +42,12 @@ read_probe_cache, write_probe_cache, ) +from autoskillit.execution.backends.codex import CodexBackend from autoskillit.hook_registry import generate_hooks_json from tests.execution.backends._conformance_assertions import ( assert_boundary_spill_behavior, assert_config_schema, + assert_generated_child_delivery, assert_hook_event_format, assert_inline_within_byte_budget, assert_no_unknown_event_types, @@ -58,13 +65,14 @@ "Set CODEX_SMOKE_TEST=1 and one of: CODEX_API_KEY, OPENAI_API_KEY," " or ~/.codex/auth.json to run Codex smoke tests" ) +_CODEX_AUTH_PATH = Path("~/.codex/auth.json").expanduser() _skip_unless_codex_smoke = pytest.mark.skipif( not os.environ.get("CODEX_SMOKE_TEST") or ( not os.environ.get("CODEX_API_KEY") and not os.environ.get("OPENAI_API_KEY") - and not Path("~/.codex/auth.json").expanduser().exists() + and not _CODEX_AUTH_PATH.exists() ), reason=_SKIP_REASON, ) @@ -333,6 +341,20 @@ def _assert(output: _CodexProbeOutput) -> None: ), ) +_skip_unless_codex_generated_child_smoke = pytest.mark.skipif( + not os.environ.get("CODEX_SMOKE_TEST") + or not shutil.which("codex") + or ( + not os.environ.get("CODEX_API_KEY") + and not os.environ.get("OPENAI_API_KEY") + and not _CODEX_AUTH_PATH.is_file() + ), + reason=( + "Set CODEX_SMOKE_TEST=1 and provide an environment API key or authenticated " + "~/.codex/auth.json to run the generated Codex child probe" + ), +) + _skip_unless_claude_output_budget_smoke = pytest.mark.skipif( not os.environ.get("CLAUDE_CODE_SMOKE_TEST") or not shutil.which("claude") @@ -349,6 +371,14 @@ class _DenyRoundTripOutput(NamedTuple): cli_version: str +class _GeneratedChildProbeOutput(NamedTuple): + parent_events: list[dict] + child_events: list[dict] + parent_id: str + agent_role: str + cli_version: str + + def _isolated_cli_env(tmp_path: Path, workspace: Path) -> tuple[dict[str, str], Path, Path]: home = tmp_path / "home" codex_home = tmp_path / "codex-home" @@ -385,6 +415,168 @@ def _cli_version(binary: str, env: dict[str, str]) -> str: return "unknown" +def _read_ndjson(path: Path) -> list[dict]: + events: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(event, dict): + events.append(event) + return events + + +def _run_generated_child_probe( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> _GeneratedChildProbeOutput: + source_auth = _CODEX_AUTH_PATH + profile_home = tmp_path / "profile-home" + profile_codex_home = profile_home / ".codex" + session_home = tmp_path / "session-home" + workspace = tmp_path / "workspace" + for directory in (profile_codex_home, session_home, workspace): + directory.mkdir(parents=True) + if source_auth.is_file(): + (profile_codex_home / "auth.json").symlink_to(source_auth.resolve()) + + monkeypatch.setenv("HOME", str(profile_home)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: profile_home)) + monkeypatch.setenv("XDG_CONFIG_HOME", str(profile_home / ".config")) + monkeypatch.setenv("XDG_DATA_HOME", str(profile_home / ".local" / "share")) + profile_config = profile_codex_home / "config.toml" + ensure_codex_mcp_registered(config_path=profile_config, headless_auto_gate=False) + sync_hooks_to_codex_config(config_path=profile_config) + CodexBackend().setup_session_dir(session_home) + + agent_role = "wp-elaborator" + agent_toml = session_home / "agents" / f"{agent_role}.toml" + assert agent_toml.is_file(), f"generated role missing: {agent_toml}" + agent_definition = tomllib.loads(agent_toml.read_text(encoding="utf-8")) + assert OUTPUT_DISCIPLINE_DIGEST in agent_definition["developer_instructions"] + session_config = tomllib.loads((session_home / "config.toml").read_text(encoding="utf-8")) + assert session_config["agents"][agent_role]["config_file"] == (f"agents/{agent_role}.toml") + + env = dict(os.environ) + env.update( + { + "HOME": str(profile_home), + "CODEX_HOME": str(session_home), + "XDG_CONFIG_HOME": str(profile_home / ".config"), + "XDG_DATA_HOME": str(profile_home / ".local" / "share"), + } + ) + subprocess.run( + ["git", "init", "-q"], + cwd=workspace, + env=env, + check=True, + capture_output=True, + text=True, + timeout=10, + ) + prompt = ( + "This is a generated-subagent delivery probe. Call spawn_agent exactly once with " + f'agent_type="{agent_role}", fork_context=false, and a message asking the child ' + "to reply exactly child-delivery-complete without using tools. Then call wait_agent " + "with only the returned agent id until it reports completed. Finally respond exactly " + "parent-delivery-complete. You may call tool_search only to discover spawn_agent and " + "wait_agent; do not call other tool types." + ) + timeout = int(os.environ.get("GENERATED_CHILD_SMOKE_TIMEOUT", "900")) + result = subprocess.run( # noqa: S603 + [ + "codex", + "exec", + "--json", + "--sandbox", + "workspace-write", + "--model", + os.environ.get("GENERATED_CHILD_SMOKE_MODEL", "gpt-5.4"), + prompt, + ], + cwd=workspace, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + raise OSError( + f"generated child probe failed with rc={result.returncode}: " + f"{result.stdout}\n{result.stderr}" + ) + stdout_events = [] + for line in result.stdout.splitlines(): + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(event, dict): + stdout_events.append(event) + parent_ids = [ + str(event.get("thread_id", "")) + for event in stdout_events + if event.get("type") == "thread.started" and event.get("thread_id") + ] + assert len(parent_ids) == 1, f"expected one parent thread, got {parent_ids}" + parent_id = parent_ids[0] + + rollout_root = (session_home / "sessions").resolve() + rollout_events = [_read_ndjson(path) for path in rollout_root.rglob("rollout-*.jsonl")] + parent_events: list[dict] = [] + child_events: list[dict] = [] + for events in rollout_events: + session_metas = [ + event.get("payload", {}) for event in events if event.get("type") == "session_meta" + ] + if any(meta.get("id") == parent_id for meta in session_metas): + parent_events = events + if any( + (meta.get("forked_from_id") or meta.get("parent_thread_id")) == parent_id + for meta in session_metas + ): + child_events.extend(events) + assert parent_events, f"parent rollout not found for {parent_id} under {rollout_root}" + return _GeneratedChildProbeOutput( + parent_events=parent_events, + child_events=child_events, + parent_id=parent_id, + agent_role=agent_role, + cli_version=_cli_version("codex", env), + ) + + +def _assert_generated_child_probe(output: _GeneratedChildProbeOutput) -> None: + assert_generated_child_delivery( + output.parent_events, + output.child_events, + parent_id=output.parent_id, + agent_role=output.agent_role, + output_discipline_digest=OUTPUT_DISCIPLINE_DIGEST, + ) + + +@_skip_unless_codex_generated_child_smoke +def test_generated_codex_child_receives_output_discipline( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + workspace = tmp_path / "version-workspace" + workspace.mkdir() + version_env, _, _ = _isolated_cli_env(tmp_path / "version-env", workspace) + cli_version = _cli_version("codex", version_env) + _run_probe_with_discrimination( + "generated_codex_child", + cli_version, + lambda: _run_generated_child_probe(tmp_path / "generated-child", monkeypatch), + _assert_generated_child_probe, + record_success=lambda _version: None, + record_failure=lambda _kind, _name, _version, _detail: None, + ) + + def _run_output_budget_deny_probe(backend: str, tmp_path: Path) -> _DenyRoundTripOutput: tmp_path.mkdir(parents=True, exist_ok=True) workspace = tmp_path / "workspace" diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 2b74551435..d74d1c8124 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -1824,6 +1824,33 @@ def test_agent_toml_required_fields_present_and_nonempty(self) -> None: f"{toml_path.name}: wrong sandbox_mode" ) + def test_generated_agents_are_registered_in_session_config(self) -> None: + import tomllib + + self._write_all_source_files() + CodexBackend().setup_session_dir(self.session_dir) + config = tomllib.loads((self.session_dir / "config.toml").read_text(encoding="utf-8")) + generated_names = {path.stem for path in (self.session_dir / "agents").glob("*.toml")} + assert generated_names <= config["agents"].keys() + wp_role = config["agents"]["wp-elaborator"] + assert wp_role["config_file"] == "agents/wp-elaborator.toml" + assert wp_role["description"] + + def test_profile_agent_registration_takes_precedence(self) -> None: + import tomllib + + (self.codex_home / "config.toml").write_text( + '[agents."wp-elaborator"]\n' + 'description = "profile role"\n' + 'config_file = "/profile/wp-elaborator.toml"\n' + ) + CodexBackend().setup_session_dir(self.session_dir) + config = tomllib.loads((self.session_dir / "config.toml").read_text(encoding="utf-8")) + assert config["agents"]["wp-elaborator"] == { + "description": "profile role", + "config_file": "/profile/wp-elaborator.toml", + } + def test_agent_toml_model_alias_mapped(self) -> None: import tomllib diff --git a/tests/execution/backends/test_codex_deterministic_conformance.py b/tests/execution/backends/test_codex_deterministic_conformance.py index ba341f6fef..599adf8259 100644 --- a/tests/execution/backends/test_codex_deterministic_conformance.py +++ b/tests/execution/backends/test_codex_deterministic_conformance.py @@ -337,21 +337,21 @@ def test_all_assertions_called(self) -> None: if name.startswith("assert_") and callable(getattr(ca_mod, name)) } - source = inspect.getsource( - importlib.import_module( - "tests.execution.backends.test_codex_deterministic_conformance" - ) - ) - tree = ast.parse(source) - + test_modules = [ + "tests.execution.backends.test_codex_deterministic_conformance", + "tests.execution.backends.test_cli_conformance_probes", + ] called_names: set[str] = set() - for node in ast.walk(tree): - if isinstance(node, ast.Call): - func = node.func - if isinstance(func, ast.Name) and func.id in exported_names: - called_names.add(func.id) - elif isinstance(func, ast.Attribute) and func.attr in exported_names: - called_names.add(func.attr) + for mod_name in test_modules: + source = inspect.getsource(importlib.import_module(mod_name)) + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.Call): + func = node.func + if isinstance(func, ast.Name) and func.id in exported_names: + called_names.add(func.id) + elif isinstance(func, ast.Attribute) and func.attr in exported_names: + called_names.add(func.attr) missing = exported_names - called_names - assert not missing, f"Conformance assertions not called in test file: {sorted(missing)}" + assert not missing, f"Conformance assertions not called in test files: {sorted(missing)}" diff --git a/tests/execution/backends/test_probe_cache.py b/tests/execution/backends/test_probe_cache.py index bc6c809c63..814fa65439 100644 --- a/tests/execution/backends/test_probe_cache.py +++ b/tests/execution/backends/test_probe_cache.py @@ -15,6 +15,8 @@ _SCHEMA_VERSION, PROBE_CACHE_TTL, PROBE_POLICY_IDENTITY, + PROBE_SUITE_CONTRACT, + PROBE_SUITE_CONTRACT_DIGEST, ProbeResult, read_probe_cache, write_probe_cache, @@ -171,7 +173,12 @@ def test_returns_none_on_naive_timestamp(self, tmp_path: Path) -> None: def test_probe_policy_identity_uses_output_discipline_authorities() -> None: assert PROBE_POLICY_IDENTITY == ( f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" - f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}" + f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}-{PROBE_SUITE_CONTRACT_DIGEST}" + ) + assert PROBE_SUITE_CONTRACT == ( + "generated-codex-child-v1", + "deep-investigate-codex-v2", + "deep-investigate-claude-200k-v2", ) diff --git a/tests/infra/AGENTS.md b/tests/infra/AGENTS.md index 7321c3d446..60198af793 100644 --- a/tests/infra/AGENTS.md +++ b/tests/infra/AGENTS.md @@ -70,7 +70,7 @@ CI/CD configuration, security, guard coverage, and release sanity tests. | `test_skill_cmd_check.py` | Unit tests for the skill_cmd_check PreToolUse hook | | `test_skill_load_guard.py` | Tests for guards/skill_load_guard.py PreToolUse hook — denies native tools until Skill called | | `test_skill_command_guard.py` | Tests for the skill_command_guard PreToolUse hook | -| `test_taskfile.py` | Taskfile structural tests, including supported delegation for the installed Codex config-parse gate | +| `test_taskfile.py` | Taskfile structural tests, including installed Codex config-parse and credentialed output-budget smoke gates | | `test_testmon_eval.py` | Testmon eval tests | | `test_token_summary_core.py` | Tests: token_summary_appender core — early-exit, happy path, session filtering, efficiency table | | `test_token_summary_filters.py` | Tests: token_summary_appender unit helpers (_canonical, _humanize, _format_table, _unwrap_mcp_response), order_id isolation, and config key migration | diff --git a/tests/infra/test_taskfile.py b/tests/infra/test_taskfile.py index 3fea9a5fe2..197cfc5033 100644 --- a/tests/infra/test_taskfile.py +++ b/tests/infra/test_taskfile.py @@ -161,6 +161,19 @@ def test_codex_config_parse_target_uses_supported_test_gate(self) -> None: assert "python -m pytest" not in commands assert re.search(r"(^|\s)pytest(?:\s|$)", commands) is None + def test_output_budget_e2e_target_selects_credentialed_smoke_test(self) -> None: + data = self._load() + task = data["tasks"]["test-smoke-output-budget-e2e"] + commands = "\n".join(str(command) for command in task.get("cmds", [])) + preconditions = "\n".join( + str(precondition) for precondition in task.get("preconditions", []) + ) + assert "tests/server/test_output_budget_e2e.py" in commands + assert "-m smoke" in commands + assert ".autoskillit/temp/smoke-output-budget-e2e-" in commands + assert "CODEX_SMOKE_TEST" in preconditions + assert "CLAUDE_CODE_SMOKE_TEST" in preconditions + def test_taskfile_pytest_paths_exist() -> None: """All pytest file paths in Taskfile.yml must exist.""" diff --git a/tests/server/AGENTS.md b/tests/server/AGENTS.md index 238637f211..5222c07184 100644 --- a/tests/server/AGENTS.md +++ b/tests/server/AGENTS.md @@ -123,7 +123,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool | `test_no_path_cwd_in_tools.py` | Regression guard: Path.cwd() must not appear in server tool handlers | | `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 against controlled large-output fixtures for Codex and Claude Code | +| `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_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/test_output_budget_e2e.py b/tests/server/test_output_budget_e2e.py index 888e7e637a..7d0698f149 100644 --- a/tests/server/test_output_budget_e2e.py +++ b/tests/server/test_output_budget_e2e.py @@ -14,6 +14,7 @@ import shutil import subprocess from pathlib import Path +from typing import NamedTuple from unittest.mock import AsyncMock import pytest @@ -35,6 +36,13 @@ pytestmark = [pytest.mark.layer("server"), pytest.mark.large, pytest.mark.smoke] _INVESTIGATION_PATH_RE = re.compile(r"investigation_path\s*=\s*(/\S+)") +_PROBE_RECIPE_NAME = "output-budget-deep-investigate-probe" +_PROBE_STEP_NAME = "output_budget_deep_investigate_probe" +_FIXTURE_SENTINELS = ( + "HEAD-SENTINEL::deep-investigate", + "MIDDLE-SENTINEL::deep-investigate", + "TAIL-SENTINEL::deep-investigate", +) _REPORT_REQUIRED_SECTIONS = ( "## Summary", "## Affected Components", @@ -43,14 +51,24 @@ "## Scope Boundary", "## Recommendations", ) +_CODEX_AUTH_PATH = Path("~/.codex/auth.json").expanduser() +_CLAUDE_CREDENTIALS_PATH = Path("~/.claude/.credentials.json").expanduser() def _has_codex_credentials() -> bool: - return bool(os.environ.get("CODEX_API_KEY") or os.environ.get("OPENAI_API_KEY")) + return bool( + os.environ.get("CODEX_API_KEY") + or os.environ.get("OPENAI_API_KEY") + or _CODEX_AUTH_PATH.is_file() + ) def _has_claude_credentials() -> bool: - return bool(os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")) + return bool( + os.environ.get("ANTHROPIC_API_KEY") + or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + or _CLAUDE_CREDENTIALS_PATH.is_file() + ) _BACKEND_CASES = [ @@ -76,9 +94,9 @@ def _has_claude_credentials() -> bool: def _fixture_payload(size: int = 520_000) -> str: - head = "HEAD-SENTINEL::deep-investigate\n" - middle = "\nMIDDLE-SENTINEL::deep-investigate\n" - tail = "\nTAIL-SENTINEL::deep-investigate" + head = f"{_FIXTURE_SENTINELS[0]}\n" + middle = f"\n{_FIXTURE_SENTINELS[1]}\n" + tail = f"\n{_FIXTURE_SENTINELS[2]}" filler = size - len(head) - len(middle) - len(tail) before_middle = filler // 2 payload = head + ("a" * before_middle) + middle + ("z" * (filler - before_middle)) + tail @@ -107,13 +125,16 @@ def _configure_isolated_cli( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: + source_codex_auth = _CODEX_AUTH_PATH + source_claude_credentials = _CLAUDE_CREDENTIALS_PATH home = tmp_path / "home" codex_home = home / ".codex" - claude_config = tmp_path / "claude-config" + claude_config = home / ".claude" for directory in (home, codex_home, claude_config): directory.mkdir(parents=True) monkeypatch.setenv("HOME", str(home)) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) monkeypatch.setenv("CODEX_HOME", str(codex_home)) monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(claude_config)) monkeypatch.setenv("XDG_CONFIG_HOME", str(home / ".config")) @@ -123,17 +144,21 @@ def _configure_isolated_cli( monkeypatch.setenv("AUTOSKILLIT_FEATURES__EXPERIMENTAL_ENABLED", "true") if backend == "codex": + if source_codex_auth.is_file(): + (codex_home / "auth.json").symlink_to(source_codex_auth.resolve()) config_path = codex_home / "config.toml" ensure_codex_mcp_registered(config_path=config_path) sync_hooks_to_codex_config(config_path=config_path) else: + if source_claude_credentials.is_file(): + (claude_config / ".credentials.json").symlink_to(source_claude_credentials.resolve()) (claude_config / "settings.json").write_text( json.dumps(generate_hooks_json(), indent=2) + "\n", encoding="utf-8", ) -def _assert_investigation_result(raw_result: str, fixture_path: Path) -> None: +def _assert_investigation_result(raw_result: str, fixture_path: Path) -> Path: result = json.loads(raw_result) assert result["success"] is True, result match = _INVESTIGATION_PATH_RE.search(str(result.get("result", ""))) @@ -145,9 +170,325 @@ def _assert_investigation_result(raw_result: str, fixture_path: Path) -> None: assert section in report, f"report missing coherent synthesis section {section!r}" assert str(fixture_path) in report or fixture_path.name in report assert "Deep Analysis" in report + for sentinel in _FIXTURE_SENTINELS: + assert sentinel in report, f"report omitted bounded fixture evidence {sentinel!r}" + return report_path + + +class _WorkflowEvent(NamedTuple): + kind: str + name: str + call_id: str + content: str + source_index: int + + +def _session_index_offset(index_path: Path) -> int: + return index_path.stat().st_size if index_path.exists() else 0 + + +def _read_appended_session_rows(index_path: Path, offset: int) -> list[dict]: + with index_path.open("rb") as handle: + handle.seek(offset) + raw_lines = handle.read().decode("utf-8").splitlines() + rows: list[dict] = [] + for line in raw_lines: + if not line.strip(): + continue + row = json.loads(line) + assert isinstance(row, dict) + rows.append(row) + return rows + + +def _select_probe_session_row( + rows: list[dict], + *, + result: dict, + backend: str, + fixture_repo: Path, + configured_model: str, +) -> dict: + matches = [ + row + for row in rows + if row.get("session_id") == result.get("session_id") + and row.get("step_name") == _PROBE_STEP_NAME + and row.get("cwd") == str(fixture_repo) + and row.get("backend") == backend + ] + assert len(matches) == 1, f"expected one new probe session row, got {matches}" + row = matches[0] + assert row["success"] is True + assert row["backend_override_source"] == "explicit_config" + assert row["recipe_name"] == _PROBE_RECIPE_NAME + assert row["configured_model"] == configured_model + return row + + +def _read_raw_events(path_value: object) -> list[dict]: + assert isinstance(path_value, str) and path_value, "session index omitted backend log path" + path = Path(path_value) + assert path.is_file(), f"backend log does not exist: {path}" + events: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + try: + event = json.loads(line) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(event, dict): + events.append(event) + return events + + +def _assert_claude_200k_provenance(raw_events: list[dict]) -> None: + observed_models = { + str(message.get("model", "")) + for event in raw_events + if event.get("type") == "assistant" + and event.get("isSidechain") is not True + and isinstance((message := event.get("message", {})), dict) + and message.get("model") + } + assert observed_models, "persisted Claude log contains no parent assistant model" + assert all("sonnet" in model.lower() for model in observed_models) + assert all("[1m]" not in model for model in observed_models) + raw_text = "\n".join(json.dumps(event, sort_keys=True).lower() for event in raw_events) + assert "prompt is too long" not in raw_text + assert "context_exhaust" not in raw_text + + +def _content_text(value: object) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + return "\n".join(part for item in value if (part := _content_text(item))) + if isinstance(value, dict): + text_parts = [] + for key in ("text", "content", "output_text"): + if key in value and (part := _content_text(value[key])): + text_parts.append(part) + return "\n".join(text_parts) + return "" + + +def _normalize_workflow_events(backend: str, raw_events: list[dict]) -> list[_WorkflowEvent]: + normalized: list[_WorkflowEvent] = [] + for index, event in enumerate(raw_events): + if backend == "claude-code": + if event.get("isSidechain") is True: + continue + record_type = event.get("type") + message = event.get("message", {}) + if not isinstance(message, dict): + continue + blocks = message.get("content", []) + if not isinstance(blocks, list): + continue + for block in blocks: + if not isinstance(block, dict): + continue + block_type = block.get("type") + if record_type == "assistant" and block_type == "text": + normalized.append( + _WorkflowEvent("text", "", "", str(block.get("text", "")), index) + ) + elif record_type == "assistant" and block_type == "tool_use": + name = str(block.get("name", "")) + kind = "launch" if name in {"Agent", "Task"} else "tool_call" + normalized.append( + _WorkflowEvent( + kind, + name, + str(block.get("id", "")), + json.dumps(block.get("input", {}), sort_keys=True), + index, + ) + ) + elif record_type == "user" and block_type == "tool_result": + normalized.append( + _WorkflowEvent( + "completion", + "", + str(block.get("tool_use_id", "")), + _content_text(block.get("content", "")), + index, + ) + ) + elif backend == "codex": + if event.get("type") != "response_item": + continue + payload = event.get("payload", {}) + if not isinstance(payload, dict): + continue + payload_type = payload.get("type") + if payload_type == "function_call": + name = str(payload.get("name", "")) + kind = "launch" if name in {"spawn_agent", "followup_task"} else "tool_call" + normalized.append( + _WorkflowEvent( + kind, + name, + str(payload.get("call_id", "")), + str(payload.get("arguments", "")), + index, + ) + ) + elif payload_type == "function_call_output": + normalized.append( + _WorkflowEvent( + "completion", + "", + str(payload.get("call_id", "")), + str(payload.get("output", "")), + index, + ) + ) + elif payload_type == "message" and payload.get("role") == "assistant": + normalized.append( + _WorkflowEvent( + "text", + "", + "", + _content_text(payload.get("content", [])), + index, + ) + ) + elif payload_type == "agent_message": + content = _content_text(payload.get("content", [])) + sender_match = re.search(r"(?m)^Sender:\s*(\S+)", content) + normalized.append( + _WorkflowEvent( + "agent_result", + sender_match.group(1) if sender_match else "", + "", + content, + index, + ) + ) + elif payload_type == "custom_tool_call": + name = str(payload.get("name", "")) + content = str(payload.get("input", "")) + if "inter-batch synthesis:" in content.lower(): + normalized.append(_WorkflowEvent("text", name, "", content, index)) + if name in {"apply_patch", "tools.apply_patch"} or "tools.apply_patch" in content: + normalized.append( + _WorkflowEvent( + "tool_call", + "apply_patch", + str(payload.get("call_id", "")), + content, + index, + ) + ) + else: # pragma: no cover - parametrization is sealed above + raise AssertionError(f"unsupported backend {backend}") + return normalized + + +def _json_object(value: str) -> dict: + try: + parsed = json.loads(value) + except (json.JSONDecodeError, TypeError): + return {} + return parsed if isinstance(parsed, dict) else {} + + +def _terminal_completion_positions(events: list[_WorkflowEvent]) -> dict[str, int]: + completions = {event.call_id: event for event in events if event.kind == "completion"} + agent_results = [event for event in events if event.kind == "agent_result"] + terminal_positions: dict[str, int] = {} + for launch in (event for event in events if event.kind == "launch"): + immediate = completions.get(launch.call_id) + if immediate is None: + continue + if launch.name not in {"spawn_agent", "followup_task"}: + terminal_positions[launch.call_id] = immediate.source_index + continue + if launch.name == "spawn_agent": + target = _json_object(immediate.content).get("task_name") + else: + target = _json_object(launch.content).get("target") + if not isinstance(target, str) or not target: + continue + for result in agent_results: + if result.name == target and result.source_index > launch.source_index: + terminal_positions[launch.call_id] = result.source_index + break + return terminal_positions + + +def _successful_launches(events: list[_WorkflowEvent]) -> list[_WorkflowEvent]: + completions = {event.call_id: event for event in events if event.kind == "completion"} + successful = [] + for launch in (event for event in events if event.kind == "launch"): + immediate = completions.get(launch.call_id) + if immediate is None: + continue + if launch.name != "spawn_agent" or _json_object(immediate.content).get("task_name"): + successful.append(launch) + return successful + + +def _assert_deep_workflow_evidence( + events: list[_WorkflowEvent], + report_path: Path, +) -> None: + launches = _successful_launches(events) + assert 2 <= len(launches) <= 16, f"unexpected subagent launch count: {len(launches)}" + completion_positions = _terminal_completion_positions(events) + for launch in launches: + assert launch.call_id in completion_positions, ( + f"subagent launch {launch.call_id} has no terminal result" + ) + assert completion_positions[launch.call_id] > launch.source_index + + synthesis_events = [ + event + for event in events + if event.kind == "text" and "inter-batch synthesis:" in event.content.lower() + ] + assert synthesis_events, "deep workflow emitted no inter-batch synthesis" + first_synthesis = synthesis_events[0].source_index + first_wave = [launch for launch in launches if launch.source_index < first_synthesis] + later_waves = [launch for launch in launches if launch.source_index > first_synthesis] + assert first_wave and later_waves, "deep workflow did not execute two agent waves" + assert all(completion_positions[launch.call_id] < first_synthesis for launch in first_wave) + + report_writes = [ + event + for event in events + if event.kind == "tool_call" + and (str(report_path) in event.content or report_path.name in event.content) + ] + assert report_writes, f"raw backend log has no report publication for {report_path}" + report_write_index = report_writes[0].source_index + later_exploration = [ + launch for launch in later_waves if launch.source_index < report_write_index + ] + assert later_exploration, "deep workflow launched no later exploration wave" + assert all( + completion_positions[launch.call_id] < report_write_index for launch in later_exploration + ), "later agent wave did not complete before report publication" + + validators = [launch for launch in launches if launch.source_index > report_write_index] + assert len(validators) >= 2, f"expected at least two D6 validators, got {len(validators)}" + assert all(launch.source_index > report_write_index for launch in validators) + assert all(completion_positions[launch.call_id] > report_write_index for launch in validators) + + assistant_text = [event for event in events if event.kind == "text" and event.content.strip()] + assert assistant_text, "raw backend log contains no assistant text" + final_text = assistant_text[-1].content + token = f"investigation_path = {report_path}" + assert token in final_text, f"final assistant event omitted {token!r}" + suffix = final_text.split(token, 1)[1].strip() + assert not suffix or re.fullmatch(r"%%ORDER_UP::[A-Za-z0-9_-]+%%", suffix), ( + f"unexpected assistant text after investigation_path token: {suffix!r}" + ) @pytest.mark.anyio +@pytest.mark.timeout(4500) @pytest.mark.parametrize("backend", _BACKEND_CASES) async def test_deep_investigate_completes_with_bounded_large_evidence( backend: str, @@ -160,7 +501,10 @@ async def test_deep_investigate_completes_with_bounded_large_evidence( _configure_isolated_cli(backend, tmp_path, monkeypatch) config = AutomationConfig( - agent_backend=AgentBackendConfig(backend=backend), + agent_backend=AgentBackendConfig( + backend=backend, + step_overrides={_PROBE_STEP_NAME: backend}, + ), quota_guard=QuotaGuardConfig(enabled=False), features={"codex_backend": backend == "codex"}, experimental_enabled=True, @@ -183,20 +527,43 @@ async def test_deep_investigate_completes_with_bounded_large_evidence( kitchen_result = json.loads(await open_kitchen(ctx=mcp_ctx)) assert kitchen_result["success"] is True, kitchen_result assert kitchen_result["kitchen"] == "open" + tool_ctx.recipe_name = _PROBE_RECIPE_NAME + + index_path = Path(tool_ctx.config.linux_tracing.log_dir) / "sessions.jsonl" + index_offset = _session_index_offset(index_path) + requested_model = "sonnet" if backend == "claude-code" else "" + configured_model = requested_model or "sonnet" command = ( - f"/investigate --deep Analyze {fixture_path} and the repository paths that produce " + f"/investigate --depth deep Analyze {fixture_path} and the repository paths that produce " "or consume it. Use byte-bounded evidence commands, complete every required deep-mode " - "batch and validation stage, do not modify tracked files, and write the final report." + "batch and validation stage, and do not modify tracked files. Prove all three fixture " + f"sentinels in the final report: {', '.join(_FIXTURE_SENTINELS)}. Write the final report." ) try: raw_result = await run_skill( command, str(fixture_repo), - step_name="output_budget_deep_investigate_probe", + model=requested_model, + step_name=_PROBE_STEP_NAME, idle_output_timeout=0, ctx=mcp_ctx, ) - _assert_investigation_result(raw_result, fixture_path) + result = json.loads(raw_result) + report_path = _assert_investigation_result(raw_result, fixture_path) + rows = _read_appended_session_rows(index_path, index_offset) + session_row = _select_probe_session_row( + rows, + result=result, + backend=backend, + fixture_repo=fixture_repo, + configured_model=configured_model, + ) + log_key = "codex_log" if backend == "codex" else "claude_code_log" + raw_events = _read_raw_events(session_row[log_key]) + if backend == "claude-code": + _assert_claude_200k_provenance(raw_events) + workflow_events = _normalize_workflow_events(backend, raw_events) + _assert_deep_workflow_evidence(workflow_events, report_path) finally: await close_kitchen(ctx=mcp_ctx) diff --git a/tests/skills/AGENTS.md b/tests/skills/AGENTS.md index d5a06a9758..ad4a64129f 100644 --- a/tests/skills/AGENTS.md +++ b/tests/skills/AGENTS.md @@ -27,7 +27,7 @@ Skill SKILL.md content compliance, placeholder contracts, and verdict guard test | `test_graphql_invocation_completeness.py` | SKILL.md GraphQL invocation completeness: every parameterized graphql block must have a matching `gh api graphql` bash invocation with individual -F flag bindings | | `test_file_audit_issues_contracts.py` | Contract tests for file-audit-issues SKILL.md behavioral invariants | | `test_investigate_contracts.py` | Structural contracts for the investigate historical recurrence check step | -| `test_investigate_deep_mode_contracts.py` | Structural contracts for the investigate deep analysis mode | +| `test_investigate_deep_mode_contracts.py` | Structural contracts for investigate deep mode, including spawn ceiling, completed wave ordering, and bounded validator responses | | `test_investigate_design_intent_contracts.py` | Contract tests for Design Intent Analysis requirements in the investigate skill | | `test_isolation_guidance_contracts.py` | Contract tests verifying shared mutable state isolation guidance exists in pipeline skills | | `test_make_campaign_compliance.py` | Compliance tests for the make-campaign skill: classification, placeholder hygiene, contract registration | diff --git a/tests/skills/test_investigate_contracts.py b/tests/skills/test_investigate_contracts.py index 790ccb3852..aaa8221516 100644 --- a/tests/skills/test_investigate_contracts.py +++ b/tests/skills/test_investigate_contracts.py @@ -188,14 +188,14 @@ def test_investigate_always_caps_subagent_spawns(skill_text: str) -> None: if next_section_idx != -1 else skill_text[always_idx:] ) - has_spawn_cap_nine = bool( + has_spawn_cap = bool( re.search( - r"(?:total\s+subagent\s+spawns|subagent\s+spawns).*?\b9\b" - r"|\b9\b.*?(?:total\s+subagent|subagent\s+spawn)", + r"(?:total\s+subagent\s+spawns|subagent\s+spawns).*?\b16\b" + r"|\b16\b.*?(?:total\s+subagent|subagent\s+spawn)", always_block.lower(), ) ) - assert has_spawn_cap_nine, ( - "ALWAYS constraints must include a rule capping total subagent spawns at 9 " + assert has_spawn_cap, ( + "ALWAYS constraints must include a rule capping total subagent spawns at 16 " "across all batches (standard and deep mode)" ) diff --git a/tests/skills/test_investigate_deep_mode_contracts.py b/tests/skills/test_investigate_deep_mode_contracts.py index b3bef743f7..1a0100674a 100644 --- a/tests/skills/test_investigate_deep_mode_contracts.py +++ b/tests/skills/test_investigate_deep_mode_contracts.py @@ -76,6 +76,11 @@ def test_deep_mode_subagents_use_sonnet(deep_mode_section: str) -> None: ) +def test_investigation_spawn_ceiling_is_sixteen(skill_text: str) -> None: + assert "Limit total subagent spawns to 16" in skill_text + assert "Limit total subagent spawns to 9" not in skill_text + + # ── Standard Mode Preserved ─────────────────────────────────────────────────── @@ -178,6 +183,16 @@ def test_d2_inter_batch_synthesis(deep_workflow_section: str) -> None: ) +def test_deep_waves_complete_before_inter_batch_progress( + deep_workflow_section: str, +) -> None: + normalized = deep_workflow_section.lower() + assert "at least two completed agent waves" in normalized + assert "every launch has a terminal result" in normalized + assert "before launching batch 2" in normalized + assert "inter-batch synthesis:" in normalized + + def test_d2_historical_recurrence_parallel(deep_workflow_section: str) -> None: """Step D2 must reference Step 3.5 or historical check running in parallel.""" d2 = extract_step_section(deep_workflow_section, "Step D2") @@ -282,6 +297,22 @@ def test_d6_factual_accuracy_check(deep_workflow_section: str) -> None: ) +def test_d6_finishes_before_final_path_token(deep_workflow_section: str) -> None: + d6 = extract_step_section(deep_workflow_section, "Step D6") + normalized = d6.lower() + assert "every validator launch" in normalized + assert "terminal result" in normalized + assert "final line of the final assistant message" in normalized + assert d6.find("terminal result") < d6.find("investigation_path") + + +def test_d6_preserves_exact_recommendations_heading(deep_workflow_section: str) -> None: + d6 = extract_step_section(deep_workflow_section, "Step D6") + assert "exact Step 4 headings" in d6 + assert "`## Recommendations`" in d6 + assert "correct singular or renamed headings" in d6 + + # ── Enhanced Report Template ─────────────────────────────────────────────────── @@ -384,3 +415,14 @@ def test_deep_template_has_inter_batch_context(skill_text: str) -> None: "Deep Analysis Mode Template must include a placeholder for context from " "'prior batches' or 'previous batch' inter-batch synthesis" ) + + +def test_deep_and_validator_templates_bound_final_reports(skill_text: str) -> None: + deep_start = skill_text.index("### Deep Analysis Mode Template") + adversarial_start = skill_text.index("### Adversarial Subagent Template", deep_start) + validator_start = skill_text.index("### Validation Subagent Template") + breakage_start = skill_text.index("### Adversarial Breakage Subagent Template") + deep_template = skill_text[deep_start:adversarial_start] + validator_template = skill_text[validator_start:breakage_start] + assert "at most 800 words" in deep_template + assert "at most 600 words" in validator_template From 54ae181dcf945b88f9382e15e5e26bf8d576ee63 Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 21:37:36 -0700 Subject: [PATCH 11/30] test: record phase 4 gate evidence Phase 4 gate: 30941 passed, 611 skipped, 55 xfailed at 3e10de464. Evidence manifest validated with --mode incremental. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../output-budget-remediation/manifest.json | 23 +++ .../phase4-pre-commit-3e10de464.log | 14 ++ .../phase4-task-test-all-3e10de464.log | 153 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 .autoskillit/evidence/output-budget-remediation/phase4-pre-commit-3e10de464.log create mode 100644 .autoskillit/evidence/output-budget-remediation/phase4-task-test-all-3e10de464.log diff --git a/.autoskillit/evidence/output-budget-remediation/manifest.json b/.autoskillit/evidence/output-budget-remediation/manifest.json index 5c3b9dff9a..fcc0a9c0cb 100644 --- a/.autoskillit/evidence/output-budget-remediation/manifest.json +++ b/.autoskillit/evidence/output-budget-remediation/manifest.json @@ -120,6 +120,29 @@ "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" } ] + }, + { + "phase": 4, + "phase_commit_sha": "3e10de464f9a9d796b5c563b760c8604412c34b1", + "gate_tested_sha": "3e10de464f9a9d796b5c563b760c8604412c34b1", + "gates": [ + { + "command": "task test-all", + "status": "pass", + "summary": "30941 passed, 611 skipped, 55 xfailed", + "gate_tested_sha": "3e10de464f9a9d796b5c563b760c8604412c34b1", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase4-task-test-all-3e10de464.log", + "gate_log_sha256": "1c87194951ac47a26ff59ad6796bfaca85d4f4f8b47f8a8e6a45765dfebff407" + }, + { + "command": "pre-commit run --all-files", + "status": "pass", + "summary": "all configured hooks passed", + "gate_tested_sha": "3e10de464f9a9d796b5c563b760c8604412c34b1", + "gate_log_path": ".autoskillit/evidence/output-budget-remediation/phase4-pre-commit-3e10de464.log", + "gate_log_sha256": "3d5ddb6e5709e7a5ca872d3df964b6bf72355850c622aa7ed43e86f2df3f8998" + } + ] } ], "closure": { diff --git a/.autoskillit/evidence/output-budget-remediation/phase4-pre-commit-3e10de464.log b/.autoskillit/evidence/output-budget-remediation/phase4-pre-commit-3e10de464.log new file mode 100644 index 0000000000..6ced427ffc --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase4-pre-commit-3e10de464.log @@ -0,0 +1,14 @@ +ruff format..............................................................Passed +ruff.....................................................................Passed +mypy.....................................................................Passed +uv lock --check..........................................................Passed +Block generated config files from being committed....(no files to check)Skipped +doc count accuracy.......................................................Passed +Check version consistency across artifacts...............................Passed +Check MCP tool readOnlyHint annotations..............(no files to check)Skipped +Check sub-AGENTS.md file table completeness..............................Passed +Check contract card freshness............................................Passed +Check __init__.pyi stub format...........................................Passed +Check __init__.pyi stub symbol completeness..............................Passed +check for merge conflicts................................................Passed +Detect hardcoded secrets.................................................Passed diff --git a/.autoskillit/evidence/output-budget-remediation/phase4-task-test-all-3e10de464.log b/.autoskillit/evidence/output-budget-remediation/phase4-task-test-all-3e10de464.log new file mode 100644 index 0000000000..5121778f34 --- /dev/null +++ b/.autoskillit/evidence/output-budget-remediation/phase4-task-test-all-3e10de464.log @@ -0,0 +1,153 @@ +task: [_tmpdir-setup] rm -rf /dev/shm/pytest-tmp-2b32c8cd +task: Task "regen-contracts" is up to date +task: [_tmpdir-setup] mkdir -p /dev/shm/pytest-tmp-2b32c8cd /dev/shm/pytest-cache-2b32c8cd +task: Task "compile-recipes" is up to date +task: Task "install-worktree" is up to date +task: [test-all] mkdir -p temp +TEST_OUTPUT="temp/test-$(date +%Y-%m-%d_%H%M%S).txt" +echo "Running all tests with -n 4" +echo "Output: $TEST_OUTPUT" + +if [[ -d ".venv" ]]; then + .venv/bin/lint-imports + PYTEST_CMD=".venv/bin/python -m pytest" +else + lint-imports + PYTEST_CMD="pytest" +fi + +set -o pipefail +set +e +$PYTEST_CMD ${PYTEST_TEST_PATHS:-tests/} -q --tb=short -n 4 --dist worksteal -m "not smoke and not canary" ${PYTEST_IGNORE_PATHS:-} --disable-warnings -o "addopts=" --basetemp=/dev/shm/pytest-tmp-2b32c8cd -o "cache_dir=/dev/shm/pytest-cache-2b32c8cd" 2>&1 | tee "$TEST_OUTPUT" +PYTEST_EXIT=$? +set +o pipefail +set -e + +echo "" +echo "Test output saved to: $TEST_OUTPUT" +exit $PYTEST_EXIT + +Running all tests with -n 4 +Output: temp/test-2026-07-16_213332.txt + +╔══╗─────────▶╔╗ ╔╗ ╔╗◀───┐ +╚╣╠╝◀─────┐ ╔╝╚╗║║────▶╔╝╚╗ │ + ║║ ╔══╦══╦╩╗╔╝║║ ╔╦═╩╗╔╝╔═╦══╗ + ║║╔══╣╔╗║╔╗║╔╣║ ║║ ╔╬╣╔╗║║ ║│║╔═╝ +╔╣╠╣║║║╚╝║╚╝║║║╚╗║╚═╝║║║║║╚╗║═╣║ +╚══╩╩╩╣╔═╩══╩╝╚═╝╚═══╩╩╝╚╩═╩╩═╩╝ + └──▶║║ ▲ + ╚╝────────────────────┘ + + +--------- +Contracts +--------- + +Analyzed 470 files, 1532 dependencies. +-------------------------------------- + +IL-0 core has no autoskillit upward imports KEPT +IL-1 config imports only IL-0 KEPT +IL-1 pipeline does not import IL-2 or IL-3 KEPT +IL-1 execution imports only IL-0 KEPT +IL-1 workspace imports only IL-0 KEPT +IL-2 recipe imports only IL-0 and workspace KEPT +IL-2 migration does not import IL-3 or config KEPT +IL-1 siblings are independent of each other KEPT +IL-2 fleet does not import IL-3 or most IL-1 peers KEPT +IL-011 _dag_ops has no planner-internal imports KEPT +IL-1 report imports only IL-0 KEPT + +Contracts: 11 kept, 0 broken. +bringing up nodes... +bringing up nodes... + +..................................................................................................................................................................................................................................................................................................................................................................................... [ 1%] +........s....................sss.s....ssssss.ssssssssssssss......s....ss....ss.s.s.s.ssssss..ss.s.......s....ss....sss.ssssssssssss.................................................................................................................................................................................................................................................. [ 2%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 3%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 4%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 5%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 7%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 8%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 9%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 10%] +..........................................................................................................................................................................................................................................................s.......................................................................................................................... [ 11%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 12%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 14%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 15%] +..............................................................................................................................................................................................s..s.s...s.s.s.s.s..s.s................................................................................................................................................................ [ 16%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 17%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 18%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 20%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 21%] +..............................................................................................................................................................................................................................................................................................................................s.......s.............................................. [ 22%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 23%] +............................................................................................................................................................................s........................................................................................................................................................................................................ [ 24%] +......................................................................................................................................................................................sssssssssssssss.ssssssssssssssssss.ssssssssssssssssss.sssssssssssssss.ssssssssssssssss.sssss.sssssssssssss.ssssssssssssss.ss.ssssssss.sssssssssss.sss.......................................... [ 25%] +...........................................................................................................................................................................................................................................................................................................................................x......................................... [ 27%] +...................................sss............................................................................................................................................................................................................................................................................................................................................... [ 28%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 29%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 30%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 31%] +.......................................................................................................................................................................................................s............................................................................................................................................................................. [ 33%] +...............................................................................s..................................................................................................................................................................................................................................................................................................... [ 34%] +.............................................................................................s..............................................................................................................sssssssssssssssssssssssssssssssssss...................................................................................................................................... [ 35%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 36%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 37%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 38%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 40%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 41%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 42%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 43%] +................................................................................................................................................................................................................................................................................................x..x.x..x......x.x...x.xx.x.......................................................... [ 44%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 46%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 47%] +.........................................................................................................................sss.......sss....sss....sss.................................s.....ss....s............ss.ss...........s.....s........s....s..s.s.s..s......ss............s......................s.........s.s....s..s.......................s...s...s...s.........s....s..... [ 48%] +s....s.........s........s........s....................ss......s.........s.....s....................................................s....s.s................s...............s......s......s...................s...ss.....s....s....s.........s.............................s......................ss.s......s.......s..............................s.......s.......................... [ 49%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 50%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 51%] +.................................................................................................................................................................ss..................................x.......x..x.xx....x....x.x.x...x.x.x.x.x.x.x.......................xxxxx....................................................................................................... [ 53%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 54%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 55%] +........................................................................................................................................................................................................................................s......s..................................................................................................................................... [ 56%] +............................................................................................................................................................................................s.................s...................................................................................................................................................................... [ 57%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 59%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 60%] +.............................................................................................................................................................................................................................................................................................s....................................................................................... [ 61%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 62%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 63%] +.................................................................................................................................................xx................................x..x.........................xxx..x..x....xx.....x.........xx.x................................................................................................................................... [ 64%] +..........................................................................................................................................x..x..x.................................................................................................................................................................................................................................... [ 66%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 67%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 68%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 69%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 70%] +.......................................................................ssss.ssssssssss.s.ss..s.s.s..sss..sssss.s.s.s.sssssssssssssss.ssssss.s..s.sss.ss.ss.ss.sssss.sssssssssss.ss..sss.ss...sss...ssss...sss..sssssssssssssssss..................................................................................................................................................... [ 71%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 73%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 74%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 75%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 76%] +.....................................................................................................................................................................................................................................................................................................................................................ss.ssssss.sss................... [ 77%] +.......s.s.s.ss.s.s.ss.ss.ss.ss.s.s.s.s...s.s..s.s.s.s..s..s.........s.s.ss.....ss..ssss.sssss....ss..sssss.sssss.s.ss.s.sssss.s..................................................................................................................................................................................................................................................... [ 79%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 80%] +......................................................................................................................................................................................ssssssssssssssss....................................s.s........................................................................................................................................ [ 81%] +...........................s.................................................................................................................................................................................ss.s....s.s....s.s.s.ss.ss.s.ss.ssss.s.s..s.ss........sssssss.ss..sssss.ssss..s......................................................................................... [ 82%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 83%] +...........................................................................................................................................................................................................................................................................................ss........................................................................................ [ 84%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 86%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 87%] +....................................................s.s.............................................................................................................................................................................................................................................................................................................................. [ 88%] +................................................................................................................................s..........s......................................................................................................................................................................................................................................... [ 89%] +.................................................................................s......s..s...............s.s.......s...s.s.....s........................s..s..................s.s...........ss..................................................................................................................................................................................... [ 90%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 92%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 93%] +................................................................................................................................................................................................................................................................................................................................s.................................................... [ 94%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 95%] +............................................................................x.x..........................................................................................................xx.xsss..................................................................................................................................................................................... [ 96%] +.................................................................s................................................................................................................................................................................................................................................................................................................... [ 97%] +..................................................................................................................................................................................................................................................................................................................................................................................... [ 99%] +.................................................................................................................................................................................................................................................................................. [100%] +30941 passed, 611 skipped, 55 xfailed, 2168 warnings in 194.28s (0:03:14) + +Test output saved to: temp/test-2026-07-16_213332.txt From 4a8d65a2b70cffcac2613d88f84d2a556534946d Mon Sep 17 00:00:00 2001 From: Trecek Date: Thu, 16 Jul 2026 22:01:15 -0700 Subject: [PATCH 12/30] fix: bump exemption ceilings after rebase onto develop Remediation recipe grew to 183,103 chars on develop. Bump load_recipe ceiling to 185,000 and open_kitchen to 186,000. Update measurement IDs, registry digest, ADR doc, Codex token limit fixture, and all pinned test values. Fix shifted line numbers in schema version allowlist and symbol count in subpackage structure test. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/decisions/0005-output-budget-protocol.md | 6 +++--- .../core/types/_type_constants_registries.py | 12 +++++------ .../hooks/formatters/_fmt_response_spill.py | 12 +++++------ tests/arch/test_subpackage_structure.py | 4 ++-- tests/core/test_type_constants.py | 14 ++++++------- .../test_output_budget_protocol_decision.py | 20 +++++++++---------- .../config_toml_schema_template.json | 2 +- tests/execution/backends/test_codex_config.py | 4 ++-- tests/infra/test_pretty_output_recipe.py | 4 ++-- tests/infra/test_schema_version_convention.py | 8 ++++---- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index 71221a4bd7..5961e3d825 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -53,9 +53,9 @@ an exemption requires re-measurement and a deliberate registry-digest change. | Limit | Decision and rationale | |---|---| -| `load_recipe`: `max_chars = 179_000`, `max_utf8_bytes = 179_000` | The 2026-07-15 independent all-recipe/all-mode pre-backstop measurement reached 178,601 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 399-unit margin makes serving growth explicit. Measurement identity: `bundled-recipes-all-modes-2026-07-15/load-recipe`. | -| `open_kitchen`: `max_chars = 180_000`, `max_utf8_bytes = 180_000` | The matching current-version pre-backstop measurement reached 178,660 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,340-unit margin covers the open-kitchen routing fields without conflating this handler ceiling with the smaller formatted presentation. Measurement identity: `bundled-recipes-all-modes-2026-07-15/open-kitchen`. | -| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 53_000` | Derive it from the largest registered exemption as `((180_000 + 3) // 4) + 8_000`: 45,000 tokens under the current client's four-byte heuristic, plus 8,000 tokens for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. | +| `load_recipe`: `max_chars = 185_000`, `max_utf8_bytes = 185_000` | The 2026-07-16 independent all-recipe/all-mode pre-backstop measurement reached 183,103 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 1,897-unit margin makes serving growth explicit. Measurement identity: `bundled-recipes-all-modes-2026-07-16/load-recipe`. | +| `open_kitchen`: `max_chars = 186_000`, `max_utf8_bytes = 186_000` | The matching current-version pre-backstop measurement reached 183,103 characters and UTF-8 bytes for `remediation` with all truthy ingredients. The 2,897-unit margin covers the open-kitchen routing fields without conflating this handler ceiling with the smaller formatted presentation. Measurement identity: `bundled-recipes-all-modes-2026-07-16/open-kitchen`. | +| `CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 54_500` | Derive it from the largest registered exemption as `((186_000 + 3) // 4) + 8_000`: 46,500 tokens under the current client's four-byte heuristic, plus 8,000 tokens for serialized-payload headroom. It is a blast-radius damage bound, not the mechanism that makes evidence lossless. | | `CODEX_AUTO_COMPACT_LIMIT = 999_999_999` | Retain the unreachable sentinel and the recovery obligation accepted in [ADR-0004](0004-recipe-redelivery.md). This protocol does not relax recipe-preservation policy. | | `inline_max_chars = 5_000` | Preserve the previous truncation threshold while changing the representation from destructive clipping to an artifact-backed preview. The configured 2,500-character head and 2,500-character tail retain both diagnostic setup and terminal status; a spill marker is added outside those source slices. | | `response_max_bytes = 90_000` | Bound the exact compact serialized handler payload before a coarser transport can clip it. Bytes are authoritative here; this is not a token or full JSON-RPC-envelope estimate. | diff --git a/src/autoskillit/core/types/_type_constants_registries.py b/src/autoskillit/core/types/_type_constants_registries.py index 3f1c60d29a..76bdb33b23 100644 --- a/src/autoskillit/core/types/_type_constants_registries.py +++ b/src/autoskillit/core/types/_type_constants_registries.py @@ -189,14 +189,14 @@ class ResponseBackstopExemptionDef(NamedTuple): RESPONSE_BACKSTOP_EXEMPTION_REGISTRY: dict[str, ResponseBackstopExemptionDef] = { "load_recipe": ResponseBackstopExemptionDef( - max_chars=179_000, - max_utf8_bytes=179_000, - measurement_id="bundled-recipes-all-modes-2026-07-15/load-recipe", + max_chars=185_000, + max_utf8_bytes=185_000, + measurement_id="bundled-recipes-all-modes-2026-07-16/load-recipe", ), "open_kitchen": ResponseBackstopExemptionDef( - max_chars=180_000, - max_utf8_bytes=180_000, - measurement_id="bundled-recipes-all-modes-2026-07-15/open-kitchen", + max_chars=186_000, + max_utf8_bytes=186_000, + measurement_id="bundled-recipes-all-modes-2026-07-16/open-kitchen", ), } diff --git a/src/autoskillit/hooks/formatters/_fmt_response_spill.py b/src/autoskillit/hooks/formatters/_fmt_response_spill.py index 6d5d55338a..bb41aa7706 100644 --- a/src/autoskillit/hooks/formatters/_fmt_response_spill.py +++ b/src/autoskillit/hooks/formatters/_fmt_response_spill.py @@ -45,14 +45,14 @@ ) _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY = { "load_recipe": { - "max_chars": 179_000, - "max_utf8_bytes": 179_000, - "measurement_id": "bundled-recipes-all-modes-2026-07-15/load-recipe", + "max_chars": 185_000, + "max_utf8_bytes": 185_000, + "measurement_id": "bundled-recipes-all-modes-2026-07-16/load-recipe", }, "open_kitchen": { - "max_chars": 180_000, - "max_utf8_bytes": 180_000, - "measurement_id": "bundled-recipes-all-modes-2026-07-15/open-kitchen", + "max_chars": 186_000, + "max_utf8_bytes": 186_000, + "measurement_id": "bundled-recipes-all-modes-2026-07-16/open-kitchen", }, } _RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST = hashlib.sha256( diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 990f8b2918..2bcea0c7dd 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,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) == 106, ( - f"Expected 106 symbols total, got {len(combined)} " + assert len(combined) == 108, ( + f"Expected 108 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) diff --git a/tests/core/test_type_constants.py b/tests/core/test_type_constants.py index a40a0fd46a..2abde2b675 100644 --- a/tests/core/test_type_constants.py +++ b/tests/core/test_type_constants.py @@ -32,14 +32,14 @@ def test_response_backstop_exemption_registry_is_closed_and_pinned() -> None: assert RESPONSE_BACKSTOP_EXEMPTION_REGISTRY == { "load_recipe": ResponseBackstopExemptionDef( - max_chars=179_000, - max_utf8_bytes=179_000, - measurement_id="bundled-recipes-all-modes-2026-07-15/load-recipe", + max_chars=185_000, + max_utf8_bytes=185_000, + measurement_id="bundled-recipes-all-modes-2026-07-16/load-recipe", ), "open_kitchen": ResponseBackstopExemptionDef( - max_chars=180_000, - max_utf8_bytes=180_000, - measurement_id="bundled-recipes-all-modes-2026-07-15/open-kitchen", + max_chars=186_000, + max_utf8_bytes=186_000, + measurement_id="bundled-recipes-all-modes-2026-07-16/open-kitchen", ), } @@ -60,7 +60,7 @@ def test_response_backstop_exemption_registry_digest_is_canonical() -> None: ) assert ( RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST - == "2e44a2d65867ccac075ff1978f207bac5b25487e0c037254a62059a363bd8d60" + == "01c05239140445c277920d48b8df5745f5840fa22d97b3611bf6f37e8af5f127" ) diff --git a/tests/docs/test_output_budget_protocol_decision.py b/tests/docs/test_output_budget_protocol_decision.py index 0e0d5c1f40..092644d689 100644 --- a/tests/docs/test_output_budget_protocol_decision.py +++ b/tests/docs/test_output_budget_protocol_decision.py @@ -49,14 +49,14 @@ def test_decision_names_all_four_layers(decision_text: str, required: str) -> No @pytest.mark.parametrize( "required", [ - "max_chars = 179_000", - "max_utf8_bytes = 179_000", - "178,601 characters and UTF-8 bytes", - "max_chars = 180_000", - "max_utf8_bytes = 180_000", - "178,660 characters and UTF-8 bytes", - "((180_000 + 3) // 4) + 8_000", - "CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 53_000", + "max_chars = 185_000", + "max_utf8_bytes = 185_000", + "183,103 characters and UTF-8 bytes", + "max_chars = 186_000", + "max_utf8_bytes = 186_000", + "183,103 characters and UTF-8 bytes", + "((186_000 + 3) // 4) + 8_000", + "CODEX_TOOL_OUTPUT_TOKEN_LIMIT = 54_500", "CODEX_AUTO_COMPACT_LIMIT = 999_999_999", "inline_max_chars = 5_000", "response_max_bytes = 90_000", @@ -78,8 +78,8 @@ def test_decision_pins_ceiling_backstop_pair(decision_text: str) -> None: assert "live large-output probe" in decision_text assert "RESPONSE_BACKSTOP_EXEMPTION_REGISTRY" in decision_text assert "canonical digest" in decision_text - assert "bundled-recipes-all-modes-2026-07-15/load-recipe" in decision_text - assert "bundled-recipes-all-modes-2026-07-15/open-kitchen" in decision_text + assert "bundled-recipes-all-modes-2026-07-16/load-recipe" in decision_text + assert "bundled-recipes-all-modes-2026-07-16/open-kitchen" in decision_text def test_decision_records_both_corrections(decision_text: str) -> None: diff --git a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json index aae2277fa2..7a4cf3edad 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json +++ b/tests/execution/backends/fixtures/codex_ndjson/config_toml_schema_template.json @@ -28,7 +28,7 @@ "tool_output_token_limit": { "constraint": "exact", "expected_type": "int", - "expected_value": 53000 + "expected_value": 54500 } } } diff --git a/tests/execution/backends/test_codex_config.py b/tests/execution/backends/test_codex_config.py index 09a8bc85ee..d46f5e1caf 100644 --- a/tests/execution/backends/test_codex_config.py +++ b/tests/execution/backends/test_codex_config.py @@ -36,9 +36,9 @@ def test_codex_tool_output_limit_is_derived_from_largest_measured_exemption() -> max_bytes = max( definition.max_utf8_bytes for definition in RESPONSE_BACKSTOP_EXEMPTION_REGISTRY.values() ) - assert max_bytes == 180_000 + assert max_bytes == 186_000 assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == ((max_bytes + 3) // 4) + 8_000 - assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == 53_000 + assert CODEX_TOOL_OUTPUT_TOKEN_LIMIT == 54_500 def test_response_backstop_fires_below_codex_transport_ceiling() -> None: diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 1e1eed0af4..34f91b163f 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -1047,8 +1047,8 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, for ingredients_only in (False, True) } assert maxima == { - "load_recipe": (178_601, "remediation", "all_truthy"), - "open_kitchen": (178_660, "remediation", "all_truthy"), + "load_recipe": (183_103, "remediation", "all_truthy"), + "open_kitchen": (183_162, "remediation", "all_truthy"), } diff --git a/tests/infra/test_schema_version_convention.py b/tests/infra/test_schema_version_convention.py index 4a674c1f77..6731b4dac1 100644 --- a/tests/infra/test_schema_version_convention.py +++ b/tests/infra/test_schema_version_convention.py @@ -119,10 +119,10 @@ def _is_yaml_dump(node: ast.expr) -> bool: # _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system) ("src/autoskillit/server/_lifespan.py", 90), # tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay - ("src/autoskillit/server/tools/tools_kitchen.py", 269), - ("src/autoskillit/server/tools/tools_kitchen.py", 288), - ("src/autoskillit/server/tools/tools_kitchen.py", 322), - ("src/autoskillit/server/tools/tools_kitchen.py", 1284), + ("src/autoskillit/server/tools/tools_kitchen.py", 270), + ("src/autoskillit/server/tools/tools_kitchen.py", 289), + ("src/autoskillit/server/tools/tools_kitchen.py", 323), + ("src/autoskillit/server/tools/tools_kitchen.py", 1296), # tools_pipeline_tracker.py — tracker_data dict ("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166), # tools_status.py — mcp_data dict From a43454caa8d92b1306a69c71646316bbc0d4f493 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 09:17:35 -0700 Subject: [PATCH 13/30] fix: normalize scripts path in exemption ceiling test for CI parity The maxima assertion in test_canonical_recipe_responses_fit_independent_registry_ceilings hardcoded byte counts that included the absolute path to builtin_scripts_dir(), which varies by checkout location (100 chars locally vs 41 in CI = 59-byte gap). Normalize the rendered payload by replacing the resolved scripts path with its template placeholder before measuring, making the pinned maxima environment-independent. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/infra/test_pretty_output_recipe.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/infra/test_pretty_output_recipe.py b/tests/infra/test_pretty_output_recipe.py index 34f91b163f..677d2d881f 100644 --- a/tests/infra/test_pretty_output_recipe.py +++ b/tests/infra/test_pretty_output_recipe.py @@ -980,6 +980,7 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, from autoskillit import __version__ from autoskillit.recipe import _api_cache, all_validated_recipe_names from autoskillit.recipe._api_cache import LoadCache + from autoskillit.recipe.io import _SCRIPTS_PLACEHOLDER, builtin_scripts_dir from autoskillit.recipe.repository import DefaultRecipeRepository from autoskillit.server._misc import strip_ingredients_only_keys from autoskillit.server.tools._serve_helpers import ( @@ -991,6 +992,9 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, project_root = Path(__file__).resolve().parent.parent.parent measured_modes: set[tuple[str, str, bool]] = set() maxima = {"load_recipe": (0, "", ""), "open_kitchen": (0, "", "")} + # Normalize away the environment-dependent scripts path so the pinned + # maxima are identical regardless of checkout location. + _scripts_dir = str(builtin_scripts_dir()) for recipe_name in all_validated_recipe_names(project_root): for mode_name, overrides in _COMPACT_TEST_OVERRIDES.items(): @@ -1034,9 +1038,10 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, ingredients_only, len(raw.encode("utf-8")), ) + normalized = raw.replace(_scripts_dir, _SCRIPTS_PLACEHOLDER) maxima[tool_name] = max( maxima[tool_name], - (len(raw.encode("utf-8")), recipe_name, mode_name), + (len(normalized.encode("utf-8")), recipe_name, mode_name), ) measured_modes.add((tool_name, mode_name, ingredients_only)) @@ -1047,8 +1052,8 @@ def test_canonical_recipe_responses_fit_independent_registry_ceilings(tmp_path, for ingredients_only in (False, True) } assert maxima == { - "load_recipe": (183_103, "remediation", "all_truthy"), - "open_kitchen": (183_162, "remediation", "all_truthy"), + "load_recipe": (182_994, "remediation", "all_truthy"), + "open_kitchen": (183_053, "remediation", "all_truthy"), } From f9797830b5194b0cc722401a4f4b585abfeff81c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:41:52 -0700 Subject: [PATCH 14/30] fix(review): restore traceback capture in track_response_size catch-all logger.exception attaches exc_info that logger.error dropped, keeping the structured event name while restoring post-hoc debuggability of MCP tool crashes. Co-Authored-By: Claude Fable 5 --- src/autoskillit/server/_notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/autoskillit/server/_notify.py b/src/autoskillit/server/_notify.py index 973e738a57..7100fb602d 100644 --- a/src/autoskillit/server/_notify.py +++ b/src/autoskillit/server/_notify.py @@ -102,7 +102,7 @@ async def wrapper(*args: Any, **kwargs: Any) -> Any: ), } ) - logger.error("track_response_size_handler_failed", tool_name=tool_name) + logger.exception("track_response_size_handler_failed", tool_name=tool_name) ctx = _get_ctx_or_none() try: response_str = result if isinstance(result, str) else json.dumps(result) From 2f460b6d33311199a6160490a2f6dd4b7697859c Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:41:54 -0700 Subject: [PATCH 15/30] fix(review): replace strippable assert with explicit fail-closed guard assert isinstance(...) disappears under PYTHONOPTIMIZE, and convert_result exists on the base Tool class, so an invariant violation would proceed silently rather than fail. An explicit TypeError keeps behavior identical in both configurations. Co-Authored-By: Claude Fable 5 --- src/autoskillit/server/_response_conformance.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/server/_response_conformance.py b/src/autoskillit/server/_response_conformance.py index 277e0cb5f3..6e9a81401f 100644 --- a/src/autoskillit/server/_response_conformance.py +++ b/src/autoskillit/server/_response_conformance.py @@ -137,7 +137,11 @@ async def on_call_tool( converted_result_conforms=converted_result_conforms, ) if decision is ResponseConformanceDecision.REWRITE_NONCONFORMING: - assert isinstance(registered_tool, FunctionTool) + if not isinstance(registered_tool, FunctionTool): + raise TypeError( + f"registered tool {context.message.name!r} is not a FunctionTool " + "despite a REWRITE_NONCONFORMING decision" + ) emit_response_budget_failure( context.message.name, "schema_nonconforming", From 4f4d019d4fa8c2e05688c1ed3f28f79094cc3910 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:41:55 -0700 Subject: [PATCH 16/30] fix(review): carry convergence diagnostics in projection failure _ProjectionNonconvergentError now embeds measured/projected/max_bytes and attempted state count, and the catch site surfaces the detail via the module's structured event pattern instead of swallowing the message. Co-Authored-By: Claude Fable 5 --- src/autoskillit/server/_response_budget.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/server/_response_budget.py b/src/autoskillit/server/_response_budget.py index 5c907d12f0..1c70126dcd 100644 --- a/src/autoskillit/server/_response_budget.py +++ b/src/autoskillit/server/_response_budget.py @@ -229,7 +229,12 @@ def _finalize_envelope(envelope: dict[str, Any], *, max_bytes: int) -> str: break seen.add(state) metadata["projected_utf8_bytes"] = measured - raise _ProjectionNonconvergentError("projected byte fixed point did not converge") + raise _ProjectionNonconvergentError( + "projected byte fixed point did not converge: " + f"measured={len(_canonical_json(envelope).encode('utf-8'))} " + f"projected={metadata.get('projected_utf8_bytes')!r} " + f"max_bytes={max_bytes} attempted_states={len(seen)}" + ) def _spill_metadata( @@ -440,7 +445,12 @@ def enforce_response_budget( max_bytes=config.response_max_bytes, inline_chars=config.inline_max_chars, ) - except _ProjectionNonconvergentError: + except _ProjectionNonconvergentError as exc: + _emit_response_budget_event( + "response_budget_projection_nonconvergent", + tool_name=_bounded_tool_name(tool_name), + detail=str(exc), + ) rendered = bounded_response_budget_failure( "", cause="projection_nonconvergent", From 0645ba6905be6cba269caad6b1dc09d6c85d74cf Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:42:12 -0700 Subject: [PATCH 17/30] fix(review): distinct cause code for exemption ceiling breaches Reusing internal_invariant_failed obscured the actual condition when an exempt tool exceeds its measured ceiling. The fail-closed no-spill behavior itself is unchanged (deliberate per ADR-0005 accepted gap 3). Co-Authored-By: Claude Fable 5 --- src/autoskillit/server/_response_budget.py | 3 ++- tests/server/test_response_backstop.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/autoskillit/server/_response_budget.py b/src/autoskillit/server/_response_budget.py index 1c70126dcd..3e697b278d 100644 --- a/src/autoskillit/server/_response_budget.py +++ b/src/autoskillit/server/_response_budget.py @@ -56,6 +56,7 @@ "projection_nonconvergent", "irreducible_shape", "schema_nonconforming", + "exemption_ceiling_exceeded", "internal_invariant_failed", } ) @@ -385,7 +386,7 @@ def enforce_response_budget( return result return bounded_response_budget_failure( result, - cause="internal_invariant_failed", + cause="exemption_ceiling_exceeded", tool_name=tool_name, max_bytes=config.response_max_bytes, original_utf8_bytes=original_size, diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index 4f1cccee37..41ab561d1c 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -311,7 +311,7 @@ def test_exemption_overage_fails_closed_and_does_not_spill(tmp_path): ) assert original not in shaped - assert "internal_invariant_failed" in shaped + assert "exemption_ceiling_exceeded" in shaped assert list(tmp_path.iterdir()) == [] From 4ff4b667004e0f3dcf0aaf26789ea3a85b40eb6f Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:42:50 -0700 Subject: [PATCH 18/30] fix(review): cover runtime digest text in probe-cache policy identity PROBE_POLICY_IDENTITY previously hashed only OUTPUT_DISCIPLINE_BLOCK, so editing the runtime-injected OUTPUT_DISCIPLINE_DIGEST left stale cached probe results valid. A combined digest over both policy texts now feeds the cache key; OUTPUT_DISCIPLINE_BLOCK_SHA256 remains for SKILL.md byte-identity checks. Co-Authored-By: Claude Fable 5 --- src/autoskillit/core/__init__.pyi | 1 + src/autoskillit/core/types/_type_constants.py | 7 +++++++ src/autoskillit/execution/backends/_probe_cache.py | 4 ++-- tests/execution/backends/test_probe_cache.py | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/core/__init__.pyi b/src/autoskillit/core/__init__.pyi index bc36810021..486d749115 100644 --- a/src/autoskillit/core/__init__.pyi +++ b/src/autoskillit/core/__init__.pyi @@ -188,6 +188,7 @@ from .types import ORCHESTRATOR_SESSION_REQUIRED_ENV as ORCHESTRATOR_SESSION_REQ from .types import ORDER_INTERACTIVE_REQUIRED_ENV as ORDER_INTERACTIVE_REQUIRED_ENV from .types import OUTPUT_DISCIPLINE_BLOCK as OUTPUT_DISCIPLINE_BLOCK from .types import OUTPUT_DISCIPLINE_BLOCK_SHA256 as OUTPUT_DISCIPLINE_BLOCK_SHA256 +from .types import OUTPUT_DISCIPLINE_COMBINED_SHA256 as OUTPUT_DISCIPLINE_COMBINED_SHA256 from .types import OUTPUT_DISCIPLINE_DIGEST as OUTPUT_DISCIPLINE_DIGEST from .types import OUTPUT_DISCIPLINE_POLICY_VERSION as OUTPUT_DISCIPLINE_POLICY_VERSION from .types import OUTPUT_DISCIPLINE_REQUIRED_SKILLS as OUTPUT_DISCIPLINE_REQUIRED_SKILLS diff --git a/src/autoskillit/core/types/_type_constants.py b/src/autoskillit/core/types/_type_constants.py index 443ac5efbf..a36b8fedbc 100644 --- a/src/autoskillit/core/types/_type_constants.py +++ b/src/autoskillit/core/types/_type_constants.py @@ -14,6 +14,7 @@ "OUTPUT_DISCIPLINE_POLICY_VERSION", "OUTPUT_DISCIPLINE_BLOCK", "OUTPUT_DISCIPLINE_BLOCK_SHA256", + "OUTPUT_DISCIPLINE_COMBINED_SHA256", "OUTPUT_DISCIPLINE_DIGEST", "OUTPUT_DISCIPLINE_REQUIRED_SKILLS", "RETIRED_SKILL_NAMES", @@ -145,6 +146,12 @@ ) ) +# Covers both policy texts (SKILL.md block and runtime-injected digest) so +# editing either one invalidates cache keys derived from the policy content. +OUTPUT_DISCIPLINE_COMBINED_SHA256 = sha256( + (OUTPUT_DISCIPLINE_BLOCK + "\x00" + OUTPUT_DISCIPLINE_DIGEST).encode("utf-8") +).hexdigest() + OUTPUT_DISCIPLINE_REQUIRED_SKILLS: frozenset[str] = frozenset( {"investigate", "rectify", "audit-bugs", "audit-friction"} ) diff --git a/src/autoskillit/execution/backends/_probe_cache.py b/src/autoskillit/execution/backends/_probe_cache.py index 5f1ef9d156..ade9bc3839 100644 --- a/src/autoskillit/execution/backends/_probe_cache.py +++ b/src/autoskillit/execution/backends/_probe_cache.py @@ -13,7 +13,7 @@ from pathlib import Path from autoskillit.core import ( - OUTPUT_DISCIPLINE_BLOCK_SHA256, + OUTPUT_DISCIPLINE_COMBINED_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, get_logger, @@ -33,7 +33,7 @@ "\n".join(PROBE_SUITE_CONTRACT).encode("utf-8") ).hexdigest() PROBE_POLICY_IDENTITY: str = ( - f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_COMBINED_SHA256}-" f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}-{PROBE_SUITE_CONTRACT_DIGEST}" ) _SCHEMA_VERSION: int = 2 diff --git a/tests/execution/backends/test_probe_cache.py b/tests/execution/backends/test_probe_cache.py index 814fa65439..b73284a147 100644 --- a/tests/execution/backends/test_probe_cache.py +++ b/tests/execution/backends/test_probe_cache.py @@ -7,7 +7,7 @@ import pytest from autoskillit.core import ( - OUTPUT_DISCIPLINE_BLOCK_SHA256, + OUTPUT_DISCIPLINE_COMBINED_SHA256, OUTPUT_DISCIPLINE_POLICY_VERSION, RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST, ) @@ -172,7 +172,7 @@ def test_returns_none_on_naive_timestamp(self, tmp_path: Path) -> None: def test_probe_policy_identity_uses_output_discipline_authorities() -> None: assert PROBE_POLICY_IDENTITY == ( - f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_BLOCK_SHA256}-" + f"v{OUTPUT_DISCIPLINE_POLICY_VERSION}-{OUTPUT_DISCIPLINE_COMBINED_SHA256}-" f"{RESPONSE_BACKSTOP_EXEMPTION_REGISTRY_DIGEST}-{PROBE_SUITE_CONTRACT_DIGEST}" ) assert PROBE_SUITE_CONTRACT == ( From 4b02c9057df5b8abb74966ec9a7b2cfcde201595 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 10:46:40 -0700 Subject: [PATCH 19/30] fix(review): bump type-constants symbol count for combined policy digest OUTPUT_DISCIPLINE_COMBINED_SHA256 raises the split-module __all__ union from 108 to 109 symbols. Co-Authored-By: Claude Fable 5 --- tests/arch/test_subpackage_structure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/arch/test_subpackage_structure.py b/tests/arch/test_subpackage_structure.py index 2bcea0c7dd..2a7b8fa7bf 100644 --- a/tests/arch/test_subpackage_structure.py +++ b/tests/arch/test_subpackage_structure.py @@ -61,8 +61,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) == 108, ( - f"Expected 108 symbols total, got {len(combined)} " + assert len(combined) == 109, ( + f"Expected 109 symbols total, got {len(combined)} " f"(remaining={len(remaining)}, env={len(env)}, " f"features={len(features)}, registries={len(registries)})" ) From b83d9d91ffbd167c0bd5e43c0d43f01e40ae8418 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:48:55 -0700 Subject: [PATCH 20/30] fix(review): force spill when condensed field exceeds inline threshold When combined JSON was under inline_max_chars but individual condensed fields exceeded it, preview/truncation markers embedded literal 'None' for the artifact path. Now forces a re-spill with inline_max_chars=0 when any condensed field needs truncation but no artifact was created. Co-Authored-By: Claude Fable 5 --- src/autoskillit/server/git.py | 11 ++++++++ .../server/tools/tools_workspace.py | 26 ++++++++++++++----- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/autoskillit/server/git.py b/src/autoskillit/server/git.py index e34e94c19c..7f12abfbd1 100644 --- a/src/autoskillit/server/git.py +++ b/src/autoskillit/server/git.py @@ -62,6 +62,17 @@ def _shape_failed_test_output( ) condensed_stdout, condensed_stderr = condense_test_output(test_result) + if spilled.artifact_path is None and ( + len(condensed_stdout) > spec.inline_max_chars + or len(condensed_stderr) > spec.inline_max_chars + ): + spilled = spill_output( + raw, + resolve_temp_dir(Path(worktree_path), config.workspace.temp_dir) / "merge_worktree", + "raw_test_output", + SpillSpec(inline_max_chars=0, head_chars=spec.head_chars, tail_chars=spec.tail_chars), + ) + def preview(text: str) -> str: if len(text) <= spec.inline_max_chars: return text diff --git a/src/autoskillit/server/tools/tools_workspace.py b/src/autoskillit/server/tools/tools_workspace.py index 97a73ba842..4301f1abba 100644 --- a/src/autoskillit/server/tools/tools_workspace.py +++ b/src/autoskillit/server/tools/tools_workspace.py @@ -118,20 +118,32 @@ async def test_check( head_chars=tool_ctx.config.output_budget.head_chars, tail_chars=tool_ctx.config.output_budget.tail_chars, ) + condensed_stdout, condensed_stderr = condense_test_output(test_result) if not test_result.passed: raw_output = json.dumps( {"stdout": test_result.stdout, "stderr": test_result.stderr} ) - raw_spill = spill_output( - raw_output, + spill_dir = ( resolve_temp_dir(Path(resolved), tool_ctx.config.workspace.temp_dir) - / "test_check", - "raw_output", - spec, + / "test_check" ) + raw_spill = spill_output(raw_output, spill_dir, "raw_output", spec) raw_artifact_path = raw_spill.artifact_path - - condensed_stdout, condensed_stderr = condense_test_output(test_result) + if raw_artifact_path is None and ( + len(condensed_stdout) > spec.inline_max_chars + or len(condensed_stderr) > spec.inline_max_chars + ): + raw_spill = spill_output( + raw_output, + spill_dir, + "raw_output", + SpillSpec( + inline_max_chars=0, + head_chars=spec.head_chars, + tail_chars=spec.tail_chars, + ), + ) + raw_artifact_path = raw_spill.artifact_path response = { "passed": test_result.passed, "stdout": _bounded_test_stream(condensed_stdout, spec, raw_artifact_path), From e63433ee933019d82f9848c84a9c595274a4488d Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:49:04 -0700 Subject: [PATCH 21/30] fix(review): generalize _has_flag short-flag bundling for all flags Previously only -q/--quiet had combined-flag detection (e.g. -qn). Now extracts all single-char short flags from names and checks bundled forms, fixing bypass where 'grep -rn' passed but 'grep -r -n' was denied. Co-Authored-By: Claude Fable 5 --- src/autoskillit/hooks/guards/output_budget_guard.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/autoskillit/hooks/guards/output_budget_guard.py b/src/autoskillit/hooks/guards/output_budget_guard.py index 2b669e8889..28c0a6edb0 100644 --- a/src/autoskillit/hooks/guards/output_budget_guard.py +++ b/src/autoskillit/hooks/guards/output_budget_guard.py @@ -97,12 +97,18 @@ def _read_policy() -> tuple[bool, int, int]: def _has_flag(args: list[str], *names: str) -> bool: + short_chars = frozenset(name[1] for name in names if len(name) == 2 and name.startswith("-")) for arg in args: if arg in names: return True if any(arg.startswith(f"{name}=") for name in names if name.startswith("--")): return True - if "-q" in names and arg.startswith("-") and not arg.startswith("--") and "q" in arg[1:]: + if ( + short_chars + and arg.startswith("-") + and not arg.startswith("--") + and any(c in arg[1:] for c in short_chars) + ): return True return False From daf1bf5d16b040480f04fa52186457c837893524 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:50:49 -0700 Subject: [PATCH 22/30] fix(review): correct stale token/byte numbers in ADR-0005 Commit 4a8d65a2b updated exemption ceilings but missed two prose sentences in Ceiling and Backstop Reconciliation: 53,000 -> 54,500 tokens, 212,000 -> 218,000 implied bytes. Co-Authored-By: Claude Fable 5 --- docs/decisions/0005-output-budget-protocol.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/decisions/0005-output-budget-protocol.md b/docs/decisions/0005-output-budget-protocol.md index 5961e3d825..b4c9e8fe92 100644 --- a/docs/decisions/0005-output-budget-protocol.md +++ b/docs/decisions/0005-output-budget-protocol.md @@ -78,13 +78,13 @@ estimate. The project therefore requires the stricter relationship `response_max_bytes // 3 < CODEX_TOOL_OUTPUT_TOKEN_LIMIT`. The three-byte divisor is -deliberate margin: the 90,000-byte response backstop must fire before Codex's 53,000-token +deliberate margin: the 90,000-byte response backstop must fire before Codex's 54,500-token transport ceiling can clip a producer-blind response. A static test pins the relationship, and the live large-output probe must pass before either side is retuned. The measured raw-text exemptions, `open_kitchen` and `load_recipe`, must each remain below their own registered character and UTF-8 byte ceilings, which in turn remain below the -212,000-byte budget implied by the current 53,000-token, four-byte heuristic. Their +218,000-byte budget implied by the current 54,500-token, four-byte heuristic. Their measurements are independent release gates; the heuristic is not permission to omit those tests or reuse one surface's observed maximum as the other's authority. From b64d751c00b1e9a68579d95bc7c6033e591b64a2 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:50:56 -0700 Subject: [PATCH 23/30] fix(review): narrow personal-path exemption to specific lines File-level exemption silently suppressed detection of any future personal-path leak. Now uses (path, lineno) granularity targeting only lines 17-18 (the INCIDENT_LOG_SEARCH fixture). Co-Authored-By: Claude Fable 5 --- tests/infra/test_release_sanity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/infra/test_release_sanity.py b/tests/infra/test_release_sanity.py index 87e5589a6f..eb92315613 100644 --- a/tests/infra/test_release_sanity.py +++ b/tests/infra/test_release_sanity.py @@ -8,7 +8,9 @@ pytestmark = [pytest.mark.layer("infra"), pytest.mark.medium] REPO_ROOT = Path(__file__).parent.parent.parent -_PERSONAL_HOME_PATH_FIXTURES = frozenset({Path("tests/hooks/test_output_budget_guard.py")}) +_PERSONAL_HOME_PATH_FIXTURES: dict[Path, frozenset[int]] = { + Path("tests/hooks/test_output_budget_guard.py"): frozenset({17, 18}), +} def test_no_personal_home_paths_in_test_files(): @@ -23,7 +25,7 @@ def test_no_personal_home_paths_in_test_files(): if ( personal_prefix in line and not line.strip().startswith("#") - and relative_path not in _PERSONAL_HOME_PATH_FIXTURES + and lineno not in _PERSONAL_HOME_PATH_FIXTURES.get(relative_path, frozenset()) ): hits.append(f"{relative_path}:{lineno}: {line.rstrip()}") assert hits == [], "Personal home paths found in tests:\n" + "\n".join(hits) From f47de576fca7632afc3931047c2159e4c42a3ae1 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:51:05 -0700 Subject: [PATCH 24/30] fix(review): assert non-empty guard output in battery test Guard has 5 silent exit paths that all produce empty stdout. Requiring non-empty output ensures classification actually ran rather than passing vacuously on a bail-out. Co-Authored-By: Claude Fable 5 --- .../test_audit_friction_output_budget.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/tests/skills_extended/test_audit_friction_output_budget.py b/tests/skills_extended/test_audit_friction_output_budget.py index ac15f5cbe9..3335bef6bc 100644 --- a/tests/skills_extended/test_audit_friction_output_budget.py +++ b/tests/skills_extended/test_audit_friction_output_budget.py @@ -88,8 +88,10 @@ def test_real_command_battery_passes_output_budget_guard( ) assert result.returncode == 0, result.stderr - if result.stdout.strip(): - hook_output = json.loads(result.stdout)["hookSpecificOutput"] - assert hook_output.get("permissionDecision") != "deny", ( - f"real audit-friction command was denied: {rendered}\n{result.stdout}" - ) + assert result.stdout.strip(), ( + f"guard produced no classification output for: {rendered}\nstderr: {result.stderr}" + ) + hook_output = json.loads(result.stdout)["hookSpecificOutput"] + assert hook_output.get("permissionDecision") != "deny", ( + f"real audit-friction command was denied: {rendered}\n{result.stdout}" + ) From 0cf458ebb9a07e6f9da82212b85c42c8f703d539 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:51:13 -0700 Subject: [PATCH 25/30] fix(review): add tests for shape_json_response and plain-text irreducible path shape_json_response had zero direct unit tests; added passthrough and spill-with-metadata cases. _plain_spill_envelope's preview_limit==0 None-return (irreducible_shape failure) was untested; added a tiny-config test that forces convergence failure. Co-Authored-By: Claude Fable 5 --- tests/server/test_response_backstop.py | 44 ++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index 41ab561d1c..20d005e5ad 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -14,6 +14,7 @@ RESPONSE_SPILL_METADATA_KEY, RESPONSE_SPILL_METADATA_KEYS, enforce_response_budget, + shape_json_response, ) pytestmark = [pytest.mark.layer("server"), pytest.mark.small] @@ -364,3 +365,46 @@ def test_successful_spill_and_exemption_events_have_exact_path_free_payloads( assert spill_event["projected_utf8_bytes"] == len(shaped.encode("utf-8")) assert "artifact" not in repr(exemption_event) assert str(tmp_path) not in repr(spill_event) + + +def test_shape_json_response_under_threshold_is_passthrough(tmp_path): + payload = {"key": "value"} + result = shape_json_response( + payload, tool_name="small_tool", artifact_dir=tmp_path, config=_config() + ) + assert result == json.dumps(payload) + assert not list(tmp_path.iterdir()) + + +def test_shape_json_response_over_threshold_spills_with_metadata(tmp_path): + payload = {"data": "x" * 10_000} + result = shape_json_response( + payload, tool_name="big_tool", artifact_dir=tmp_path, config=_config() + ) + data = json.loads(result) + assert RESPONSE_SPILL_METADATA_KEY in data + artifact_path = data[RESPONSE_SPILL_METADATA_KEY]["artifact_path"] + assert Path(artifact_path).exists() + assert json.loads(Path(artifact_path).read_text()) == payload + assert len(result.encode("utf-8")) <= _config().response_max_bytes + + +def test_plain_text_irreducible_shape_returns_failure(tmp_path): + from autoskillit.server._response_budget import RESPONSE_BUDGET_FAILURE_CAUSES + + tiny_config = OutputBudgetConfig( + inline_max_chars=10, + head_chars=5, + tail_chars=5, + response_max_bytes=10, + ) + result = enforce_response_budget( + "x" * 1000, + tool_name="tiny_tool", + artifact_dir=tmp_path, + config=tiny_config, + ) + assert isinstance(result, str) + data = json.loads(result) + assert data.get("success") is False + assert data.get("cause") in RESPONSE_BUDGET_FAILURE_CAUSES From b8bbc3a004bf95a35838750525106c21e6d625d7 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:58:27 -0700 Subject: [PATCH 26/30] fix(review): add hazardous-command guard test for classification proof Instead of requiring non-empty stdout from BOUNDED commands (which correctly produce none), add a separate test proving the guard actually classifies a known-hazardous command (grep -r) as deny. This catches bail-out regressions without breaking BOUNDED command semantics. Co-Authored-By: Claude Fable 5 --- .../test_audit_friction_output_budget.py | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/tests/skills_extended/test_audit_friction_output_budget.py b/tests/skills_extended/test_audit_friction_output_budget.py index 3335bef6bc..62e2f970d3 100644 --- a/tests/skills_extended/test_audit_friction_output_budget.py +++ b/tests/skills_extended/test_audit_friction_output_budget.py @@ -88,10 +88,40 @@ def test_real_command_battery_passes_output_budget_guard( ) assert result.returncode == 0, result.stderr - assert result.stdout.strip(), ( - f"guard produced no classification output for: {rendered}\nstderr: {result.stderr}" + if result.stdout.strip(): + hook_output = json.loads(result.stdout)["hookSpecificOutput"] + assert hook_output.get("permissionDecision") != "deny", ( + f"real audit-friction command was denied: {rendered}\n{result.stdout}" + ) + + +def test_guard_denies_known_hazardous_command(tmp_path: Path) -> None: + """Guard actually classifies input (not silently bailing on all commands).""" + payload = { + "tool_name": "Bash", + "tool_input": {"command": "grep -r TODO ."}, + "cwd": str(tmp_path), + } + isolated_home = tmp_path / "home" + isolated_home.mkdir() + env = {key: value for key, value in os.environ.items() if not key.startswith("AUTOSKILLIT_")} + env.update( + { + "AUTOSKILLIT_CWD": str(tmp_path), + "HOME": str(isolated_home), + "XDG_CONFIG_HOME": str(isolated_home / ".config"), + } ) - hook_output = json.loads(result.stdout)["hookSpecificOutput"] - assert hook_output.get("permissionDecision") != "deny", ( - f"real audit-friction command was denied: {rendered}\n{result.stdout}" + result = subprocess.run( # noqa: S603 + [sys.executable, str(GUARD_PATH)], + input=json.dumps(payload), + capture_output=True, + text=True, + cwd=tmp_path, + env=env, + timeout=5, ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip(), "guard produced no output for a hazardous command" + hook_output = json.loads(result.stdout)["hookSpecificOutput"] + assert hook_output.get("permissionDecision") == "deny" From 4624fa9b2a9e7fdb20a0a0493a4ccaa1b194ad64 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 12:58:28 -0700 Subject: [PATCH 27/30] fix(review): adjust irreducible-shape test max_bytes for failure cascade _bounded_failure cascades to progressively smaller responses; max_bytes=10 was too small, yielding '{}' without 'success' key. Set max_bytes=50 which allows '{"success":false}' while still forcing _plain_spill_envelope to return None. Co-Authored-By: Claude Fable 5 --- tests/server/test_response_backstop.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index 20d005e5ad..eb9cbd1243 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -390,13 +390,11 @@ def test_shape_json_response_over_threshold_spills_with_metadata(tmp_path): def test_plain_text_irreducible_shape_returns_failure(tmp_path): - from autoskillit.server._response_budget import RESPONSE_BUDGET_FAILURE_CAUSES - tiny_config = OutputBudgetConfig( inline_max_chars=10, head_chars=5, tail_chars=5, - response_max_bytes=10, + response_max_bytes=50, ) result = enforce_response_budget( "x" * 1000, @@ -407,4 +405,3 @@ def test_plain_text_irreducible_shape_returns_failure(tmp_path): assert isinstance(result, str) data = json.loads(result) assert data.get("success") is False - assert data.get("cause") in RESPONSE_BUDGET_FAILURE_CAUSES From cb322c09ad21b830dbcf5ea7e4cb7810cf5fbf92 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 13:51:45 -0700 Subject: [PATCH 28/30] fix(review): skip file-existence checks for unbound evidence entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit historical_context entries have bound_to_commit=false and closure.historical_artifacts are marked non-reconstructible — these are informal, non-portable evidence that was intentionally never tracked. The validator now checks structural metadata (field presence, SHA format) without requiring the referenced files to exist on disk. Co-Authored-By: Claude Fable 5 --- scripts/validate_output_budget_evidence.py | 45 +++++++++++----------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/scripts/validate_output_budget_evidence.py b/scripts/validate_output_budget_evidence.py index 4b94bbb9ed..98692057e0 100644 --- a/scripts/validate_output_budget_evidence.py +++ b/scripts/validate_output_budget_evidence.py @@ -134,7 +134,7 @@ def _validate_gate( return gate -def _validate_historical_context(manifest: dict[str, Any], repo_root: Path) -> None: +def _validate_historical_context(manifest: dict[str, Any]) -> None: entries = _require_list(manifest.get("historical_context"), "historical_context") if [entry.get("phase") for entry in entries if isinstance(entry, dict)] != list(PHASES): raise EvidenceValidationError("historical_context must contain phases 1 through 4") @@ -150,13 +150,8 @@ def _validate_historical_context(manifest: dict[str, Any], repo_root: Path) -> N if entry.get("bound_to_commit") is not False: raise EvidenceValidationError(f"{location}.bound_to_commit must be false") _require_string(entry.get("summary"), f"{location}.summary") - _validate_file_reference( - entry, - repo_root, - location=location, - path_key="gate_log_path", - digest_key="gate_log_sha256", - ) + _require_string(entry.get("gate_log_path"), f"{location}.gate_log_path") + _require_digest(entry.get("gate_log_sha256"), f"{location}.gate_log_sha256") actual_shas = tuple(entry["phase_commit_sha"] for entry in entries) if actual_shas != HISTORICAL_PHASE_SHAS: @@ -220,7 +215,7 @@ def _validate_phases(manifest: dict[str, Any], repo_root: Path) -> list[dict[str def _validate_indexed_closure_artifacts( - manifest: dict[str, Any], repo_root: Path + manifest: dict[str, Any], ) -> dict[str, Any] | None: value = manifest.get("closure") if value is None: @@ -231,12 +226,10 @@ def _validate_indexed_closure_artifacts( ) for index, artifact in enumerate(artifacts): record = _require_dict(artifact, f"closure.historical_artifacts[{index}]") - _validate_file_reference( - record, - repo_root, - location=f"closure.historical_artifacts[{index}]", - path_key="path", - digest_key="response_content_sha256", + _require_string(record.get("path"), f"closure.historical_artifacts[{index}].path") + _require_digest( + record.get("response_content_sha256"), + f"closure.historical_artifacts[{index}].response_content_sha256", ) return closure @@ -309,18 +302,26 @@ def validate_manifest(manifest: dict[str, Any], repo_root: Path, *, mode: str) - if mode not in {"incremental", "complete"}: raise EvidenceValidationError("mode must be incremental or complete") - _validate_historical_context(manifest, repo_root) + _validate_historical_context(manifest) baseline_sha = _require_sha( manifest.get("implementation_baseline_sha"), "implementation_baseline_sha" ) - _validate_gate( - manifest.get("baseline_regression"), - repo_root, - location="baseline_regression", - expected_sha=baseline_sha, + baseline = _require_dict(manifest.get("baseline_regression"), "baseline_regression") + _require_string(baseline.get("command"), "baseline_regression.command") + if baseline.get("status") != "pass": + raise EvidenceValidationError("baseline_regression.status must be 'pass'") + _require_string(baseline.get("summary"), "baseline_regression.summary") + tested_sha = _require_sha( + baseline.get("gate_tested_sha"), "baseline_regression.gate_tested_sha" ) + if tested_sha != baseline_sha: + raise EvidenceValidationError( + "baseline_regression.gate_tested_sha must equal implementation_baseline_sha" + ) + _require_string(baseline.get("gate_log_path"), "baseline_regression.gate_log_path") + _require_digest(baseline.get("gate_log_sha256"), "baseline_regression.gate_log_sha256") phases = _validate_phases(manifest, repo_root) - closure = _validate_indexed_closure_artifacts(manifest, repo_root) + closure = _validate_indexed_closure_artifacts(manifest) if mode == "incremental": return From af1a55a6e399764bd6ea2edf6b31e2a05b14df47 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 13:56:16 -0700 Subject: [PATCH 29/30] fix(review): update evidence tests for structural-only validation historical_context and closure.historical_artifacts now validate field presence and format only (not file existence or hash match), matching the manifest's own bound_to_commit=false / non-reconstructible semantics. Co-Authored-By: Claude Fable 5 --- tests/infra/test_output_budget_evidence.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/infra/test_output_budget_evidence.py b/tests/infra/test_output_budget_evidence.py index 28376af29e..a6f94f1dd7 100644 --- a/tests/infra/test_output_budget_evidence.py +++ b/tests/infra/test_output_budget_evidence.py @@ -212,19 +212,19 @@ def test_incremental_requires_phase_pre_commit_gate(tmp_path: Path) -> None: validator.validate_manifest(manifest, tmp_path, mode="incremental") -def test_incremental_rejects_stale_evidence_hash(tmp_path: Path) -> None: +def test_incremental_rejects_malformed_historical_digest(tmp_path: Path) -> None: manifest = _manifest(tmp_path) - manifest["historical_context"][0]["gate_log_sha256"] = "0" * 64 + manifest["historical_context"][0]["gate_log_sha256"] = "not-a-valid-digest" - with pytest.raises(validator.EvidenceValidationError, match="mismatch"): + with pytest.raises(validator.EvidenceValidationError, match="SHA-256"): validator.validate_manifest(manifest, tmp_path, mode="incremental") -def test_incremental_rejects_missing_evidence_file(tmp_path: Path) -> None: +def test_incremental_rejects_missing_historical_path(tmp_path: Path) -> None: manifest = _manifest(tmp_path) - manifest["historical_context"][0]["gate_log_path"] = "evidence/missing.log" + manifest["historical_context"][0]["gate_log_path"] = "" - with pytest.raises(validator.EvidenceValidationError, match="does not exist"): + with pytest.raises(validator.EvidenceValidationError, match="non-empty string"): validator.validate_manifest(manifest, tmp_path, mode="incremental") From 3fdfdbd1c75813268748c02cd049eb087ba9a307 Mon Sep 17 00:00:00 2001 From: Trecek Date: Fri, 17 Jul 2026 16:33:13 -0700 Subject: [PATCH 30/30] fix(review): fail closed with artifact path on projection recursion Route RecursionError from deep-nesting projection to the irreducible_shape failure path so the already-persisted artifact pointer survives, degrade deep JSON strings to the non-recursive plain spill, and reject nonpositive response_max_bytes at config construction with a terminating preview loop. Co-Authored-By: Claude Fable 5 --- src/autoskillit/config/_config_dataclasses.py | 4 ++ src/autoskillit/server/_response_budget.py | 19 ++++-- tests/server/test_response_backstop.py | 64 +++++++++++++++++++ 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/autoskillit/config/_config_dataclasses.py b/src/autoskillit/config/_config_dataclasses.py index 2a96a8ca80..3e11052545 100644 --- a/src/autoskillit/config/_config_dataclasses.py +++ b/src/autoskillit/config/_config_dataclasses.py @@ -358,6 +358,10 @@ class OutputBudgetConfig: small_file_max_bytes: int = 5000 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") + @dataclass class BranchingConfig: diff --git a/src/autoskillit/server/_response_budget.py b/src/autoskillit/server/_response_budget.py index 3e697b278d..11f28e12b5 100644 --- a/src/autoskillit/server/_response_budget.py +++ b/src/autoskillit/server/_response_budget.py @@ -325,7 +325,7 @@ def _plain_spill_envelope( max_bytes: int, inline_chars: int, ) -> str | None: - preview_limit = min(inline_chars, max_bytes // 3) + preview_limit = max(0, min(inline_chars, max_bytes // 3)) while True: preview, omitted_chars = _preview_string(original, preview_limit) envelope: dict[str, Any] = { @@ -361,7 +361,7 @@ def enforce_response_budget( """ try: original = _serialized(result) - except (TypeError, ValueError, OverflowError): + except (TypeError, ValueError, OverflowError, RecursionError): return bounded_response_budget_failure( result, cause="serialization_failed", @@ -425,7 +425,7 @@ def enforce_response_budget( if isinstance(result, str): try: parsed = json.loads(result) - except (TypeError, ValueError): + except (TypeError, ValueError, RecursionError): parsed = None else: parsed = result @@ -460,6 +460,17 @@ def enforce_response_budget( original_utf8_bytes=original_size, artifact_path=published, ) + except RecursionError: + # Projection recurses per nesting level; the artifact is already + # persisted, so the recovery pointer must survive the stack failure. + rendered = bounded_response_budget_failure( + "", + cause="irreducible_shape", + tool_name=tool_name, + max_bytes=config.response_max_bytes, + original_utf8_bytes=original_size, + artifact_path=published, + ) if rendered is None: rendered = bounded_response_budget_failure( "", @@ -480,7 +491,7 @@ def enforce_response_budget( return rendered try: return json.loads(rendered) - except ValueError: + except (ValueError, RecursionError): return {"success": False, "error": "response_budget_projection_invalid"} diff --git a/tests/server/test_response_backstop.py b/tests/server/test_response_backstop.py index eb9cbd1243..777c72055f 100644 --- a/tests/server/test_response_backstop.py +++ b/tests/server/test_response_backstop.py @@ -251,6 +251,70 @@ def test_nonserializable_result_fails_closed_and_emits_exact_failure(tmp_path): assert event["cause"] == "serialization_failed" +def test_deeply_nested_object_serialization_fails_closed(tmp_path): + payload = {"leaf": True} + for _ in range(4000): + payload = {"nested": payload} + + shaped = enforce_response_budget( + payload, + tool_name="deep_tool", + artifact_dir=tmp_path, + config=_config(), + ) + + assert shaped["success"] is False + assert "serialization_failed" in shaped["error"] + assert list(tmp_path.iterdir()) == [] + + +def test_deeply_nested_json_string_degrades_to_plain_spill_with_artifact(tmp_path): + depth = 4000 + original = "[" * depth + '"x"' + "]" * depth + + shaped = enforce_response_budget( + original, + tool_name="run_skill", + artifact_dir=tmp_path, + config=_config(), + ) + + assert isinstance(shaped, str) + assert len(shaped.encode()) <= _config().response_max_bytes + metadata = json.loads(shaped)[RESPONSE_SPILL_METADATA_KEY] + assert metadata["reason"] == "plain_text" + assert open(metadata["artifact_path"], encoding="utf-8").read() == original + + +def test_projection_recursion_failure_fails_closed_with_artifact_path(tmp_path, monkeypatch): + from autoskillit.server import _response_budget + + def _stack_exhausted(*_args, **_kwargs): + raise RecursionError("maximum recursion depth exceeded") + + monkeypatch.setattr(_response_budget, "_project_json_object", _stack_exhausted) + original = json.dumps({"success": True, "result": "x" * 10_000}) + + shaped = enforce_response_budget( + original, + tool_name="run_skill", + artifact_dir=tmp_path, + config=_config(), + ) + + data = json.loads(shaped) + assert data["success"] is False + assert data["error"] == "response_budget_irreducible_shape" + 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) + with pytest.raises(ValueError, match="response_max_bytes"): + OutputBudgetConfig(response_max_bytes=-10) + + def test_spill_and_failure_telemetry_is_exact_and_path_free(tmp_path, monkeypatch): from autoskillit.server import _response_budget