Skip to content

Commit 1d5a872

Browse files
committed
release: publish csep 1.3.9
1 parent d0edde5 commit 1d5a872

4 files changed

Lines changed: 188 additions & 8 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "csep"
7-
version = "1.3.8"
7+
version = "1.3.9"
88
description = "Self-evolution loop for OpenAI Codex: session reflection, session recall, and reflection-generated skills with zero Python runtime dependencies."
99
readme = "README.md"
1010
requires-python = ">=3.11"
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__all__ = ["__version__"]
22

3-
__version__ = "1.3.8"
3+
__version__ = "1.3.9"

‎src/codex_self_evolution/session_reflection/runner.py‎

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@
4141

4242
RECEIPT_POLL_INTERVAL_SECONDS = 0.05
4343
RECEIPT_STABLE_INVALID_GRACE_SECONDS = 0.5
44+
VALIDATION_ISSUE_KEYS = (
45+
"boundary_violations",
46+
"hash_mismatches",
47+
"invalid_skills",
48+
"low_value_memory_writes",
49+
)
4450

4551

4652
def enqueue_reflection_from_payload(payload: dict[str, Any], *, home: str | Path | None = None) -> dict[str, Any]:
@@ -272,9 +278,45 @@ def _compact_validation(validation: dict[str, Any]) -> dict[str, Any]:
272278
value = validation.get(key)
273279
if value is not None:
274280
compact[key] = value
281+
category = _validation_category(validation)
282+
if category:
283+
compact["category"] = category
284+
compact["issue_counts"] = _validation_issue_counts(validation)
275285
return compact
276286

277287

