Skip to content

Commit 793448d

Browse files
fix: stop plan-mode execution loops
1 parent c9f837d commit 793448d

3 files changed

Lines changed: 78 additions & 2 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: **239 unittest cases**.
241+
Current repository test count at this snapshot: **240 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 239 discovered tests. The historical run also covered the API
278+
repository contains 240 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: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4755,6 +4755,35 @@ def _remove_task_blocks(text: str) -> str:
47554755
return _clean_final_response(text)
47564756

47574757

4758+
def _legacy_plan_value(text: str) -> dict[str, Any] | None:
4759+
"""Convert a bounded numbered Plan block into a proposal value."""
4760+
match = re.search(
4761+
r"^[ \t]*Plan:[ \t]*\n(?P<body>[\s\S]*?)(?=^[ \t]*(?:Action|TaskList|TaskDone|DefineTool):|\Z)",
4762+
str(text or ""), re.IGNORECASE | re.MULTILINE,
4763+
)
4764+
if not match:
4765+
return None
4766+
steps = []
4767+
for line in match.group("body").splitlines():
4768+
item = re.match(r"^[ \t]*(?:\d+[.)]|[-*])[ \t]+(.+?)\s*$", line)
4769+
if item:
4770+
description = item.group(1).strip()
4771+
steps.append({
4772+
"id": f"step-{len(steps) + 1}",
4773+
"title": description[:120],
4774+
"description": description,
4775+
"acceptance": ["Step completed with observable evidence"],
4776+
})
4777+
if not 1 <= len(steps) <= 10:
4778+
return None
4779+
return {
4780+
"title": steps[0]["title"],
4781+
"summary": "Review and approve these steps before execution.",
4782+
"assumptions": [],
4783+
"steps": steps,
4784+
}
4785+
4786+
47584787
def _parse_model_response(text: str) -> dict[str, Any]:
47594788
"""Parse one model response exactly once for the turn state machine."""
47604789
raw = str(text or "").strip()
@@ -4772,6 +4801,12 @@ def _parse_model_response(text: str) -> dict[str, Any]:
47724801
name, value = normalized
47734802
question_value = value if name == "AskUser" else None
47744803
plan_value = value if name == "PlanProposal" else None
4804+
if (question_value is None and plan_value is None and tool_calls
4805+
and _active_interaction_mode.get() == "plan"):
4806+
plan_value = _legacy_plan_value(raw)
4807+
if plan_value is not None:
4808+
# A usable plan wins over provider attempts to execute it early.
4809+
tool_calls = []
47754810
if question_value is not None:
47764811
question = validate_question_request(question_value)
47774812
if plan_value is not None:
@@ -4784,6 +4819,13 @@ def _parse_model_response(text: str) -> dict[str, Any]:
47844819
or bool(re.search(r"^[ \t]*(?:TaskList|TaskDone|DefineTool):", raw, re.IGNORECASE | re.MULTILINE))
47854820
or (question is not None and plan_proposal is not None)
47864821
)
4822+
disallowed_plan_action = (
4823+
_active_interaction_mode.get() == "plan"
4824+
and any(tool_capability(_operation_action(call.get("action", ""))) not in {"read", "network"}
4825+
for call in tool_calls)
4826+
)
4827+
if disallowed_plan_action:
4828+
tool_calls = []
47874829
return {
47884830
"raw": raw,
47894831
"clean": _clean_final_response(raw),
@@ -4797,6 +4839,8 @@ def _parse_model_response(text: str) -> dict[str, Any]:
47974839
"AskUser and PlanProposal must be the only control block in a response. "
47984840
"No tool or task action was executed."
47994841
if combined_control else
4842+
"Plan mode rejected an executable action; no action was executed. Retry with one PlanProposal block."
4843+
if disallowed_plan_action else
48004844
"The model returned an incomplete or ambiguous unwrapped tool call. "
48014845
"No tool was executed. Retry with one complete structured tool call."
48024846
if malformed_unwrapped else None

‎tests/test_interaction.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,38 @@ def test_provider_shaped_plan_is_normalized_but_actions_are_not(self):
190190
rejected = main._observe_model_response(executable)
191191
self.assertIsNone(rejected["plan_proposal"])
192192

193+
def test_plan_mode_converts_plain_plan_and_discards_early_actions(self):
194+
mode_token = main._active_interaction_mode.set("plan")
195+
previous_controller = main._interaction_controller
196+
main._interaction_controller = self.controller
197+
try:
198+
parsed = main._parse_model_response(
199+
"Plan:\n"
200+
"1. Create index.html with the requested heading.\n"
201+
"2. Serve it locally and verify HTTP 200.\n"
202+
'Action: {"action":"write_file","args":"index.html|unsafe"}'
203+
)
204+
self.assertEqual(parsed["tool_calls"], [])
205+
self.assertIsNone(parsed["protocol_error"])
206+
rendered = main._interaction_gate(parsed, "Create a local page")
207+
self.assertIn("Create index.html", rendered)
208+
self.assertEqual(len(self.controller.state()["pending_plan"]["steps"]), 2)
209+
self.assertEqual(
210+
self.store.list_events(
211+
"execution.receipt", workspace_id="workspace", session_id="session",
212+
),
213+
[],
214+
)
215+
216+
action_only = main._parse_model_response(
217+
'Action: {"action":"run_cmd","args":"python -m http.server"}'
218+
)
219+
self.assertEqual(action_only["tool_calls"], [])
220+
self.assertIn("rejected an executable action", action_only["protocol_error"])
221+
finally:
222+
main._interaction_controller = previous_controller
223+
main._active_interaction_mode.reset(mode_token)
224+
193225
def test_control_blocks_combined_with_actions_fail_closed(self):
194226
parsed = main._observe_model_response(
195227
'AskUser:\n```json\n{"questions":[{"id":"scope","header":"Scope",'

0 commit comments

Comments
 (0)