Skip to content

Commit 312465e

Browse files
Merge pull request #199 from EvanProgramming/codex/issue-198-plan-action-repair
fix: recover plan after rejected provider action
2 parents 16f21d1 + b0cad8c commit 312465e

3 files changed

Lines changed: 63 additions & 5 deletions

File tree

‎docs/self-evolution.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ diagnostic only and is not treated as a release claim.
238238

239239
## Verification snapshot commands
240240

241-
Current repository test count at this snapshot: **241 unittest cases**.
241+
Current repository test count at this snapshot: **242 unittest cases**.
242242

243243
The post-#54 snapshot ran the repository's current checks and smoke coverage:
244244

@@ -275,7 +275,7 @@ git diff --check
275275
```
276276

277277
The historical post-#54 verification passed the 130 discovered tests; the current
278-
repository contains 241 discovered tests. The historical run also covered the API
278+
repository contains 242 discovered tests. The historical run also covered the API
279279
health/scoping smoke, the CLI command-loop smoke, and the five-case
280280
clean/evolved benchmark described above. `make check` reports the live 39-tool
281281
runtime inventory, including 14 `git_` tools. A new artifact is not immediate:

‎main.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5165,9 +5165,12 @@ def observe_turn_response(text: str, context: list[dict[str, str]]) -> tuple[str
51655165
"""Parse once, with one side-effect-free repair for malformed interaction JSON."""
51665166
nonlocal turn_prompt_total, turn_completion_total
51675167
parsed = _observe_model_response(text)
5168-
if not parsed.get("protocol_error") or not re.search(
5169-
r"^[ \t]*(?:AskUser|PlanProposal)(?![\w])[ \t]*:?", str(text),
5170-
re.IGNORECASE | re.MULTILINE):
5168+
has_control_marker = re.search(
5169+
r"^[ \t]*(?:AskUser|PlanProposal)(?![\w])[ \t]*:?", str(text),
5170+
re.IGNORECASE | re.MULTILINE,
5171+
)
5172+
if not parsed.get("protocol_error") or (
5173+
not has_control_marker and _active_interaction_mode.get() != "plan"):
51715174
return text, parsed
51725175
question_control = bool(re.search(r"^[ \t]*AskUser(?![\w])", str(text), re.IGNORECASE | re.MULTILINE))
51735176
example = (
@@ -5202,6 +5205,8 @@ def observe_turn_response(text: str, context: list[dict[str, str]]) -> tuple[str
52025205
candidate.get("question") is not None or candidate.get("plan_proposal") is not None):
52035206
candidate["tool_calls"] = []
52045207
candidate["protocol_error"] = "The model could not produce a valid interaction control; no action was executed."
5208+
candidate["define_tool_present"] = False
5209+
candidate["define_tool_registered"] = False
52055210
return repaired, candidate
52065211

52075212
MAX_RETRIES = 3

‎tests/test_interaction.py‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,59 @@ def test_plan_mode_converts_plain_plan_and_discards_early_actions(self):
262262
main._interaction_controller = previous_controller
263263
main._active_interaction_mode.reset(mode_token)
264264

265+
def test_plan_mode_repairs_rejected_action_into_proposal(self):
266+
class LearningStub:
267+
def feedback_signal(self, _text): return None
268+
def route_profile(self, _text, _profile=None): return "coder"
269+
def begin_run(self, profile, _task, provider_model=None):
270+
return {"run_id": "plan-repair", "profile": profile, "provider_model": provider_model}
271+
def artifact_context(self, _run): return "", []
272+
273+
class RuntimeStub:
274+
def turn_start(self, **_kwargs): return None
275+
def turn_end(self, **_kwargs): return None
276+
277+
original = (
278+
main.tasks, main._interaction_controller, main.learning_engine,
279+
main._get_workspace_root(), main._execution_capability_token,
280+
)
281+
with tempfile.TemporaryDirectory() as directory:
282+
root = Path(directory)
283+
store = EventStore(root / "state.sqlite3")
284+
main.tasks = TaskManager(store, workspace_id="repair", session_id="repair")
285+
main._interaction_controller = InteractionController(
286+
store, workspace_id="repair", session_id="repair",
287+
)
288+
main._interaction_controller.set_mode("plan")
289+
main.learning_engine = LearningStub()
290+
main._set_workspace_root(root)
291+
responses = [
292+
'Action: {"action":"write_file","args":"unsafe.txt|bad"}',
293+
'PlanProposal:\n```json\n{"title":"Safe plan","summary":"Write only after approval.",'
294+
'"assumptions":[],"steps":[{"id":"write","title":"Write file",'
295+
'"description":"Create the requested file.","acceptance":["File exists"]}]}\n```',
296+
]
297+
try:
298+
with patch.object(main, "_plugin_runtime_for_surface", return_value=RuntimeStub()), \
299+
patch.object(main, "_touch_detached_learning_heartbeat"), \
300+
patch.object(main, "dispatch_learning_cycle"), \
301+
patch.object(main, "_summarize_old_turns"), \
302+
patch.object(main, "_build_messages", return_value=[]), \
303+
patch.object(main, "_classify_complexity", return_value="simple"), \
304+
patch.object(main, "_call_llm_with_spinner", side_effect=responses), \
305+
patch.object(main, "_finish_learning_run", side_effect=lambda run, receipts, task,
306+
result, records, tokens, started: result):
307+
reply = main._chat_turn("Create the file")
308+
self.assertIn("Safe plan (v1)", reply)
309+
self.assertFalse((root / "unsafe.txt").exists())
310+
self.assertEqual(
311+
main._interaction_controller.state()["pending_plan"]["title"], "Safe plan",
312+
)
313+
finally:
314+
(main.tasks, main._interaction_controller, main.learning_engine,
315+
previous_root, main._execution_capability_token) = original
316+
main._set_workspace_root(previous_root)
317+
265318
def test_control_blocks_combined_with_actions_fail_closed(self):
266319
parsed = main._observe_model_response(
267320
'AskUser:\n```json\n{"questions":[{"id":"scope","header":"Scope",'

0 commit comments

Comments
 (0)