Efren Yevale Varela 1 ヶ月 前
親
コミット
ce4411872c
2 ファイル変更、108 行追加、13 行削除
  1. 56 13
      src/stt_daemon/assistant/triage.py
  2. 52 0
      tests/test_triage.py

+ 56 - 13
src/stt_daemon/assistant/triage.py

@@ -379,11 +379,52 @@ class TriageAnalyzer:
         parts.append("Return only the updated brief as plain text.")
         return "\n\n".join(parts)
 
-    def _unwrap_tool_call(self, text: str) -> str:
-        """Unwrap a stray model tool-call envelope into its plain brief payload.
+    @staticmethod
+    def _first_arg_start(text: str) -> int:
+        name_at = text.find("updated_brief")
+        if name_at != -1:
+            cursor = name_at + len("updated_brief")
+            while cursor < len(text) and text[cursor] == " ":
+                cursor += 1
+            if cursor < len(text) and text[cursor] == "=":
+                return cursor + 1
+        call_at = text.find("update_brief(")
+        if call_at == -1:
+            call_at = text.find("update_brief (")
+        return call_at + len("update_brief(") if call_at != -1 else 0
+
+    @staticmethod
+    def _first_quote(text: str, start: int) -> tuple[int, str]:
+        opening = -1
+        quote = "'"
+        for candidate in ("'", '"'):
+            index = text.find(candidate, start)
+            if index != -1 and (opening == -1 or index < opening):
+                opening, quote = index, candidate
+        return opening, quote
+
+    @staticmethod
+    def _arg_boundary_quote(text: str, opening: int, quote: str) -> int:
+        index = opening + 1
+        while True:
+            candidate = text.find(quote, index)
+            if candidate == -1:
+                return -1
+            after = candidate + 1
+            while after < len(text) and text[after] == " ":
+                after += 1
+            if after >= len(text) or text[after] in ",)]<":
+                return candidate
+            index = candidate + 1
+
+    def _unwrap_tool_call(self, text: str) -> str | None:
+        """Unwrap a stray tool-call envelope into its first plain argument value.
 
         Only fires when an explicit wrapper marker is present so clean markdown
-        containing ``[``, ``(``, or ``'`` is passed through untouched.
+        containing ``[``, ``(``, or ``'`` is passed through untouched. The first
+        argument is taken up to its closing quote boundary, so a multi-argument
+        call never swallows the separators; an envelope that cannot be reduced to
+        a clean payload is rejected (None) rather than stored verbatim.
         """
         markers = ("<|tool_call_start|>", "<|tool_call_end|>", "update_brief(", "update_brief (")
         if not any(marker in text for marker in markers):
@@ -394,17 +435,19 @@ class TriageAnalyzer:
             text = text.split("<|tool_call_end|>", 1)[0]
         text = text.strip()
         if "update_brief(" not in text and "update_brief (" not in text:
+            if any(token in text for token in ("<|", "previous_brief=", "mode=")):
+                return None
             return text
-        # Extract the argument between the first and last matching quote. The
-        # payload itself may contain apostrophes, so pair on the LAST quote of
-        # whichever delimiter opens first.
-        positions = [(text.find(quote), quote) for quote in ("'", '"') if text.find(quote) != -1]
-        if positions:
-            first, quote = min(positions)
-            last = text.rfind(quote)
-            if last > first:
-                text = text[first + 1 : last]
-        return text.strip()
+        opening, quote = self._first_quote(text, self._first_arg_start(text))
+        if opening == -1:
+            return None
+        closing = self._arg_boundary_quote(text, opening, quote)
+        if closing == -1:
+            return None
+        value = text[opening + 1 : closing].strip()
+        if any(token in value for token in ("<|", "previous_brief=", "mode=")):
+            return None
+        return value
 
     def parse_brief(self, text) -> str | None:
         """Strip a code fence and any stray tool-call envelope; None when empty."""

+ 52 - 0
tests/test_triage.py

@@ -471,6 +471,58 @@ def test_parse_brief_empty_tool_call_payload_is_none(tmp_path):
     assert analyzer.parse_brief("[update_brief(updated_brief='')]") is None
 
 
+def test_parse_brief_unwraps_multi_arg_tool_call(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = (
+        "[update_brief(updated_brief='## Opportunities\n- ship Friday', "
+        "previous_brief='## old\n- y', mode='read')]"
+    )
+    assert analyzer.parse_brief(wrapped) == "## Opportunities\n- ship Friday"
+
+
+def test_parse_brief_unwraps_multi_arg_with_markers(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = (
+        "<|tool_call_start|>[update_brief(updated_brief='## Opportunities\n- ship Friday', "
+        "previous_brief='## old\n- y', mode='read')]<|tool_call_end|>"
+    )
+    assert analyzer.parse_brief(wrapped) == "## Opportunities\n- ship Friday"
+
+
+def test_parse_brief_unwraps_multi_arg_apostrophes_preserved(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    wrapped = (
+        "[update_brief(updated_brief='the company's game failed', "
+        "previous_brief='## old', mode='read')]"
+    )
+    assert analyzer.parse_brief(wrapped) == "the company's game failed"
+
+
+def test_parse_brief_unwraps_positional_tool_call(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_brief("[update_brief('payload here', mode='read')]") == "payload here"
+
+
+def test_parse_brief_rejects_polluted_tool_call(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    polluted = "[update_brief(updated_brief='oops, previous_brief=', mode='read')]"
+    assert analyzer.parse_brief(polluted) is None
+    no_arg = "<|tool_call_start|>[update_brief(previous_brief=old, mode=read)]"
+    assert analyzer.parse_brief(no_arg) is None
+
+
+def test_parse_brief_clean_mode_text_not_mangled(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    clean = "Set mode=read, then read [the brief] - it's fine (really)."
+    assert analyzer.parse_brief(clean) == clean
+
+
+def test_parse_brief_rejects_marker_with_artifacts_but_no_call(tmp_path):
+    analyzer = _analyzer(tmp_path, _score_questions())
+    assert analyzer.parse_brief("<|tool_call_start|>previous_brief='x', mode='read'") is None
+    assert analyzer.parse_brief("<|tool_call_start|>mode='read'") is None
+
+
 def _brief_signals(p=None):
     item = {"kind": "noul"}
     if p is not None: