unswallow 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: unswallow
3
+ Version: 0.1.0
4
+ Summary: Detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp. 1:1 mirror of the TypeScript library.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/0DukePan/unswallow
7
+ Project-URL: Repository, https://github.com/0DukePan/unswallow
8
+ Project-URL: Issues, https://github.com/0DukePan/unswallow/issues
9
+ Keywords: llm,agents,tool-calls,tool-use,function-calling,reasoning,thinking,vllm,sglang,llama.cpp,qwen3,deepseek,openai-compatible
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # unswallow (Python)
18
+
19
+ The 1:1 Python mirror of the `unswallow` TypeScript library: detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp.
20
+
21
+ ```bash
22
+ pip install unswallow
23
+ ```
24
+
25
+ ```python
26
+ from unswallow import check_and_rescue
27
+
28
+ result = check_and_rescue(
29
+ raw_provider_response,
30
+ engine_hint="vllm",
31
+ engine_version="0.19.0",
32
+ tool_schemas=my_tools,
33
+ )
34
+
35
+ if result.recovered and result.recovered_response:
36
+ return result.recovered_response # tool_calls is populated, finish_reason is "tool_calls"
37
+ ```
38
+
39
+ Same interface, same semantics, same bundled matrix data (synced from `packages/matrix/data/engine-matrix.json`), zero runtime dependencies, Python 3.9+.
40
+
41
+ ## Streaming
42
+
43
+ ```python
44
+ from unswallow import check_and_rescue_stream
45
+
46
+ result = await check_and_rescue_stream(
47
+ chunk_iterable, # AsyncIterable of OpenAI-compatible SSE chunks
48
+ engine_hint="vllm",
49
+ engine_version="0.19.0",
50
+ on_leak=logger.warning, # live pattern-C signal, optional
51
+ )
52
+ ```
53
+
54
+ Or accumulate manually with `StreamAccumulator` (`.push(chunk)` / `.end()`).
55
+
56
+ ## History hygiene (Pattern D)
57
+
58
+ ```python
59
+ from unswallow import sanitize_history, strip_reasoning_tags
60
+
61
+ clean = sanitize_history(messages)
62
+ text = strip_reasoning_tags(assistant_text) # "< thinking>\nplan\n< response>\nanswer" -> "answer"
63
+ ```
64
+
65
+ ## CLI
66
+
67
+ ```bash
68
+ unswallow check # bundled self-test demo (vLLM #39056)
69
+ unswallow check --fixture response.json # captured raw response
70
+ unswallow check --endpoint http://localhost:8000/v1 --model Qwen/Qwen3.5-35B-A3B-FP8 --engine vllm --version 0.19.0
71
+ unswallow matrix
72
+ ```
73
+
74
+ Exit codes for live probes: `0` = not affected, `1` = affected, `2` = error.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ cd packages/python
80
+ python -m unittest discover -s tests -v # stdlib-only test suite
81
+ ```
82
+
83
+ The engine matrix is bundled as data — after editing `packages/matrix/data/engine-matrix.json`, run `python packages/python/scripts/sync_matrix.py` from the repo root. CI verifies the copies stay identical.
84
+
85
+ Everything else — patterns, confidence scoring, the false-positive guard, the benchmark corpus and the engine matrix — is documented in the repository root [README](../../README.md).
@@ -0,0 +1,69 @@
1
+ # unswallow (Python)
2
+
3
+ The 1:1 Python mirror of the `unswallow` TypeScript library: detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp.
4
+
5
+ ```bash
6
+ pip install unswallow
7
+ ```
8
+
9
+ ```python
10
+ from unswallow import check_and_rescue
11
+
12
+ result = check_and_rescue(
13
+ raw_provider_response,
14
+ engine_hint="vllm",
15
+ engine_version="0.19.0",
16
+ tool_schemas=my_tools,
17
+ )
18
+
19
+ if result.recovered and result.recovered_response:
20
+ return result.recovered_response # tool_calls is populated, finish_reason is "tool_calls"
21
+ ```
22
+
23
+ Same interface, same semantics, same bundled matrix data (synced from `packages/matrix/data/engine-matrix.json`), zero runtime dependencies, Python 3.9+.
24
+
25
+ ## Streaming
26
+
27
+ ```python
28
+ from unswallow import check_and_rescue_stream
29
+
30
+ result = await check_and_rescue_stream(
31
+ chunk_iterable, # AsyncIterable of OpenAI-compatible SSE chunks
32
+ engine_hint="vllm",
33
+ engine_version="0.19.0",
34
+ on_leak=logger.warning, # live pattern-C signal, optional
35
+ )
36
+ ```
37
+
38
+ Or accumulate manually with `StreamAccumulator` (`.push(chunk)` / `.end()`).
39
+
40
+ ## History hygiene (Pattern D)
41
+
42
+ ```python
43
+ from unswallow import sanitize_history, strip_reasoning_tags
44
+
45
+ clean = sanitize_history(messages)
46
+ text = strip_reasoning_tags(assistant_text) # "< thinking>\nplan\n< response>\nanswer" -> "answer"
47
+ ```
48
+
49
+ ## CLI
50
+
51
+ ```bash
52
+ unswallow check # bundled self-test demo (vLLM #39056)
53
+ unswallow check --fixture response.json # captured raw response
54
+ unswallow check --endpoint http://localhost:8000/v1 --model Qwen/Qwen3.5-35B-A3B-FP8 --engine vllm --version 0.19.0
55
+ unswallow matrix
56
+ ```
57
+
58
+ Exit codes for live probes: `0` = not affected, `1` = affected, `2` = error.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ cd packages/python
64
+ python -m unittest discover -s tests -v # stdlib-only test suite
65
+ ```
66
+
67
+ The engine matrix is bundled as data — after editing `packages/matrix/data/engine-matrix.json`, run `python packages/python/scripts/sync_matrix.py` from the repo root. CI verifies the copies stay identical.
68
+
69
+ Everything else — patterns, confidence scoring, the false-positive guard, the benchmark corpus and the engine matrix — is documented in the repository root [README](../../README.md).
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "unswallow"
7
+ version = "0.1.0"
8
+ description = "Detect and recover tool calls trapped inside a model's reasoning channel — the reasoning-channel swallow bug class across vLLM, SGLang, and llama.cpp. 1:1 mirror of the TypeScript library."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = [
13
+ "llm",
14
+ "agents",
15
+ "tool-calls",
16
+ "tool-use",
17
+ "function-calling",
18
+ "reasoning",
19
+ "thinking",
20
+ "vllm",
21
+ "sglang",
22
+ "llama.cpp",
23
+ "qwen3",
24
+ "deepseek",
25
+ "openai-compatible",
26
+ ]
27
+ classifiers = [
28
+ "Programming Language :: Python :: 3",
29
+ "License :: OSI Approved :: MIT License",
30
+ "Operating System :: OS Independent",
31
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
32
+ ]
33
+ dependencies = []
34
+
35
+ [project.urls]
36
+ Homepage = "https://github.com/0DukePan/unswallow"
37
+ Repository = "https://github.com/0DukePan/unswallow"
38
+ Issues = "https://github.com/0DukePan/unswallow/issues"
39
+
40
+ [project.scripts]
41
+ unswallow = "unswallow.cli:main"
42
+
43
+ [tool.setuptools]
44
+ packages = ["unswallow"]
45
+
46
+ [tool.setuptools.package-data]
47
+ unswallow = ["data/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,234 @@
1
+ import unittest
2
+
3
+ from unswallow import check_and_rescue, match_matrix_entry, normalize_engine
4
+
5
+
6
+ def response(message, finish_reason="stop"):
7
+ return {
8
+ "id": "chatcmpl-test",
9
+ "object": "chat.completion",
10
+ "model": "test-model",
11
+ "choices": [{"index": 0, "finish_reason": finish_reason, "message": message}],
12
+ }
13
+
14
+
15
+ class CoreTest(unittest.TestCase):
16
+ def test_pattern_a_function_xml_in_reasoning(self):
17
+ r = response(
18
+ {
19
+ "role": "assistant",
20
+ "content": "",
21
+ "reasoning": (
22
+ "< thinking>\nI need to answer the user\u2019s question. The answer is 204.\n"
23
+ "<tool_call>\n<function=Finish>\n<parameter=answer>\n204\n</parameter>\n</function>\n"
24
+ "</tool_call>\n< response>\n"
25
+ ),
26
+ "tool_calls": [],
27
+ }
28
+ )
29
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
30
+ self.assertTrue(result.detected)
31
+ self.assertEqual(result.pattern, "A")
32
+ self.assertEqual(result.source, "reasoning")
33
+ self.assertTrue(result.recovered)
34
+ self.assertEqual(result.confidence, 0.95)
35
+ self.assertEqual(result.tool_call.name, "Finish")
36
+ self.assertEqual(result.tool_call.arguments, {"answer": 204})
37
+ self.assertIsNotNone(result.recovered_response)
38
+ calls = result.recovered_response["choices"][0]["message"]["tool_calls"]
39
+ self.assertEqual(calls[0]["function"]["name"], "Finish")
40
+ self.assertEqual(calls[0]["function"]["arguments"], '{"answer": 204}')
41
+ self.assertEqual(result.recovered_response["choices"][0]["finish_reason"], "tool_calls")
42
+
43
+ def test_pattern_a_json_envelope_in_reasoning_content(self):
44
+ r = response(
45
+ {
46
+ "role": "assistant",
47
+ "content": "",
48
+ "reasoning_content": (
49
+ "< thinking>\nI should get the weather for Tokyo.\n"
50
+ '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
51
+ "< response>\n"
52
+ ),
53
+ "tool_calls": [],
54
+ }
55
+ )
56
+ result = check_and_rescue(r, engine_hint="sglang", engine_version="0.4.6")
57
+ self.assertTrue(result.detected)
58
+ self.assertEqual(result.pattern, "A")
59
+ self.assertEqual(result.source, "reasoning_content")
60
+ self.assertEqual(result.tool_call.arguments, {"city": "Tokyo"})
61
+ self.assertEqual(result.confidence, 0.95)
62
+
63
+ def test_pattern_a_think_block_in_content(self):
64
+ r = response(
65
+ {
66
+ "role": "assistant",
67
+ "content": (
68
+ "<thinking>\nThe user wants the weather in Berlin.\n"
69
+ '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Berlin"}}\n</tool_call>\n'
70
+ "</thinking>\n"
71
+ ),
72
+ "tool_calls": [],
73
+ }
74
+ )
75
+ result = check_and_rescue(r, engine_hint="llama.cpp", engine_version="b8461")
76
+ self.assertTrue(result.detected)
77
+ self.assertEqual(result.pattern, "A")
78
+ self.assertEqual(result.source, "thinking")
79
+ self.assertEqual(result.tool_call.arguments, {"city": "Berlin"})
80
+ self.assertEqual(result.confidence, 0.95)
81
+
82
+ def test_pattern_b_trailing_text(self):
83
+ r = response(
84
+ {
85
+ "role": "assistant",
86
+ "content": (
87
+ '{"name": "get_weather", "arguments": {"city": "Beijing"}}\n\n'
88
+ "Let me also check whether there is any other useful information to report.\n"
89
+ ),
90
+ "tool_calls": [],
91
+ }
92
+ )
93
+ result = check_and_rescue(r, engine_hint="llama.cpp", engine_version="b8461")
94
+ self.assertTrue(result.detected)
95
+ self.assertEqual(result.pattern, "B")
96
+ self.assertEqual(result.source, "content")
97
+ self.assertTrue(result.recovered)
98
+ self.assertEqual(result.confidence, 0.55)
99
+ self.assertTrue(any(w.startswith("trailing text") for w in result.warnings))
100
+
101
+ def test_pattern_b_complete_json_in_content(self):
102
+ r = response(
103
+ {
104
+ "role": "assistant",
105
+ "content": '{"name": "get_weather", "arguments": {"city": "Paris"}}',
106
+ "tool_calls": [],
107
+ }
108
+ )
109
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.26.0")
110
+ self.assertTrue(result.detected)
111
+ self.assertEqual(result.pattern, "B")
112
+ self.assertTrue(result.recovered)
113
+
114
+ def test_pattern_c_leak_detection_only(self):
115
+ r = response(
116
+ {
117
+ "role": "assistant",
118
+ "content": "Here is the answer. <mm:think>I should verify the weather data first",
119
+ "tool_calls": [],
120
+ }
121
+ )
122
+ result = check_and_rescue(r)
123
+ self.assertTrue(result.detected)
124
+ self.assertEqual(result.pattern, "C")
125
+ self.assertFalse(result.recovered)
126
+ self.assertLessEqual(result.confidence, 0.5)
127
+
128
+ def test_healthy_response_untouched(self):
129
+ r = response(
130
+ {
131
+ "role": "assistant",
132
+ "content": "",
133
+ "reasoning": "< thinking>\nI will call get_weather.\n< response>\n",
134
+ "tool_calls": [
135
+ {
136
+ "id": "call_abc",
137
+ "type": "function",
138
+ "function": {"name": "get_weather", "arguments": '{"city": "Tokyo"}'},
139
+ }
140
+ ],
141
+ },
142
+ "tool_calls",
143
+ )
144
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.24.0")
145
+ self.assertFalse(result.detected)
146
+ self.assertEqual(result.confidence, 0.0)
147
+ self.assertIsNone(result.recovered_response)
148
+
149
+ def test_resolved_range_warns(self):
150
+ r = response(
151
+ {
152
+ "role": "assistant",
153
+ "content": "",
154
+ "reasoning": (
155
+ "< thinking>\n"
156
+ '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
157
+ "< response>\n"
158
+ ),
159
+ "tool_calls": [],
160
+ }
161
+ )
162
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.25.0")
163
+ self.assertTrue(result.detected)
164
+ self.assertEqual(result.confidence, 0.6)
165
+ self.assertIsNotNone(result.matrix_match)
166
+ self.assertEqual(result.matrix_match.behavior, "resolved")
167
+ self.assertTrue(any("resolved" in w for w in result.warnings))
168
+
169
+ def test_unknown_engine_heuristic(self):
170
+ r = response(
171
+ {
172
+ "role": "assistant",
173
+ "content": "",
174
+ "reasoning": (
175
+ "< thinking>\n"
176
+ '{"name": "get_weather", "arguments": {"city": "Rome"}}\n'
177
+ "< response>\n"
178
+ ),
179
+ "tool_calls": [],
180
+ }
181
+ )
182
+ result = check_and_rescue(r)
183
+ self.assertTrue(result.detected)
184
+ self.assertEqual(result.pattern, "A")
185
+ self.assertEqual(result.confidence, 0.55)
186
+ self.assertEqual(result.engine_hint, "unknown")
187
+ self.assertIsNone(result.matrix_match)
188
+ self.assertTrue(any("engine_hint" in w for w in result.warnings))
189
+
190
+ def test_tool_schemas_mismatch_lowers_confidence(self):
191
+ r = response(
192
+ {
193
+ "role": "assistant",
194
+ "content": "",
195
+ "reasoning": (
196
+ "< thinking>\n"
197
+ '{"name": "get_weather", "arguments": {"city": "Oslo"}}\n'
198
+ "< response>\n"
199
+ ),
200
+ "tool_calls": [],
201
+ }
202
+ )
203
+ result = check_and_rescue(
204
+ r,
205
+ engine_hint="vllm",
206
+ engine_version="0.19.0",
207
+ tool_schemas=[
208
+ {
209
+ "type": "function",
210
+ "function": {"name": "get_news", "parameters": {"type": "object", "properties": {}}},
211
+ }
212
+ ],
213
+ )
214
+ self.assertEqual(result.confidence, 0.85)
215
+ self.assertTrue(any("not found in provided tool_schemas" in w for w in result.warnings))
216
+
217
+ def test_missing_choices(self):
218
+ result = check_and_rescue({"choices": []})
219
+ self.assertFalse(result.detected)
220
+ self.assertEqual(result.confidence, 0.0)
221
+ self.assertTrue(result.warnings)
222
+
223
+ def test_normalize_engine(self):
224
+ self.assertEqual(normalize_engine("vllm"), "vllm")
225
+ self.assertEqual(normalize_engine("sglang"), "sglang")
226
+ self.assertEqual(normalize_engine("llama.cpp"), "llama.cpp")
227
+ self.assertEqual(normalize_engine("llama-cpp"), "llama.cpp")
228
+ self.assertEqual(normalize_engine("LLAMA.CPP"), "llama.cpp")
229
+ self.assertEqual(normalize_engine("anything-else"), "unknown")
230
+ self.assertEqual(normalize_engine(None), "unknown")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ unittest.main()
@@ -0,0 +1,147 @@
1
+ import unittest
2
+
3
+ from unswallow import check_and_rescue
4
+
5
+
6
+ def response(message):
7
+ return {
8
+ "id": "chatcmpl-fp",
9
+ "object": "chat.completion",
10
+ "model": "test-model",
11
+ "choices": [{"index": 0, "finish_reason": "stop", "message": message}],
12
+ }
13
+
14
+
15
+ class FpGuardTest(unittest.TestCase):
16
+ def test_discussion_only_never_recovered(self):
17
+ r = response(
18
+ {
19
+ "role": "assistant",
20
+ "content": "",
21
+ "reasoning": (
22
+ "< thinking>\nI could call get_weather to check the weather in Tokyo, but I do not "
23
+ "need it for this answer. The user only asked a general question.\n< response>\n"
24
+ ),
25
+ "tool_calls": [],
26
+ }
27
+ )
28
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
29
+ self.assertFalse(result.detected)
30
+ self.assertEqual(result.confidence, 0.0)
31
+ self.assertIsNone(result.recovered_response)
32
+
33
+ def test_partial_json_without_arguments(self):
34
+ r = response(
35
+ {
36
+ "role": "assistant",
37
+ "content": "",
38
+ "reasoning": (
39
+ "< thinking>\nI might call {\"name\": \"get_weather\" if needed.\n< response>\n"
40
+ ),
41
+ "tool_calls": [],
42
+ }
43
+ )
44
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
45
+ self.assertFalse(result.detected)
46
+
47
+ def test_non_object_arguments_rejected(self):
48
+ r = response(
49
+ {
50
+ "role": "assistant",
51
+ "content": "",
52
+ "reasoning": (
53
+ "< thinking>\n"
54
+ '<tool_call>\n{"name": "get_weather", "arguments": "Tokyo"}\n</tool_call>\n'
55
+ "< response>\n"
56
+ ),
57
+ "tool_calls": [],
58
+ }
59
+ )
60
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
61
+ self.assertFalse(result.detected)
62
+
63
+ def test_missing_name_rejected(self):
64
+ r = response(
65
+ {
66
+ "role": "assistant",
67
+ "content": "",
68
+ "reasoning": (
69
+ "< thinking>\n"
70
+ '<tool_call>\n{"arguments": {"city": "Tokyo"}}\n</tool_call>\n'
71
+ "< response>\n"
72
+ ),
73
+ "tool_calls": [],
74
+ }
75
+ )
76
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
77
+ self.assertFalse(result.detected)
78
+
79
+ def test_unclosed_xml_with_broken_json_rejected(self):
80
+ r = response(
81
+ {
82
+ "role": "assistant",
83
+ "content": "",
84
+ "reasoning": (
85
+ "< thinking>\nI wonder if <tool_call>\n"
86
+ '{"name": "get_weather", "arguments": {"city": "Tokyo"}\n'
87
+ "is the right thing to do.\n< response>\n"
88
+ ),
89
+ "tool_calls": [],
90
+ }
91
+ )
92
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
93
+ self.assertFalse(result.detected)
94
+
95
+ def test_empty_name_function_rejected(self):
96
+ r = response(
97
+ {
98
+ "role": "assistant",
99
+ "content": "",
100
+ "reasoning": (
101
+ "< thinking>\n<function=>\n<parameter=answer>204</parameter>\n</function>\n"
102
+ "< response>\n"
103
+ ),
104
+ "tool_calls": [],
105
+ }
106
+ )
107
+ result = check_and_rescue(r, engine_hint="vllm", engine_version="0.19.0")
108
+ self.assertFalse(result.detected)
109
+
110
+ def test_real_recovery_with_matching_schema(self):
111
+ r = response(
112
+ {
113
+ "role": "assistant",
114
+ "content": "",
115
+ "reasoning": (
116
+ "< thinking>\n"
117
+ '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Tokyo"}}\n</tool_call>\n'
118
+ "< response>\n"
119
+ ),
120
+ "tool_calls": [],
121
+ }
122
+ )
123
+ result = check_and_rescue(
124
+ r,
125
+ engine_hint="vllm",
126
+ engine_version="0.19.0",
127
+ tool_schemas=[
128
+ {
129
+ "type": "function",
130
+ "function": {
131
+ "name": "get_weather",
132
+ "parameters": {
133
+ "type": "object",
134
+ "properties": {"city": {"type": "string"}},
135
+ "required": ["city"],
136
+ },
137
+ },
138
+ }
139
+ ],
140
+ )
141
+ self.assertTrue(result.detected)
142
+ self.assertEqual(result.confidence, 0.95)
143
+ self.assertEqual(result.tool_call.arguments, {"city": "Tokyo"})
144
+
145
+
146
+ if __name__ == "__main__":
147
+ unittest.main()
@@ -0,0 +1,65 @@
1
+ import unittest
2
+
3
+ from unswallow import sanitize_history, strip_reasoning_tags
4
+
5
+
6
+ class HistoryTest(unittest.TestCase):
7
+ def test_strip_complete_think_blocks(self):
8
+ text = "< thinking>\nI should get the weather for Tokyo.\n< response>\nThe weather in Tokyo is 24C."
9
+ self.assertEqual(strip_reasoning_tags(text), "The weather in Tokyo is 24C.")
10
+
11
+ def test_strip_xml_thinking_blocks(self):
12
+ text = "<thinking>\nplanning\n</thinking>\nFinal answer here."
13
+ self.assertEqual(strip_reasoning_tags(text), "Final answer here.")
14
+
15
+ def test_leak_keeps_text_drops_tag(self):
16
+ text = "Here is the summary. <mm:think>I should verify the weather data first"
17
+ self.assertEqual(strip_reasoning_tags(text), "Here is the summary. I should verify the weather data first")
18
+
19
+ def test_plain_text_untouched(self):
20
+ text = "The user asked about the weather and I answered directly."
21
+ self.assertEqual(strip_reasoning_tags(text), text)
22
+
23
+ def test_word_think_not_eaten(self):
24
+ text = "I think this is a great question."
25
+ self.assertEqual(strip_reasoning_tags(text), text)
26
+
27
+ def test_sanitize_history(self):
28
+ history = [
29
+ {"role": "user", "content": "What is the weather?"},
30
+ {
31
+ "role": "assistant",
32
+ "content": "< thinking>\nI need a tool.\n< response>\nLet me call the tool.",
33
+ "reasoning": "< thinking>\nI need a tool.\n< response>\n",
34
+ "reasoning_content": "< thinking>\nI need a tool.\n< response>\n",
35
+ },
36
+ {"role": "tool", "content": "24C"},
37
+ ]
38
+ frozen = json_repr(history)
39
+ clean = sanitize_history(history)
40
+ self.assertEqual(json_repr(history), frozen)
41
+ self.assertEqual(len(clean), 3)
42
+ self.assertEqual(clean[0]["content"], "What is the weather?")
43
+ self.assertEqual(clean[1]["content"], "Let me call the tool.")
44
+ self.assertNotIn("reasoning", clean[1])
45
+ self.assertNotIn("reasoning_content", clean[1])
46
+ self.assertEqual(clean[2]["content"], "24C")
47
+
48
+ def test_options(self):
49
+ history = [{"role": "assistant", "content": "answer", "thinking": "< thinking>\nplan\n< response>\n"}]
50
+ clean = sanitize_history(history, strip_reasoning_fields=False)
51
+ self.assertEqual(clean[0]["thinking"], "< thinking>\nplan\n< response>\n")
52
+ clean2 = sanitize_history(history, strip_reasoning_tags_opt=False)
53
+ self.assertEqual(clean2[0]["content"], "answer")
54
+ clean3 = sanitize_history(history, strip_reasoning_fields=False, strip_reasoning_tags_opt=False)
55
+ self.assertEqual(clean3, history)
56
+
57
+
58
+ def json_repr(obj):
59
+ import json
60
+
61
+ return json.dumps(obj, sort_keys=True)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()