Skip to content

Commit 3bc3573

Browse files
Merge pull request #177 from EvanProgramming/codex/issue-171-deepseek-controls
fix: normalize DeepSeek controls and sanitize streams
2 parents e782158 + a04849a commit 3bc3573

7 files changed

Lines changed: 113 additions & 9 deletions

File tree

‎interaction.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,53 @@ def parse_control_block(text: str, name: str) -> Any | None:
182182
raise InteractionError(f"{name} contains invalid JSON: {exc.msg}") from exc
183183

184184

185+
def normalize_provider_control(text: str) -> tuple[str, dict[str, Any]] | None:
186+
"""Map a bounded provider JSON shape to one non-executable control."""
187+
source = str(text or "").strip()
188+
fenced = re.fullmatch(r"```(?:json)?\s*([\s\S]*?)\s*```", source, re.IGNORECASE)
189+
if fenced:
190+
source = fenced.group(1).strip()
191+
try:
192+
value = json.loads(source)
193+
except json.JSONDecodeError:
194+
return None
195+
if not isinstance(value, dict):
196+
return None
197+
198+
executable_keys = {"action", "args", "arguments", "command", "tool", "tool_calls"}
199+
stack: list[Any] = [value]
200+
while stack:
201+
item = stack.pop()
202+
if isinstance(item, dict):
203+
if executable_keys & {str(key).lower() for key in item}:
204+
return None
205+
stack.extend(item.values())
206+
elif isinstance(item, list):
207+
stack.extend(item)
208+
209+
mode = str(value.get("mode") or "").strip().lower()
210+
if mode == "plan" or value.get("do_not_execute_until_approved") is True:
211+
steps = []
212+
for index, step in enumerate(value.get("steps") or [], 1):
213+
if not isinstance(step, dict):
214+
return None
215+
steps.append({
216+
"id": str(step.get("id") or step.get("step_id") or f"step-{step.get('step', index)}"),
217+
"title": step.get("title") or f"Step {index}",
218+
"description": step.get("description") or step.get("details"),
219+
"acceptance": step.get("acceptance") or step.get("acceptance_criteria"),
220+
})
221+
return "PlanProposal", {
222+
"title": value.get("title") or value.get("plan_name"),
223+
"summary": value.get("summary") or value.get("overview"),
224+
"assumptions": value.get("assumptions") or [],
225+
"steps": steps,
226+
}
227+
if mode == "ask" or value.get("ask_user") is True:
228+
return "AskUser", {"questions": value.get("questions")}
229+
return None
230+
231+
185232
def route_mode(preference: str, user_input: str, *, pending_plan: bool = False,
186233
executing_plan: bool = False) -> str:
187234
if executing_plan:

