@@ -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+
47584787def _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
0 commit comments