Skip to content

Commit 87b7f6b

Browse files
Trecekclaude
andauthored
Implementation Plan: Codex Sous-Chef Recipe Context Loss via Auto-Compaction (#3947)
## Summary Prevent Codex sous-chef sessions from losing recipe content during long pipeline runs by: 1. **Disabling auto-compaction** — Set `model_auto_compact_token_limit = 999999999` in the Codex `config.toml` via `ensure_codex_mcp_registered()`, using the existing `_ensure_top_level_key()` pattern already used for `tool_output_token_limit`. 2. **Structural recipe-read guard** — Add a new blocking PreToolUse guard hook (`recipe_read_guard.py`) that denies `run_cmd`/`Bash` calls containing recipe/skill/agent file path patterns and `run_python` calls importing `autoskillit.recipe.*`. This provides defense-in-depth that cannot be compacted away, unlike the prompt-based prohibitions in `_backend_supplement()`. ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260608-151346-014727/.autoskillit/temp/make-plan/codex_sous_chef_recipe_context_loss_via_auto_compaction_plan_2026-06-08_153500.md` Closes #3938 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | run_bem* | sonnet | 1 | 62 | 31.8k | 434.0k | 97.6k | 24 | 89.5k | 9m 23s | | dispatch:f69547b0-e15c-448a-a61c-366c3d67786d* | sonnet | 1 | 66 | 4.0k | 269.4k | 42.6k | 17 | 43.5k | 10m 32s | | plan* | opus[1m] | 7 | 5.0k | 63.9k | 5.9M | 94.3k | 210 | 376.6k | 44m 19s | | verify* | sonnet | 6 | 5.3k | 50.7k | 2.6M | 66.1k | 151 | 216.9k | 27m 9s | | implement* | MiniMax-M3 | 6 | 381.4k | 29.1k | 6.2M | 74.7k | 279 | 0 | 22m 19s | | audit_impl* | sonnet | 6 | 4.9k | 33.4k | 1.2M | 48.0k | 97 | 143.6k | 16m 50s | | prepare_pr* | MiniMax-M3 | 5 | 219.3k | 11.8k | 798.6k | 47.9k | 64 | 0 | 5m 36s | | compose_pr* | MiniMax-M3 | 5 | 181.4k | 7.3k | 858.4k | 39.0k | 64 | 0 | 4m 13s | | review_pr* | sonnet | 10 | 1.3k | 180.5k | 7.9M | 90.9k | 427 | 440.6k | 43m 52s | | dispatch:2496288b-5215-4250-9d9d-a50396f464c3* | sonnet | 1 | 354 | 12.7k | 3.2M | 85.4k | 125 | 85.6k | 47m 48s | | resolve_review* | sonnet | 3 | 655 | 34.5k | 5.0M | 98.2k | 187 | 151.8k | 16m 29s | | dispatch:90358dda-aa18-4129-8dc1-d152f9f5f96c* | sonnet | 1 | 314 | 10.3k | 2.8M | 80.8k | 82 | 110.7k | 35m 1s | | dispatch:14357aff-0458-4688-aeeb-7546f8b07de4* | sonnet | 1 | 346 | 12.7k | 3.2M | 85.0k | 87 | 85.2k | 32m 2s | | fix* | sonnet | 1 | 232 | 22.3k | 1.9M | 86.5k | 76 | 64.8k | 10m 28s | | dispatch:d8fd01d8-d94b-4d47-a6f0-120c4a965a79* | sonnet | 1 | 346 | 12.3k | 3.4M | 89.5k | 123 | 65.1k | 1h 3m | | retry_worktree* | sonnet | 1 | 84 | 2.8k | 356.0k | 40.6k | 25 | 23.0k | 1m 45s | | dispatch:71dae9f1-25d8-40bc-bdc2-31bcc568ef60* | sonnet | 1 | 178 | 6.1k | 1.5M | 76.7k | 44 | 52.4k | 22m 15s | | dispatch:1bd28782-d46f-49ab-bf61-0b127e2bc596* | sonnet | 1 | 370 | 12.2k | 3.5M | 86.8k | 97 | 62.4k | 1h 14m | | **Total** | | | 801.5k | 538.3k | 51.0M | 98.2k | | 2.0M | 8h 7m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | run_bem | 0 | — | — | — | | dispatch:f69547b0-e15c-448a-a61c-366c3d67786d | 0 | — | — | — | | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 353 | 17691.0 | 0.0 | 82.4 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | dispatch:2496288b-5215-4250-9d9d-a50396f464c3 | 7568 | 426.8 | 11.3 | 1.7 | | resolve_review | 53 | 94520.3 | 2864.8 | 651.7 | | dispatch:90358dda-aa18-4129-8dc1-d152f9f5f96c | 0 | — | — | — | | dispatch:14357aff-0458-4688-aeeb-7546f8b07de4 | 0 | — | — | — | | fix | 14 | 134006.6 | 4625.6 | 1592.7 | | dispatch:d8fd01d8-d94b-4d47-a6f0-120c4a965a79 | 0 | — | — | — | | retry_worktree | 0 | — | — | — | | dispatch:71dae9f1-25d8-40bc-bdc2-31bcc568ef60 | 0 | — | — | — | | dispatch:1bd28782-d46f-49ab-bf61-0b127e2bc596 | 1230 | 2842.6 | 50.8 | 9.9 | | **Total** | **9218** | 5535.2 | 218.2 | 58.4 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | sonnet | 14 | 14.5k | 426.2k | 37.2M | 1.6M | 6h 51m | | opus[1m] | 1 | 5.0k | 63.9k | 5.9M | 376.6k | 44m 19s | | MiniMax-M3 | 3 | 782.1k | 48.1k | 7.9M | 0 | 32m 9s | --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3e77893 commit 87b7f6b

14 files changed

Lines changed: 366 additions & 2 deletions

src/autoskillit/execution/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
)
2020
from autoskillit.execution.backends import (
2121
BACKEND_REGISTRY,
22+
CODEX_AUTO_COMPACT_LIMIT,
2223
CODEX_MCP_REQUIRED_KEYS,
2324
CODEX_MCP_STARTUP_TIMEOUT_SEC,
2425
CODEX_MCP_TOOL_TIMEOUT_FLOOR,
@@ -228,6 +229,7 @@
228229
"CODEX_MCP_STARTUP_TIMEOUT_SEC",
229230
"CODEX_MCP_TOOL_TIMEOUT_FLOOR",
230231
"CODEX_TOOL_OUTPUT_TOKEN_LIMIT",
232+
"CODEX_AUTO_COMPACT_LIMIT",
231233
"ClaudeCodeBackend",
232234
"CodexBackend",
233235
"_is_autoskillit_hook_entry",

src/autoskillit/execution/backends/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from autoskillit.core import CodingAgentBackend
44

55
from ._codex_config import (
6+
CODEX_AUTO_COMPACT_LIMIT,
67
CODEX_MCP_REQUIRED_KEYS,
78
CODEX_MCP_STARTUP_TIMEOUT_SEC,
89
CODEX_MCP_TOOL_TIMEOUT_FLOOR,
@@ -80,6 +81,7 @@ def get_backend(name: str) -> CodingAgentBackend:
8081
"CODEX_MCP_TOOL_TIMEOUT_FLOOR",
8182
"CODEX_MCP_REQUIRED_KEYS",
8283
"CODEX_TOOL_OUTPUT_TOKEN_LIMIT",
84+
"CODEX_AUTO_COMPACT_LIMIT",
8385
"NON_VARIADIC_CODEX_FLAGS",
8486
"VARIADIC_CODEX_FLAGS",
8587
"_is_autoskillit_registered",

src/autoskillit/execution/backends/_codex_config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
# `open_kitchen` response sizes with 2x headroom.
2828
CODEX_TOOL_OUTPUT_TOKEN_LIMIT: int = 50_000
2929

30+
# Disable Codex auto-compaction by setting the limit to an unreachable value.
31+
# Auto-compaction at 90% of 258K context window can destroy recipe content
32+
# loaded by `open_kitchen`, leaving the agent without the recipe steps it
33+
# needs to complete the pipeline. Defense-in-depth is provided by the
34+
# `recipe_read_guard.py` PreToolUse hook which prevents the agent from
35+
# re-reading recipe files via run_cmd/run_python after compaction.
36+
CODEX_AUTO_COMPACT_LIMIT: int = 999_999_999
37+
3038
# Keys that must be present in the autoskillit MCP server entry for the Codex
3139
# backend. Validated against the entry dict actually written by
3240
# `ensure_codex_mcp_registered` via the arch test
@@ -233,6 +241,8 @@ def _is_autoskillit_registered(
233241
return False
234242
if config.get("tool_output_token_limit", 0) < CODEX_TOOL_OUTPUT_TOKEN_LIMIT:
235243
return False
244+
if config.get("model_auto_compact_token_limit", 0) < CODEX_AUTO_COMPACT_LIMIT:
245+
return False
236246
return True
237247

238248

@@ -294,6 +304,11 @@ def ensure_codex_mcp_registered(
294304
key="tool_output_token_limit",
295305
value=CODEX_TOOL_OUTPUT_TOKEN_LIMIT,
296306
)
307+
_ensure_top_level_key(
308+
config_path,
309+
key="model_auto_compact_token_limit",
310+
value=CODEX_AUTO_COMPACT_LIMIT,
311+
)
297312
return True
298313
else:
299314
config = result.data
@@ -305,5 +320,6 @@ def ensure_codex_mcp_registered(
305320
return False
306321
config.setdefault("mcp_servers", {})["autoskillit"] = entry
307322
config.setdefault("tool_output_token_limit", CODEX_TOOL_OUTPUT_TOKEN_LIMIT)
323+
config["model_auto_compact_token_limit"] = CODEX_AUTO_COMPACT_LIMIT
308324
_write_codex_config(config_path, config, source=result)
309325
return True

src/autoskillit/hook_registry.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,11 @@ def __post_init__(self) -> None:
207207
scripts=["guards/skill_orchestration_guard.py"],
208208
session_scope="headless_only",
209209
),
210+
HookDef(
211+
matcher=r"Bash|mcp__.*autoskillit.*__(run_cmd|run_python)",
212+
scripts=["guards/recipe_read_guard.py"],
213+
session_scope="headless_only",
214+
),
210215
HookDef(
211216
matcher=r"Bash|Agent",
212217
scripts=["guards/background_exec_guard.py"],
@@ -334,6 +339,7 @@ def __post_init__(self) -> None:
334339
"test_runner_guard.py",
335340
"fleet_claim_guard.py",
336341
"reset_resume_gate.py",
342+
"recipe_read_guard.py",
337343
}
338344
)
339345

src/autoskillit/hooks/guards/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ PreToolUse guard scripts — standalone Python processes enforcing tool-call pol
1818
| `skill_orchestration_guard.py` | Blocks `run_skill`/`run_cmd`/`run_python` from L1 skill sessions |
1919
| `mcp_health_advisor.py` | Detects MCP server disconnection (dead PID); non-blocking advisory |
2020
| `open_kitchen_guard.py` | Blocks `open_kitchen` from headless sessions; writes kitchen marker |
21+
| `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 |
2122
| `pipeline_step_guard.py` | Advisory unmet-dependency warning for `run_skill`; server-side `_check_pipeline_deps` is primary enforcer |
2223
| `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 |
2324
| `planner_gh_discovery_guard.py` | Blocks GitHub issue/PR listing in planner sessions |
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env python3
2+
"""PreToolUse hook — blocks unauthorized recipe/skill/agent file reads.
3+
4+
Defense-in-depth over the prompt-based prohibitions in `_backend_supplement()`.
5+
If Codex auto-compaction destroys the recipe content loaded by `open_kitchen`,
6+
the agent may attempt to re-read the recipe YAML, skill SKILL.md, or agent
7+
definition files via `run_cmd`/`run_python`/`Bash`. This guard blocks those
8+
recovery attempts at the tool-call boundary so the agent cannot self-recover
9+
from a state that should be treated as a hard failure (the recipe is gone).
10+
"""
11+
12+
import json
13+
import os
14+
import re
15+
import sys
16+
17+
RECIPE_READ_DENY_TRIGGER: str = "must not read recipe/skill/agent files directly"
18+
19+
_CMD_PATH_PATTERNS: list[re.Pattern[str]] = [
20+
re.compile(r"(?:\.autoskillit|src/autoskillit)/recipes/.*\.ya?ml"),
21+
re.compile(r"src/autoskillit/skills(?:_extended)?/.*/SKILL\.md"),
22+
re.compile(r"src/autoskillit/agents/.*\.md"),
23+
]
24+
25+
_CALLABLE_PATTERN: re.Pattern[str] = re.compile(r"^autoskillit\.recipe\b")
26+
27+
28+
def main() -> None:
29+
try:
30+
data = json.loads(sys.stdin.read())
31+
except (json.JSONDecodeError, ValueError, OSError):
32+
sys.stderr.write("recipe_read_guard: malformed stdin — failing open\n")
33+
sys.exit(0)
34+
35+
if os.environ.get("AUTOSKILLIT_HEADLESS") != "1":
36+
sys.exit(0)
37+
38+
tool_name: str = data.get("tool_name", "")
39+
tool_input: dict = data.get("tool_input", {})
40+
41+
tool = tool_name.split("__")[-1]
42+
43+
if tool == "run_cmd" or tool_name == "Bash":
44+
cmd_key = "command" if tool_name == "Bash" else "cmd"
45+
cmd: str = tool_input.get(cmd_key, "")
46+
for pattern in _CMD_PATH_PATTERNS:
47+
if pattern.search(cmd):
48+
_deny(
49+
f"run_cmd/Bash {RECIPE_READ_DENY_TRIGGER}. "
50+
"Use load_recipe to recall step definitions or the Skill tool "
51+
"for skill instructions."
52+
)
53+
54+
if tool == "run_python":
55+
callable_name: str = tool_input.get("callable", "")
56+
if _CALLABLE_PATTERN.search(callable_name):
57+
_deny(
58+
f"run_python {RECIPE_READ_DENY_TRIGGER}. "
59+
"Use load_recipe to recall step definitions."
60+
)
61+
62+
sys.exit(0)
63+
64+
65+
def _deny(reason: str) -> None:
66+
payload = json.dumps(
67+
{
68+
"hookSpecificOutput": {
69+
"hookEventName": "PreToolUse",
70+
"permissionDecision": "deny",
71+
"permissionDecisionReason": reason,
72+
}
73+
}
74+
)
75+
sys.stdout.write(payload + "\n")
76+
sys.exit(0)
77+
78+
79+
if __name__ == "__main__":
80+
main()
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
e16b7355310b925a6937aee8e0d6028f4dbe6c475d14c8d8f487923601ab8702
1+
d2267bd693aa5e4c6cea0d0575fe80c9201b0aa601b4efe1ebe10211ef5eeed8

tests/arch/test_subpackage_isolation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -874,7 +874,7 @@ def test_no_subpackage_exceeds_10_files() -> None:
874874
"fleet": 22, # REQ-CNST-003-E9: _dispatch_reaper.py; +_sidecar_synthesis.py; +_reset.py
875875
"recipe/rules": 50, # +commit_guard_regression_route +rules_model
876876
"server/tools": 25, # _auto_overrides.py + _cancellation_shield.py + tools_fleet_reset.py
877-
"hooks/guards": 31, # +fleet_claim_guard, +reset_resume_gate
877+
"hooks/guards": 32, # +fleet_claim_guard, +reset_resume_gate, +recipe_read_guard
878878
}
879879
violations: list[str] = []
880880
dirs_to_check: list[Path] = []

tests/cli/test_doctor_backend_guards.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ def test_codex_backend_ok_when_registered(
190190
Severity,
191191
)
192192
from autoskillit.execution import (
193+
CODEX_AUTO_COMPACT_LIMIT,
193194
CODEX_MCP_STARTUP_TIMEOUT_SEC,
194195
CODEX_MCP_TOOL_TIMEOUT_FLOOR,
195196
CODEX_TOOL_OUTPUT_TOKEN_LIMIT,
@@ -203,6 +204,7 @@ def test_codex_backend_ok_when_registered(
203204
)
204205
(codex_dir / "config.toml").write_text(
205206
f"tool_output_token_limit = {CODEX_TOOL_OUTPUT_TOKEN_LIMIT}\n"
207+
f"model_auto_compact_token_limit = {CODEX_AUTO_COMPACT_LIMIT}\n"
206208
f'[mcp_servers.autoskillit]\ncommand = "autoskillit"\n'
207209
f"env_vars = [{env_vars_str}]\n"
208210
f"startup_timeout_sec = {CODEX_MCP_STARTUP_TIMEOUT_SEC}\n"

tests/cli/test_doctor_codex_mcp.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
)
1212
from autoskillit.core import ReadResult, Severity
1313
from autoskillit.execution.backends._codex_config import (
14+
CODEX_AUTO_COMPACT_LIMIT,
1415
CODEX_MCP_STARTUP_TIMEOUT_SEC,
1516
CODEX_MCP_TOOL_TIMEOUT_FLOOR,
1617
CODEX_TOOL_OUTPUT_TOKEN_LIMIT,
@@ -41,6 +42,7 @@ def test_ok_when_valid_config(self, monkeypatch: pytest.MonkeyPatch) -> None:
4142
}
4243
},
4344
"tool_output_token_limit": CODEX_TOOL_OUTPUT_TOKEN_LIMIT,
45+
"model_auto_compact_token_limit": CODEX_AUTO_COMPACT_LIMIT,
4446
}
4547
),
4648
)

0 commit comments

Comments
 (0)