‎main.py‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ def _terminal_supports_unicode() -> bool:
105105
from event_store import stable_hash, utc_now
106106
from interaction import (
107107
InteractionController, InteractionError, is_plan_acceptance, mode_capabilities,
108-
parse_control_block, render_plan, render_question, validate_plan_proposal,
108+
normalize_provider_control, parse_control_block, render_plan, render_question, validate_plan_proposal,
109109
validate_question_request,
110110
)
111111
from learning_engine import LearningEngine
@@ -4357,14 +4357,17 @@ class DeepSeekDSMLFilter:
43574357
r"(?i)(?<![\w])(?P<kind>Action|Thought|Plan|TaskList|TaskDone|DefineTool)\s*:"
43584358
)
43594359
_GENERIC_OPEN_RE = re.compile(
4360-
r"<\s*(?P<kind>invoke|parameter|calls|tool_calls|function_calls)\b[^>]*>",
4360+
r"<\s*(?P<kind>invoke|parameter|calls|tool_calls|function_calls|tool_use|notes|thought|reasoning)\b[^>]*>",
43614361
re.IGNORECASE,
43624362
)
43634363
_GENERIC_CLOSE_RE = re.compile(
4364-
r"</\s*(?P<kind>invoke|parameter|calls|tool_calls|function_calls)\s*>",
4364+
r"</\s*(?P<kind>invoke|parameter|calls|tool_calls|function_calls|tool_use|notes|thought|reasoning)\s*>",
43654365
re.IGNORECASE,
43664366
)
4367-
_GENERIC_KINDS = ("invoke", "parameter", "calls", "tool_calls", "function_calls")
4367+
_GENERIC_KINDS = (
4368+
"invoke", "parameter", "calls", "tool_calls", "function_calls",
4369+
"tool_use", "notes", "thought", "reasoning",
4370+
)
43684371
_ACTION_MARKER_RE = _ACTION_MARKER_RE
43694372
_CONTROL_PREFIXES = tuple(
43704373
item[:length].lower()
@@ -4494,7 +4497,10 @@ def _action_end(cls, value: str, start: int, *, final: bool) -> int | None:
44944497
@classmethod
44954498
def _generic_block_end(cls, value: str, match: re.Match[str]) -> int | None:
44964499
kind = match.group("kind").lower()
4497-
close_kinds = "parameter" if kind == "parameter" else "invoke|calls|tool_calls|function_calls"
4500+
close_kinds = (
4501+
"parameter" if kind == "parameter" else
4502+
"invoke|calls|tool_calls|function_calls|tool_use|notes|thought|reasoning"
4503+
)
44984504
close = re.compile(rf"</\s*(?:{close_kinds})\s*>", re.IGNORECASE).search(value, match.end())
44994505
return close.end() if close else None
45004506

@@ -4760,6 +4766,12 @@ def _parse_model_response(text: str) -> dict[str, Any]:
47604766
try:
47614767
question_value = parse_control_block(raw, "AskUser")
47624768
plan_value = parse_control_block(raw, "PlanProposal")
4769+
if question_value is None and plan_value is None:
4770+
normalized = normalize_provider_control(raw)
4771+
if normalized is not None:
4772+
name, value = normalized
4773+
question_value = value if name == "AskUser" else None
4774+
plan_value = value if name == "PlanProposal" else None
47634775
if question_value is not None:
47644776
question = validate_question_request(question_value)
47654777
if plan_value is not None:

‎server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1127,7 +1127,8 @@ class StreamProjection:
11271127
"""Pass plain deltas through while holding model control prefixes."""
11281128

11291129
prefixes = ("Thought:", "Plan:", "TaskList:", "TaskDone:", "Action:", "DefineTool:",
1130-
"AskUser:", "AskUser\n", "PlanProposal:", "PlanProposal\n")
1130+
"AskUser:", "AskUser\n", "PlanProposal:", "PlanProposal\n",
1131+
"{", "[", "```json")
11311132

11321133
def __init__(self, sink):
11331134
self.sink = sink
@@ -1163,7 +1164,9 @@ def _flush(self) -> None:
11631164
return
11641165
text = self.buffer
11651166
self.buffer = ""
1166-
if _agent._collect_tool_calls(text) or any(
1167+
if _agent.normalize_provider_control(text):
1168+
text = ""
1169+
elif _agent._collect_tool_calls(text) or any(
11671170
text.lstrip().lower().startswith(prefix.lower()) for prefix in self.prefixes
11681171
):
11691172
text = _agent._clean_final_response(text)

‎tests/test_interaction.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import tempfile
23
import unittest
34
from pathlib import Path
@@ -168,6 +169,27 @@ def test_exact_acceptance_phrases_and_control_parser(self):
168169
'PlanProposal\n```json\n{"steps": []}\n```', "PlanProposal",
169170
), {"steps": []})
170171