288+
def _validation_category(validation: dict[str, Any]) -> str:
289+
"""Classify validation failures by whether receipt or artifacts broke."""
290+
status = str(validation.get("status") or "")
291+
if status not in {"failed", "partial"}:
292+
return ""
293+
reason = str(validation.get("reason") or "")
294+
if reason.startswith("receipt_") or reason in {
295+
"missing_receipt",
296+
"job_id_mismatch",
297+
"parent_session_id_mismatch",
298+
"child_thread_id_mismatch",
299+
}:
300+
return "child_receipt_contract"
301+
if validation.get("boundary_violations"):
302+
return "child_artifact_boundary"
303+
if validation.get("hash_mismatches") or validation.get("invalid_skills") or validation.get("low_value_memory_writes"):
304+
return "child_artifact_quality"
305+
if validation.get("error"):
306+
return "system_error"
307+
return "child_validation"
308+
309+
310+
def _validation_issue_counts(validation: dict[str, Any]) -> dict[str, int]:
311+
"""Return compact counts for validation issue lists present in status."""
312+
if not any(key in validation for key in VALIDATION_ISSUE_KEYS):
313+
return {}
314+
return {
315+
key: len(value) if isinstance(value := validation.get(key), list) else 0
316+
for key in VALIDATION_ISSUE_KEYS
317+
}
318+
319+
278320
def _wait_for_receipt(receipt_path: Path, *, timeout_seconds: float) -> None:
279321
"""Wait for an asynchronous app-server turn to finish writing its receipt."""
280322
deadline = time.monotonic() + max(0.0, timeout_seconds)
@@ -381,13 +423,66 @@ def _load_receipt_object(receipt_path: Path) -> tuple[dict[str, Any] | None, str
381423
return None, f"receipt_unreadable: {exc}"
382424
if not text.strip():
383425
return None, "receipt_empty"
384-
try:
385-
loaded = json.loads(text)
386-
except ValueError as exc:
387-
return None, f"receipt_invalid_json: {exc}"
388-
if not isinstance(loaded, dict):
426+
receipt, parse_error = _parse_receipt_object_text(text)
427+
if receipt is None:
428+
return None, f"receipt_invalid_json: {parse_error}"
429+
if not isinstance(receipt, dict):
389430
return None, "receipt_not_object"
390-
return loaded, ""
431+
return receipt, ""
432+
433+
434+
def _parse_receipt_object_text(text: str) -> tuple[dict[str, Any] | None, str]:
435+
"""Parse common model-written receipt variants into a receipt object."""
436+
last_error = ""
437+
for candidate in _receipt_text_candidates(text):
438+
try:
439+
loaded = json.loads(candidate)
440+
except ValueError as exc:
441+
last_error = str(exc)
442+
continue
443+
if isinstance(loaded, dict):
444+
return loaded, ""
445+
if isinstance(loaded, str):
446+
try:
447+
nested = json.loads(loaded)
448+
except ValueError as exc:
449+
last_error = str(exc)
450+
continue
451+
if isinstance(nested, dict):
452+
return nested, ""
453+
return None, last_error or "not a JSON object"
454+
455+
456+
def _receipt_text_candidates(text: str) -> list[str]:
457+
"""Return raw, fenced, and escaped receipt text candidates."""
458+
stripped = text.strip()
459+
candidates = [stripped]
460+
unfenced = _strip_json_code_fence(stripped)
461+
if unfenced != stripped:
462+
candidates.append(unfenced)
463+
for base in list(candidates):
464+
decoded = _decode_escaped_receipt_text(base)
465+
if decoded and decoded not in candidates:
466+
candidates.append(decoded)
467+
return candidates
468+
469+
470+
def _strip_json_code_fence(text: str) -> str:
471+
"""Remove a surrounding Markdown JSON code fence from receipt text."""
472+
lines = text.splitlines()
473+
if len(lines) >= 3 and lines[0].strip().startswith("```") and lines[-1].strip() == "```":
474+
return "\n".join(lines[1:-1]).strip()
475+
return text
476+
477+
478+
def _decode_escaped_receipt_text(text: str) -> str:
479+
"""Decode receipt text that escaped quotes/newlines without string quotes."""
480+
if "\\\"" not in text and "\\n" not in text:
481+
return ""
482+
try:
483+
return text.encode("utf-8").decode("unicode_escape")
484+
except UnicodeError:
485+
return ""
391486

392487

393488
def _canonicalize_receipt_envelope(

‎tests/test_session_reflection_runner.py‎

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,51 @@ def test_run_reflection_job_recovers_changed_memory_when_receipt_is_invalid(
437437
assert receipt["skill_changes"] == []
438438

439439

440+
def test_run_reflection_job_canonicalizes_escaped_json_receipt(
441+
monkeypatch: pytest.MonkeyPatch,
442+
tmp_path: Path,
443+
) -> None:
444+
"""Escaped JSON object text from a child turn is normalized before validation."""
445+
home = tmp_path / "home"
446+
repo = tmp_path / "repo"
447+
repo.mkdir()
448+
monkeypatch.setenv("CODEX_SELF_EVOLUTION_HOME", str(home))
449+
monkeypatch.setenv("CSEP_CODEX_SKILLS_DIR", str(tmp_path / "skills"))
450+
job = create_job_from_payload(_payload(repo), home=home)
451+
452+
updated = run_reflection_job(
453+
str(job["job_id"]),
454+
home=home,
455+
client=FakeReflectionClient(
456+
invalid_receipt_text=(
457+
"{\\n"
458+
' \\"schema_version\\": 1,\\n'
459+
' \\"job_id\\": \\"wrong-job\\",\\n'
460+
' \\"parent_session_id\\": \\"wrong-parent\\",\\n'
461+
' \\"child_thread_id\\": \\"wrong-child\\",\\n'
462+
' \\"status\\": \\"succeeded\\",\\n'
463+
' \\"memory_changes\\": [],\\n'
464+
' \\"skill_changes\\": [],\\n'
465+
' \\"skipped_candidates\\": [],\\n'
466+
' \\"validation_notes\\": [],\\n'
467+
' \\"errors\\": [],\\n'
468+
' \\"started_at\\": \\"$run_ts\\",\\n'
469+
' \\"finished_at\\": \\"$run_ts\\"\\n'
470+
"}"
471+
),
472+
),
473+
)
474+
receipt = json.loads((home / "session_reflection" / "runs" / str(job["job_id"]) / "receipt.json").read_text())
475+
476+
assert updated["status"] == "skipped_empty"
477+
assert updated["validation"]["status"] == "skipped_empty"
478+
assert receipt["job_id"] == job["job_id"]
479+
assert receipt["parent_session_id"] == "parent-1"
480+
assert receipt["child_thread_id"] == "child-1"
481+
assert receipt["started_at"].endswith("Z")
482+
assert receipt["validation_notes"][0]["reason"] == "receipt_envelope_canonicalized"
483+
484+
440485
def test_run_reflection_job_resets_trigger_counters_after_success(
441486
monkeypatch: pytest.MonkeyPatch,
442487
tmp_path: Path,
@@ -870,6 +915,46 @@ def test_session_reflection_status_reports_failure_details(
870915
"status": "failed",
871916
"reason": "missing_receipt",
872917
"error": "receipt missing",
918+
"category": "child_receipt_contract",
919+
"issue_counts": {},
920+
}
921+
922+
923+
def test_session_reflection_status_classifies_validation_failures(
924+
monkeypatch: pytest.MonkeyPatch,
925+
tmp_path: Path,
926+
) -> None:
927+
"""Status explains whether validation failed in the child receipt or artifacts."""
928+
home = tmp_path / "home"
929+
repo = tmp_path / "repo"
930+
repo.mkdir()
931+
monkeypatch.setenv("CODEX_SELF_EVOLUTION_HOME", str(home))
932+
job = create_job_from_payload(_payload(repo), home=home)
933+
update_job_status(
934+
str(job["job_id"]),
935+
"failed",
936+
home=home,
937+
validation={
938+
"status": "failed",
939+
"receipt_status": "succeeded",
940+
"boundary_violations": [{"reason": "memory_outside_root", "path": "/tmp/outside.md"}],
941+
"hash_mismatches": [],
942+
"invalid_skills": [],
943+
"low_value_memory_writes": [],
944+
},
945+
)
946+
947+
status = session_reflection_status(home=home)
948+
949+
assert status["latest"]["validation"] == {
950+
"status": "failed",
951+
"category": "child_artifact_boundary",
952+
"issue_counts": {
953+
"boundary_violations": 1,
954+
"hash_mismatches": 0,
955+
"invalid_skills": 0,
956+
"low_value_memory_writes": 0,
957+
},
873958
}
874959

875960

0 commit comments

Comments
 (0)