diff --git a/agent/decider.py b/agent/decider.py index 59582cce..6a27c359 100644 --- a/agent/decider.py +++ b/agent/decider.py @@ -20,6 +20,7 @@ import re from agent.context import context_for_agent +from agent.philosophy import serialize_philosophy_for_prompt from agent.planner import _release_cadence_signal, _release_timing_state logger = logging.getLogger(__name__) @@ -290,7 +291,7 @@ def _run_lens(name: str, context: dict, philosophy: dict, request: str, llm) -> system = _LENS_SYSTEMS[name] if name == "direction": user = ( - f"Repository philosophy:\n{json.dumps(philosophy, indent=1)[:3000]}\n\n" + f"Repository philosophy:\n{serialize_philosophy_for_prompt(philosophy, 3000)}\n\n" f"Decision request: {request}\n\n" 'Return JSON: {"verdict": "one short sentence", "reasoning": "why"}' ) @@ -314,7 +315,7 @@ def decide(context: dict, philosophy: dict, request: str, llm) -> dict: for name, verdict in lenses.items() ) user = ( - f"Repository philosophy:\n{json.dumps(philosophy, indent=1)[:3000]}\n\n" + f"Repository philosophy:\n{serialize_philosophy_for_prompt(philosophy, 3000)}\n\n" f"Repository state:\n{_render(context)}\n" f"{_release_context_note(context)}" f"{_planning_version_bump_note(context, request)}" diff --git a/agent/philosophy.py b/agent/philosophy.py index 38886f37..bcfe00de 100644 --- a/agent/philosophy.py +++ b/agent/philosophy.py @@ -133,3 +133,28 @@ def _render(context: dict) -> str: "labels", "milestones", "releases", "readme_excerpt", )} return json.dumps(keep, indent=1)[:12000] + + +def serialize_philosophy_for_prompt(philosophy: dict, budget: int) -> str: + """Serialize ``philosophy`` for agent prompts without breaking JSON syntax. + + When the full ``json.dumps(..., indent=1)`` fits ``budget``, the result is + byte-identical. Otherwise whole trailing ``evidence`` entries are dropped so + ``summary``/``values``/``merge_bar``/``direction`` survive as valid JSON. A raw + string slice is used only when the object still cannot fit with ``evidence`` + empty — the same last-resort behavior as before this helper existed. + """ + full = json.dumps(philosophy, indent=1) + if len(full) <= budget: + return full + + if isinstance(philosophy, dict) and isinstance(philosophy.get("evidence"), list): + evidence = list(philosophy["evidence"]) + while evidence: + evidence.pop() + trimmed = {**philosophy, "evidence": evidence} + candidate = json.dumps(trimmed, indent=1) + if len(candidate) <= budget: + return candidate + + return full[:budget] diff --git a/agent/planner.py b/agent/planner.py index b14d7e00..1b19393d 100644 --- a/agent/planner.py +++ b/agent/planner.py @@ -12,6 +12,7 @@ from datetime import datetime, timezone from agent.context import context_for_agent +from agent.philosophy import serialize_philosophy_for_prompt logger = logging.getLogger(__name__) @@ -1098,7 +1099,7 @@ def plan_next_actions(context: dict, philosophy: dict, n: int, llm) -> list: if not isinstance(context, dict): return _offline_plan_stub({}, n) user = ( - f"Repository philosophy:\n{json.dumps(philosophy, indent=1)[:4000]}\n\n" + f"Repository philosophy:\n{serialize_philosophy_for_prompt(philosophy, 4000)}\n\n" f"Repository state:\n{_render(context)}\n" f"{_repo_layout_note(context)}" f"{_recent_kinds_note(context)}" diff --git a/tests/test_philosophy.py b/tests/test_philosophy.py index 43b50fd1..47d8be61 100644 --- a/tests/test_philosophy.py +++ b/tests/test_philosophy.py @@ -21,6 +21,7 @@ _normalize_string_list, _normalize_text, infer_philosophy, + serialize_philosophy_for_prompt, ) EXPECTED_KEYS = {"summary", "values", "merge_bar", "direction", "evidence"} @@ -137,3 +138,64 @@ def test_infer_philosophy_non_dict_context_returns_fresh_copy(): b = infer_philosophy(None, llm) a["summary"] = "mutated" assert b["summary"] != "mutated" + + +def _oversized_philosophy(num_evidence: int = 13) -> dict: + """Shape sized like the #1706 instrumented run (4,237 chars full serialization).""" + return { + "summary": "A mature library prioritizing stability and a small dependency surface.", + "values": ["conservative", "stability-over-features", "docs-first"], + "merge_bar": ( + "Merges well-justified fixes and incremental hardening; rejects churn, new " + "dependencies without payoff, and breaking changes outside a deprecation window." + ), + "direction": "Incremental hardening on the current major line with careful API evolution.", + "evidence": [f"signal {i}: " + ("x" * 300) for i in range(num_evidence)], + } + + +def test_serialize_philosophy_for_prompt_is_byte_identical_when_it_fits(): + philosophy = {"summary": "ship fast", "values": ["feature-first"], "merge_bar": "low", + "direction": "expand", "evidence": ["recent feature flags"]} + rendered = serialize_philosophy_for_prompt(philosophy, 4000) + assert rendered == json.dumps(philosophy, indent=1) + + +def test_serialize_philosophy_for_prompt_drops_trailing_evidence_to_stay_valid_json(): + philosophy = _oversized_philosophy() + full = json.dumps(philosophy, indent=1) + assert len(full) > 4000 + + for budget in (4000, 3000): + rendered = serialize_philosophy_for_prompt(philosophy, budget) + assert len(rendered) <= budget + decoded = json.loads(rendered) + assert decoded["summary"] == philosophy["summary"] + assert decoded["values"] == philosophy["values"] + assert decoded["merge_bar"] == philosophy["merge_bar"] + assert decoded["direction"] == philosophy["direction"] + assert isinstance(decoded["evidence"], list) + assert decoded["evidence"] == philosophy["evidence"][: len(decoded["evidence"])] + if len(full) > budget: + assert len(decoded["evidence"]) < len(philosophy["evidence"]) + + +def test_serialize_philosophy_for_prompt_hard_slices_when_core_fields_exceed_budget(): + philosophy = { + "summary": "p" * 5000, + "values": [], + "merge_bar": "high", + "direction": "forward", + "evidence": [], + } + full = json.dumps(philosophy, indent=1) + rendered = serialize_philosophy_for_prompt(philosophy, 3000) + assert rendered == full[:3000] + assert len(rendered) == 3000 + + +def test_serialize_philosophy_for_prompt_does_not_mutate_input(): + philosophy = _oversized_philosophy() + original_evidence = list(philosophy["evidence"]) + serialize_philosophy_for_prompt(philosophy, 3000) + assert philosophy["evidence"] == original_evidence