172+
def test_provider_shaped_plan_is_normalized_but_actions_are_not(self):
173+
shaped = json.dumps({
174+
"plan_name": "Status page", "mode": "plan",
175+
"do_not_execute_until_approved": True,
176+
"overview": "Build after approval.", "assumptions": ["Python exists"],
177+
"steps": [{"step": 1, "title": "Create page", "details": "Write index.html.",
178+
"acceptance_criteria": ["index.html exists"]}],
179+
})
180+
parsed = main._observe_model_response(shaped)
181+
self.assertEqual(parsed["plan_proposal"]["title"], "Status page")
182+
self.assertEqual(parsed["plan_proposal"]["steps"][0]["id"], "step-1")
183+
self.assertEqual(parsed["tool_calls"], [])
184+
185+
executable = json.dumps({
186+
"mode": "plan", "plan_name": "Unsafe", "overview": "Write now.",
187+
"steps": [{"title": "Write", "details": "Write README.",
188+
"acceptance_criteria": ["written"], "action": "write_file"}],
189+
})
190+
rejected = main._observe_model_response(executable)
191+
self.assertIsNone(rejected["plan_proposal"])
192+
171193
def test_control_blocks_combined_with_actions_fail_closed(self):
172194
parsed = main._observe_model_response(
173195
'AskUser:\n```json\n{"questions":[{"id":"scope","header":"Scope",'

‎tests/test_streaming.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,18 @@ def chat_stream(self, _messages, _model=None):
122122

123123

124124
class StreamingEndpointTests(unittest.TestCase):
125+
def test_reasoning_and_tool_use_tags_are_filtered_at_every_boundary(self):
126+
marker = (
127+
"<notes><thought>private</thought>"
128+
"<tool_use><read_file><path>README.md</path></read_file></tool_use></notes>"
129+
)
130+
expected = "Visible answer."
131+
for split in range(1, len(marker)):
132+
stream_filter = main.DeepSeekDSMLFilter()
133+
content = stream_filter.feed("Visible " + marker[:split])
134+
content += stream_filter.feed(marker[split:] + " answer.", final=True)
135+
self.assertEqual(content, expected, split)
136+
125137
def test_first_sse_content_arrives_before_provider_stream_completes(self):
126138
session_id = "stream-timing-regression"
127139
provider = _DelayedProvider()

‎tests/test_tui_protocol.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ def test_stream_projection_preserves_event_order_and_redacts_receipts(self):
7272
self.assertEqual([event["event"] for event in events], ["stream_delta", "tool_receipt", "tasks"])
7373
self.assertEqual(events[0]["request_id"], "turn-1")
7474

75+
def test_provider_shaped_plan_json_is_not_streamed_as_chat_text(self):
76+
callback = self.backend._stream_projection("turn-plan")
77+
callback({"event": "content", "chunk": '{"mode":"plan","plan_name":"Safe",'})
78+
callback({"event": "content", "chunk": '"overview":"Wait","steps":[]}'})
79+
callback({"event": "model_complete"})
80+
self.assertEqual(self.output.getvalue(), "")
81+
7582
def test_provider_setup_emits_ready_and_masks_key_flow(self):
7683
config = tui_backend.agent.ProviderConfig(provider="deepseek")
7784
with patch.object(tui_backend.agent, "_provider_config", config), \

‎tui_backend.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,8 @@ def _stream_projection(self, request_id: str):
274274
dsml = agent.DeepSeekDSMLFilter()
275275
buffer = ""
276276
prefixes = ("action:", "tasklist:", "taskdone:", "thought:", "plan:", "definetool:",
277-
"askuser:", "askuser\n", "planproposal:", "planproposal\n", "<")
277+
"askuser:", "askuser\n", "planproposal:", "planproposal\n", "<",
278+
"{", "[", "```json")
278279

279280
def emit_text(text: str) -> None:
280281
nonlocal buffer
@@ -295,7 +296,7 @@ def callback(event: dict[str, Any]) -> None:
295296
elif kind == "model_complete":
296297
emit_text(dsml.feed("", final=True))
297298
if buffer:
298-
cleaned = agent._clean_final_response(buffer)
299+
cleaned = "" if agent.normalize_provider_control(buffer) else agent._clean_final_response(buffer)
299300
if cleaned:
300301
self.emit("stream_delta", request_id, text=_redact(cleaned))
301302
buffer = ""

0 commit comments

Comments
 (0)