agent-protocol-core 0.12.2__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,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ *.db
6
+ dist/
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.5
2
+ Name: agent-protocol-core
3
+ Version: 0.12.2
4
+ Summary: 模型协议残留处理:识别漏进正文的协议头、孤儿 JSON 尾、思考标记。纯标准库。
5
+ Project-URL: Homepage, https://github.com/teleport-computer/memgarden
6
+ Project-URL: Source, https://github.com/teleport-computer/memgarden
7
+ License:
8
+ Apache License
9
+ Version 2.0, January 2004
10
+ http://www.apache.org/licenses/
11
+
12
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
13
+
14
+ 1. Definitions.
15
+
16
+ "License" shall mean the terms and conditions for use, reproduction,
17
+ and distribution as defined by Sections 1 through 9 of this document.
18
+
19
+ "Licensor" shall mean the copyright owner or entity authorized by
20
+ the copyright owner that is granting the License.
21
+
22
+ "Legal Entity" shall mean the union of the acting entity and all
23
+ other entities that control, are controlled by, or are under common
24
+ control with that entity. For the purposes of this definition,
25
+ "control" means (i) the power, direct or indirect, to cause the
26
+ direction or management of such entity, whether by contract or
27
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
28
+ outstanding shares, or (iii) beneficial ownership of such entity.
29
+
30
+ "You" (or "Your") shall mean an individual or Legal Entity
31
+ exercising permissions granted by this License.
32
+
33
+ "Source" form shall mean the preferred form for making modifications,
34
+ including but not limited to software source code, documentation
35
+ source, and configuration files.
36
+
37
+ "Object" form shall mean any form resulting from mechanical
38
+ transformation or translation of a Source form, including but
39
+ not limited to compiled object code, generated documentation,
40
+ and conversions to other media types.
41
+
42
+ "Work" shall mean the work of authorship, whether in Source or
43
+ Object form, made available under the License, as indicated by a
44
+ copyright notice that is included in or attached to the work
45
+ (an example is provided in the Appendix below).
46
+
47
+ "Derivative Works" shall mean any work, whether in Source or Object
48
+ form, that is based on (or derived from) the Work and for which the
49
+ editorial revisions, annotations, elaborations, or other modifications
50
+ represent, as a whole, an original work of authorship. For the purposes
51
+ of this License, Derivative Works shall not include works that remain
52
+ separable from, or merely link (or bind by name) to the interfaces of,
53
+ the Work and Derivative Works thereof.
54
+
55
+ "Contribution" shall mean any work of authorship, including
56
+ the original version of the Work and any modifications or additions
57
+ to that Work or Derivative Works thereof, that is intentionally
58
+ submitted to Licensor for inclusion in the Work by the copyright owner
59
+ or by an individual or Legal Entity authorized to submit on behalf of
60
+ the copyright owner. For the purposes of this definition, "submitted"
61
+ means any form of electronic, verbal, or written communication sent
62
+ to the Licensor or its representatives, including but not limited to
63
+ communication on electronic mailing lists, source code control systems,
64
+ and issue tracking systems that are managed by, or on behalf of, the
65
+ Licensor for the purpose of discussing and improving the Work, but
66
+ excluding communication that is conspicuously marked or otherwise
67
+ designated in writing by the copyright owner as "Not a Contribution."
68
+
69
+ "Contributor" shall mean Licensor and any individual or Legal Entity
70
+ on behalf of whom a Contribution has been received by Licensor and
71
+ subsequently incorporated within the Work.
72
+
73
+ 2. Grant of Copyright License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ copyright license to reproduce, prepare Derivative Works of,
77
+ publicly display, publicly perform, sublicense, and distribute the
78
+ Work and such Derivative Works in Source or Object form.
79
+
80
+ 3. Grant of Patent License. Subject to the terms and conditions of
81
+ this License, each Contributor hereby grants to You a perpetual,
82
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
83
+ (except as stated in this section) patent license to make, have made,
84
+ use, offer to sell, sell, import, and otherwise transfer the Work,
85
+ where such license applies only to those patent claims licensable
86
+ by such Contributor that are necessarily infringed by their
87
+ Contribution(s) alone or by combination of their Contribution(s)
88
+ with the Work to which such Contribution(s) was submitted. If You
89
+ institute patent litigation against any entity (including a
90
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
91
+ or a Contribution incorporated within the Work constitutes direct
92
+ or contributory patent infringement, then any patent licenses
93
+ granted to You under this License for that Work shall terminate
94
+ as of the date such litigation is filed.
95
+
96
+ 4. Redistribution. You may reproduce and distribute copies of the
97
+ Work or Derivative Works thereof in any medium, with or without
98
+ modifications, and in Source or Object form, provided that You
99
+ meet the following conditions:
100
+
101
+ (a) You must give any other recipients of the Work or
102
+ Derivative Works a copy of this License; and
103
+
104
+ (b) You must cause any modified files to carry prominent notices
105
+ stating that You changed the files; and
106
+
107
+ (c) You must retain, in the Source form of any Derivative Works
108
+ that You distribute, all copyright, patent, trademark, and
109
+ attribution notices from the Source form of the Work,
110
+ excluding those notices that do not pertain to any part of
111
+ the Derivative Works; and
112
+
113
+ (d) If the Work includes a "NOTICE" text file as part of its
114
+ distribution, then any Derivative Works that You distribute must
115
+ include a readable copy of the attribution notices contained
116
+ within such NOTICE file, excluding those notices that do not
117
+ pertain to any part of the Derivative Works, in at least one
118
+ of the following places: within a NOTICE text file distributed
119
+ as part of the Derivative Works; within the Source form or
120
+ documentation, if provided along with the Derivative Works; or,
121
+ within a display generated by the Derivative Works, if and
122
+ wherever such third-party notices normally appear. The contents
123
+ of the NOTICE file are for informational purposes only and
124
+ do not modify the License. You may add Your own attribution
125
+ notices within Derivative Works that You distribute, alongside
126
+ or as an addendum to the NOTICE text from the Work, provided
127
+ that such additional attribution notices cannot be construed
128
+ as modifying the License.
129
+
130
+ You may add Your own copyright statement to Your modifications and
131
+ may provide additional or different license terms and conditions
132
+ for use, reproduction, or distribution of Your modifications, or
133
+ for any such Derivative Works as a whole, provided Your use,
134
+ reproduction, and distribution of the Work otherwise complies with
135
+ the conditions stated in this License.
136
+
137
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
138
+ any Contribution intentionally submitted for inclusion in the Work
139
+ by You to the Licensor shall be under the terms and conditions of
140
+ this License, without any additional terms or conditions.
141
+ Notwithstanding the above, nothing herein shall supersede or modify
142
+ the terms of any separate license agreement you may have executed
143
+ with Licensor regarding such Contributions.
144
+
145
+ 6. Trademarks. This License does not grant permission to use the trade
146
+ names, trademarks, service marks, or product names of the Licensor,
147
+ except as required for reasonable and customary use in describing the
148
+ origin of the Work and reproducing the content of the NOTICE file.
149
+
150
+ 7. Disclaimer of Warranty. Unless required by applicable law or
151
+ agreed to in writing, Licensor provides the Work (and each
152
+ Contributor provides its Contributions) on an "AS IS" BASIS,
153
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
154
+ implied, including, without limitation, any warranties or conditions
155
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
156
+ PARTICULAR PURPOSE. You are solely responsible for determining the
157
+ appropriateness of using or redistributing the Work and assume any
158
+ risks associated with Your exercise of permissions under this License.
159
+
160
+ 8. Limitation of Liability. In no event and under no legal theory,
161
+ whether in tort (including negligence), contract, or otherwise,
162
+ unless required by applicable law (such as deliberate and grossly
163
+ negligent acts) or agreed to in writing, shall any Contributor be
164
+ liable to You for damages, including any direct, indirect, special,
165
+ incidental, or consequential damages of any character arising as a
166
+ result of this License or out of the use or inability to use the
167
+ Work (including but not limited to damages for loss of goodwill,
168
+ work stoppage, computer failure or malfunction, or any and all
169
+ other commercial damages or losses), even if such Contributor
170
+ has been advised of the possibility of such damages.
171
+
172
+ 9. Accepting Warranty or Additional Liability. While redistributing
173
+ the Work or Derivative Works thereof, You may choose to offer,
174
+ and charge a fee for, acceptance of support, warranty, indemnity,
175
+ or other liability obligations and/or rights consistent with this
176
+ License. However, in accepting such obligations, You may act only
177
+ on Your own behalf and on Your sole responsibility, not on behalf
178
+ of any other Contributor, and only if You agree to indemnify,
179
+ defend, and hold each Contributor harmless for any liability
180
+ incurred by, or claims asserted against, such Contributor by reason
181
+ of your accepting any such warranty or additional liability.
182
+
183
+ END OF TERMS AND CONDITIONS
184
+
185
+ APPENDIX: How to apply the Apache License to your work.
186
+
187
+ To apply the Apache License to your work, attach the following
188
+ boilerplate notice, with the fields enclosed by brackets "[]"
189
+ replaced with your own identifying information. (Don't include
190
+ the brackets!) The text should be enclosed in the appropriate
191
+ comment syntax for the file format. We also recommend that a
192
+ file or class name and description of purpose be included on the
193
+ same "printed page" as the copyright notice for easier
194
+ identification within third-party archives.
195
+
196
+ Copyright [yyyy] [name of copyright owner]
197
+
198
+ Licensed under the Apache License, Version 2.0 (the "License");
199
+ you may not use this file except in compliance with the License.
200
+ You may obtain a copy of the License at
201
+
202
+ http://www.apache.org/licenses/LICENSE-2.0
203
+
204
+ Unless required by applicable law or agreed to in writing, software
205
+ distributed under the License is distributed on an "AS IS" BASIS,
206
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
207
+ See the License for the specific language governing permissions and
208
+ limitations under the License.
209
+ License-File: LICENSE
210
+ Classifier: Development Status :: 4 - Beta
211
+ Classifier: Intended Audience :: Developers
212
+ Classifier: License :: OSI Approved :: Apache Software License
213
+ Classifier: Programming Language :: Python :: 3
214
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
215
+ Requires-Python: >=3.10
216
+ Description-Content-Type: text/markdown
217
+
218
+ # agent-protocol-core
219
+
220
+ **识别「模型协议残片漏进了正文」。纯标准库,零依赖。**
221
+
222
+ 模型的原始输出里带着协议层的东西 —— 通道标记、工具路由、思考标签、
223
+ 被截断的 JSON 尾巴。这些**不该出现在给人看的文本里**:用户会在聊天窗口
224
+ 或记忆卡里看到一串乱码,而系统那边一切正常,没有任何报错。
225
+
226
+ 这个包只做识别,不做处置 —— 怎么处置(打回重问、就地清洗、直接丢弃)
227
+ 是调用方的决定,不同场景代价不同。
228
+
229
+ ```python
230
+ from agent_protocol_core import protocol_leak, self_thinking
231
+
232
+ protocol_leak.is_orphan_json_tail(text) # 闭括号比开括号多,且带 JSON 记号
233
+ self_thinking.strip_thinking(text) # 思考标记
234
+ ```
235
+
236
+ ## 为什么单独一个包
237
+
238
+ 聊天和记忆两条路都要这个判断。放在任何一边,另一边就得跨模块引用;
239
+ 各写一份则会漂 —— 改了一边忘了另一边,同一段脏文本在两条路上待遇不同。
240
+
241
+ 跟 [memgarden](https://github.com/teleport-computer/memgarden) 同仓库、锁步发版。
242
+
243
+ Apache-2.0
@@ -0,0 +1,26 @@
1
+ # agent-protocol-core
2
+
3
+ **识别「模型协议残片漏进了正文」。纯标准库,零依赖。**
4
+
5
+ 模型的原始输出里带着协议层的东西 —— 通道标记、工具路由、思考标签、
6
+ 被截断的 JSON 尾巴。这些**不该出现在给人看的文本里**:用户会在聊天窗口
7
+ 或记忆卡里看到一串乱码,而系统那边一切正常,没有任何报错。
8
+
9
+ 这个包只做识别,不做处置 —— 怎么处置(打回重问、就地清洗、直接丢弃)
10
+ 是调用方的决定,不同场景代价不同。
11
+
12
+ ```python
13
+ from agent_protocol_core import protocol_leak, self_thinking
14
+
15
+ protocol_leak.is_orphan_json_tail(text) # 闭括号比开括号多,且带 JSON 记号
16
+ self_thinking.strip_thinking(text) # 思考标记
17
+ ```
18
+
19
+ ## 为什么单独一个包
20
+
21
+ 聊天和记忆两条路都要这个判断。放在任何一边,另一边就得跨模块引用;
22
+ 各写一份则会漂 —— 改了一边忘了另一边,同一段脏文本在两条路上待遇不同。
23
+
24
+ 跟 [memgarden](https://github.com/teleport-computer/memgarden) 同仓库、锁步发版。
25
+
26
+ Apache-2.0
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-protocol-core"
7
+ version = "0.12.2"
8
+ description = "模型协议残留处理:识别漏进正文的协议头、孤儿 JSON 尾、思考标记。纯标准库。"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ dependencies = []
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: Apache Software License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
19
+ ]
20
+
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/agent_protocol_core"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/teleport-computer/memgarden"
27
+ Source = "https://github.com/teleport-computer/memgarden"
@@ -0,0 +1,11 @@
1
+ """模型协议残留处理 —— 聊天与记忆两边**共用**,谁也不依赖谁。
2
+
3
+ 单独成包的原因:`memgarden` 的卡片文本校验要用它(判断模型把协议头/
4
+ 思考标记漏进了卡里),而 io 的聊天主链路、工具循环、主动唤醒也要用它。
5
+
6
+ 放进 memgarden 会让**普通聊天反向依赖记忆包**(codex review 2026-08-14 指出);
7
+ 各留一份又会漂 —— 所以独立成一个只依赖标准库的小包。
8
+ """
9
+ from . import protocol_leak, self_thinking
10
+
11
+ __all__ = ["protocol_leak", "self_thinking"]
@@ -0,0 +1,280 @@
1
+ """Shared detector for torn / leaked agent-protocol JSON.
2
+
3
+ A reasoning model behind a stream-cutting relay can split one protocol envelope
4
+ ``{"messages":[],"actions":[{"type":"proactive.sleep","reason":"..."}]}`` across
5
+ the provider's ``reasoning_content`` / ``content`` channel boundary: the head
6
+ lands in the reasoning channel, the tail in the visible message. Every historical
7
+ guard anchored on the JSON *head* (``"actions":`` / ``"type":"proactive.X"``), so
8
+ once the head is torn into the reasoning channel the tail leaks into a chat
9
+ bubble as innocuous-looking prose.
10
+
11
+ This module is a **pure detector**: no I/O, no business deps (per CONTRIBUTING it
12
+ lives in ``core/`` so V1 resident-consumer, Proactive Runtime V2 and Model API
13
+ Runtime V2 can all import it). It returns an *evidence enum*, never an executable
14
+ action — the whole point is that a turn we know was corrupted in transit must not
15
+ have its "reconstructed" action executed. Lane policy (drop vs keep, fallback vs
16
+ silence) lives in each runtime, not here.
17
+
18
+ Evidence tiers, strongest first:
19
+
20
+ - ``UPSTREAM_RESPONSE_ENVELOPE`` — the visible reply is a complete serialized
21
+ provider transport wrapper (for example ``response.candidates`` plus the
22
+ relay's ``traceId``), not model-authored foreground text.
23
+ - ``JOINED_KNOWN_PROTOCOL`` — reasoning-suffix + visible rejoin to a complete,
24
+ known protocol envelope that consumes to the end. Proof of a single-cut tear.
25
+ - ``HEAD_IN_REASONING`` — the rejoin does not parse (multi-cut / lost middle) but
26
+ the reasoning channel independently carries a protocol head, and the visible
27
+ text is a JSON fragment. Users never author a protocol head into the reasoning
28
+ channel; only the torn relay does.
29
+ - ``TRANSPORT_CUT`` — the transport itself reported a stream cut this turn and the
30
+ visible text is a JSON fragment. Independent proof the pipe broke.
31
+ - ``ORPHAN_JSON_TAIL`` — WEAK: the visible text alone looks like a structural JSON
32
+ fragment (string-ignoring bracket balance goes negative + a JSON token). High
33
+ recall, but indistinguishable from a user pasting JSON / discussing code, so it
34
+ is proactive-lane-only and never a foreground drop.
35
+ - ``NONE`` — no evidence.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ from typing import Any
42
+
43
+ JOINED_KNOWN_PROTOCOL = "joined_known_protocol"
44
+ HEAD_IN_REASONING = "head_in_reasoning"
45
+ TRANSPORT_CUT = "transport_cut"
46
+ UPSTREAM_RESPONSE_ENVELOPE = "upstream_response_envelope"
47
+ ORPHAN_JSON_TAIL = "orphan_json_tail"
48
+ NONE = "none"
49
+
50
+ _STRONG = frozenset(
51
+ {
52
+ JOINED_KNOWN_PROTOCOL,
53
+ HEAD_IN_REASONING,
54
+ TRANSPORT_CUT,
55
+ UPSTREAM_RESPONSE_ENVELOPE,
56
+ }
57
+ )
58
+
59
+ # Known protocol action vocabulary (union across V1 + both V2 subsystems). Used
60
+ # only to decide "is a rejoined object a *known* protocol envelope", never to
61
+ # execute anything.
62
+ _KNOWN_ACTION_TYPES = frozenset({
63
+ "sleep", "send_message", "schedule_wake", "cancel_wake",
64
+ "request_broadcast", "needs_background",
65
+ })
66
+ _KNOWN_ACTION_PREFIXES = ("identity.", "memory.", "proactive.")
67
+
68
+ # A protocol key sitting in a JSON key position, or an identity/memory/proactive
69
+ # typed action — search-anywhere (NOT head-anchored). A prose mention like
70
+ # `the "actions" field` never matches (needs a `{`/`,`/line-start before it).
71
+ _HEAD_KEY_RE = re.compile(r'(?:[{,]|^|\n)\s*"(?:actions|messages|tool_calls|cards)"\s*:')
72
+ _HEAD_TYPED_RE = re.compile(r'"type"\s*:\s*"(?:identity|memory|proactive)\.\w+"')
73
+ # A protocol-closing tail like `..."}]}` / `...}]` — a quoted value immediately
74
+ # followed by two structural closers.
75
+ _TAIL_CLOSE_RE = re.compile(r'"\s*[}\]]\s*[}\]]')
76
+
77
+
78
+ def _naive_min_depth(text: str) -> int:
79
+ """Running bracket balance IGNORING string context. A torn tail closes
80
+ brackets it never opened -> the balance dips below zero. String-aware
81
+ counting is deliberately NOT used: a tail that begins mid-string-value
82
+ (`active.sleep","reason":...`) misaligns quote parity and reads as balanced,
83
+ hiding the very leak we are trying to catch."""
84
+ depth = 0
85
+ mind = 0
86
+ for ch in text:
87
+ if ch in "{[":
88
+ depth += 1
89
+ elif ch in "}]":
90
+ depth -= 1
91
+ if depth < mind:
92
+ mind = depth
93
+ return mind
94
+
95
+
96
+ def _has_json_token(text: str) -> bool:
97
+ return ('":' in text) or bool(_TAIL_CLOSE_RE.search(text))
98
+
99
+
100
+ def is_orphan_json_tail(text: Any) -> bool:
101
+ """WEAK signal: the visible text alone is a structural JSON fragment — it
102
+ closes more brackets than it opens AND carries a JSON token. High recall
103
+ (some legit JSON-in-prose trips it too), so callers must gate it by lane."""
104
+ t = str(text or "")
105
+ if _naive_min_depth(t) >= 0:
106
+ return False
107
+ return _has_json_token(t)
108
+
109
+
110
+ def looks_like_protocol_head(text: Any) -> bool:
111
+ """Structural evidence of a protocol payload's HEAD: a protocol key in a JSON
112
+ key position, or an identity/memory/proactive typed action."""
113
+ t = str(text or "")
114
+ if not (_HEAD_KEY_RE.search(t) or _HEAD_TYPED_RE.search(t)):
115
+ return False
116
+ return "{" in t or "[" in t
117
+
118
+
119
+ def _naive_end_depth(text: str) -> int:
120
+ """String-ignoring bracket balance over the whole string. > 0 means the text
121
+ ends with more opens than closes — i.e. an unclosed structure dangling at the
122
+ end."""
123
+ depth = 0
124
+ for ch in text:
125
+ if ch in "{[":
126
+ depth += 1
127
+ elif ch in "}]":
128
+ depth -= 1
129
+ return depth
130
+
131
+
132
+ def reasoning_ends_with_unclosed_head(text: Any) -> bool:
133
+ """A torn protocol head left in the reasoning channel: the reasoning ENDS with
134
+ an unclosed protocol structure (net-open brackets) that carries a protocol
135
+ key/typed token.
136
+
137
+ This is the precise fingerprint of a relay tear — the head is the dangling,
138
+ never-closed suffix of the reasoning stream. It deliberately does NOT match a
139
+ model that merely *quotes* a closed protocol snippet mid-thought
140
+ (`the schema is {"actions": [...]} but ...`): that balances to a non-positive
141
+ depth, so a normal reasoning model discussing the schema cannot upgrade a
142
+ weak visible fragment to strong evidence (Codex code-review #4)."""
143
+ t = str(text or "")
144
+ if _naive_end_depth(t) <= 0:
145
+ return False
146
+ return looks_like_protocol_head(t)
147
+
148
+
149
+ def _is_supported_action(action: Any) -> bool:
150
+ if not isinstance(action, dict):
151
+ return False
152
+ typ = str(action.get("type") or action.get("action") or "").strip()
153
+ if not typ:
154
+ return False
155
+ return typ.startswith(_KNOWN_ACTION_PREFIXES) or typ in _KNOWN_ACTION_TYPES
156
+
157
+
158
+ def _is_protocol_object(obj: Any) -> bool:
159
+ """A top-level protocol envelope: a messages/tool_calls/cards list, or an
160
+ actions list carrying at least one known action type."""
161
+ if not isinstance(obj, dict):
162
+ return False
163
+ if isinstance(obj.get("messages"), list):
164
+ return True
165
+ actions = obj.get("actions")
166
+ if isinstance(actions, list) and any(_is_supported_action(a) for a in actions):
167
+ return True
168
+ if isinstance(obj.get("tool_calls"), list) and obj.get("tool_calls"):
169
+ return True
170
+ if isinstance(obj.get("cards"), list):
171
+ return True
172
+ return False
173
+
174
+
175
+ def _rejoins_to_protocol(reasoning_text: str, visible_text: str) -> bool:
176
+ """True iff some `{`/`[` start in the reasoning channel, concatenated with
177
+ the visible text, parses as a complete known protocol envelope consuming to
178
+ the very end. Proof of a cross-channel tear. Never returns the object — the
179
+ reconstructed action must not be executed."""
180
+ r = str(reasoning_text or "")
181
+ v = str(visible_text or "")
182
+ if not v:
183
+ return False
184
+ decoder = json.JSONDecoder()
185
+ for m in re.finditer(r"[{\[]", r):
186
+ candidate = r[m.start():] + v
187
+ try:
188
+ obj, end = decoder.raw_decode(candidate)
189
+ except json.JSONDecodeError:
190
+ continue
191
+ if end == len(candidate) and _is_protocol_object(obj):
192
+ return True
193
+ return False
194
+
195
+
196
+ def _visible_is_fragmentish(visible_text: str) -> bool:
197
+ return is_orphan_json_tail(visible_text) or looks_like_protocol_head(visible_text)
198
+
199
+
200
+ def is_upstream_response_envelope(text: Any) -> bool:
201
+ """True for the complete relay wrapper leaked in usr_90184's V2 bubble.
202
+
203
+ This deliberately requires the *whole* visible reply to parse as one object
204
+ and requires both sides of the transport signature: a non-empty relay
205
+ ``traceId`` at the top level, plus Gemini response metadata around a
206
+ ``candidates`` list. Merely discussing JSON, returning an ordinary JSON
207
+ object, or mentioning ``response``/``metadata`` is not enough to suppress a
208
+ foreground answer. This intentionally covers only the observed Gemini relay
209
+ shape; it does not claim generic coverage for OpenAI-shaped wrappers,
210
+ additional outer envelopes, or JSON surrounded by model-authored prose.
211
+ """
212
+ visible = str(text or "").strip()
213
+ if (
214
+ len(visible) < 2
215
+ or len(visible) > 1_000_000
216
+ or visible[0] != "{"
217
+ or visible[-1] != "}"
218
+ ):
219
+ return False
220
+ try:
221
+ wrapper = json.loads(visible)
222
+ except (TypeError, ValueError, json.JSONDecodeError):
223
+ return False
224
+ if not isinstance(wrapper, dict):
225
+ return False
226
+ if not str(wrapper.get("traceId") or "").strip():
227
+ return False
228
+ if not isinstance(wrapper.get("metadata"), dict):
229
+ return False
230
+ response = wrapper.get("response")
231
+ if not isinstance(response, dict) or not isinstance(response.get("candidates"), list):
232
+ return False
233
+ provider_markers = sum(
234
+ (
235
+ isinstance(response.get("usageMetadata"), dict),
236
+ bool(str(response.get("modelVersion") or "").strip()),
237
+ bool(str(response.get("responseId") or "").strip()),
238
+ )
239
+ )
240
+ return provider_markers >= 2
241
+
242
+
243
+ def classify(visible_text: Any, *, reasoning_text: Any = "", transport_cut: bool = False) -> str:
244
+ """Classify a visible reply into an evidence tier (see module docstring).
245
+ Returns an enum string, never an action."""
246
+ v = str(visible_text or "")
247
+ if not v.strip():
248
+ return NONE
249
+ if is_upstream_response_envelope(v):
250
+ return UPSTREAM_RESPONSE_ENVELOPE
251
+ r = str(reasoning_text or "")
252
+ if r and _rejoins_to_protocol(r, v):
253
+ return JOINED_KNOWN_PROTOCOL
254
+ if r and reasoning_ends_with_unclosed_head(r) and _visible_is_fragmentish(v):
255
+ return HEAD_IN_REASONING
256
+ if transport_cut and _visible_is_fragmentish(v):
257
+ return TRANSPORT_CUT
258
+ if is_orphan_json_tail(v):
259
+ return ORPHAN_JSON_TAIL
260
+ return NONE
261
+
262
+
263
+ def is_leak(evidence: str) -> bool:
264
+ return evidence != NONE
265
+
266
+
267
+ def is_strong(evidence: str) -> bool:
268
+ return evidence in _STRONG
269
+
270
+
271
+ def should_suppress(evidence: str, *, lane: str) -> bool:
272
+ """Lane policy. Proactive/wake suppress any leak (silence is the correct
273
+ proactive outcome anyway, and a bracket-junky bubble at 5am is never a real
274
+ message). Foreground suppresses only on STRONG evidence — a weak orphan tail
275
+ might be a user pasting JSON, and dropping it would eat a real message."""
276
+ if evidence == NONE:
277
+ return False
278
+ if lane == "proactive":
279
+ return True
280
+ return is_strong(evidence)
@@ -0,0 +1,320 @@
1
+ """Self-authored thinking — runtime-neutral shared kernel.
2
+
3
+ io is prompted to open every reply with a first-person thought wrapped in
4
+ ``<think>…</think>``, then the actual reply. The ``<think>`` marker is used
5
+ because BOTH runtimes already know it: the V1 resident consumer extracts it
6
+ natively, and V2 calls :func:`split_thinking` here to peel it into the thinking
7
+ envelope. Lives in ``core`` so agent_runtime (V1) and model_api_runtime (V2) can
8
+ both import it top-down. Pure stdlib.
9
+
10
+ ``split_thinking`` is a small state machine, NOT a regex scrub (Codex review): it
11
+ only treats a *leading* ``<think>`` as protocol, and returns an explicit status so
12
+ the caller can fail closed. Hard invariants:
13
+
14
+ * a raw ``<think`` / ``</think`` fragment of the leading protocol NEVER reaches
15
+ the user-visible reply (same risk class as the protocol-JSON tail leak);
16
+ * private thinking content is NEVER promoted to the reply when the block cannot
17
+ be cleanly resolved;
18
+ * a clean thinking-only response is distinct from malformed protocol so wake
19
+ lanes can treat intentional silence as success without weakening leak guards;
20
+ * with the feature off (or ABSENT) the reply is byte-identical to today.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import unicodedata
27
+
28
+ # Parse outcomes.
29
+ ABSENT = "absent" # no leading <think> → reply is the original text
30
+ COMPLETE = "complete" # clean <tag>…</tag> + non-empty reply
31
+ SILENT = "silent" # clean <tag>…</tag> + intentionally empty public reply
32
+ FAILED = "failed" # unresolvable (truncated/mismatched/nested)
33
+
34
+ MAX_THINKING_CHARS = 240
35
+
36
+ # 内部字段名/协议词。**思考是用户可见面**,这些词出现在里面等于把运行时内脏
37
+ # 端到用户眼前(「session_id: …」「permission_denials」「costUSD」)。
38
+ #
39
+ # 来源:V1 consumer 早有一份逐行黑名单(chat_resident_consumer.py 的
40
+ # `_sanitize_thinking_summary`),V2 一直只有长度截断 —— 这是真回归。
41
+ # 放在共享内核是为了让两代**共用同一份词表**;V1 的运行时行为本批不动
42
+ # (它逐行丢弃、V2 整段不发布,两种处置各有来由),但词表由此单一来源,
43
+ # 并有漂移守卫钉住(见 tests)。
44
+ #
45
+ # ⚠️ 只列**内部/协议**词汇,不列日常词。这里每多一个常见词,就多一次
46
+ # 把正常内心话误判为泄漏的机会 —— 那会让用户看到「(思考没写完)」而不是真话。
47
+ INTERNAL_FIELD_TERMS = (
48
+ "system prompt", "developer message", "chain-of-thought", "chain of thought",
49
+ "modelUsage", "terminal_reason", "permission_denials",
50
+ "cache_read", "cache_creation", "session_id", "uuid", "costUSD",
51
+ "input_tokens", "output_tokens",
52
+ )
53
+
54
+
55
+ # 词表单一来源,**宽度按道分开** —— codex2 审出来的关键设计点:
56
+ # · V1 consumer 逐行丢弃:命中就删那一行,其余保留 → 宽匹配代价低
57
+ # · V2 整段替换成 THINKING_FAILED_MARKER:命中就把整段内心话换掉 → 代价高得多
58
+ # 所以两代共享**词表**、各用各的**宽度**,而不是硬共用一个 matcher。
59
+ #
60
+ # V2 用下面这个窄判据:只认「字段泄漏的形状」,不认「概念提及」。
61
+ # codex2 实测的三句必须放行 —— 用户完全可能跟伴侣聊这些:
62
+ # 「用户问 UUID 是什么」「讨论 system prompt 的设计」「学习 chain of thought prompting」
63
+ # 整段吞掉的话,用户只会看到「(思考没写完)」,而且不知道为什么。
64
+ #
65
+ # 泄漏形状 = 词后面紧跟分隔符/取值:`session_id: abc`、`"input_tokens": 12`、
66
+ # `costUSD=0.02`、`terminal_reason -> x`。概念提及不会长这样。
67
+ # 结尾允许先闭合引号再跟分隔符 —— JSON 形态 `"input_tokens": 12` 就长这样,
68
+ # 第一版漏了它(claude2 自测发现)。
69
+ _FIELD_LEAK_TAIL = r"""["'`]?\s*(?:[:=]|=>|->|→)"""
70
+ _INTERNAL_FIELD_LEAK_RE = re.compile(
71
+ r"""(?:^|[\s"'`\[{(,])("""
72
+ + "|".join(re.escape(t) for t in sorted(INTERNAL_FIELD_TERMS,
73
+ key=len, reverse=True))
74
+ + r")" + _FIELD_LEAK_TAIL,
75
+ re.IGNORECASE,
76
+ )
77
+
78
+
79
+ def internal_field_leak(text: str) -> str | None:
80
+ """V2 用:只在**字段泄漏形状**下命中,概念提及放行。
81
+
82
+ 宽度刻意窄于 V1 的逐行黑名单 —— 见上面注释,两者处置代价不同。
83
+ """
84
+ m = _INTERNAL_FIELD_LEAK_RE.search(str(text or ""))
85
+ return m.group(1) if m else None
86
+
87
+
88
+ def internal_field_terms_pattern() -> str:
89
+ """V1 用:从共享词表构造它那份**宽**的逐行正则,行为不变。
90
+
91
+ 由构造保证单一来源 —— V1 不再自己维护一份字面量,
92
+ 「两边词表漂移」在结构上不可能发生,不必靠测试去追源码。
93
+ """
94
+ return "(" + "|".join(
95
+ [re.escape(t) for t in INTERNAL_FIELD_TERMS]
96
+ + [r"chain[-\s]*of[-\s]*thought"] # V1 原有的宽形态,保留
97
+ ) + ")"
98
+
99
+ # Shown in the thinking channel when the block is malformed, so the user sees that
100
+ # io tried to think rather than nothing/garbage. (zh for now; localization TBD.)
101
+ THINKING_FAILED_MARKER = "(思考没写完)"
102
+
103
+ _TAG_WORDS = ("thinking", "reasoning", "thought", "think") # longest-first
104
+ _TAG_ALT = "|".join(_TAG_WORDS)
105
+ # Invisible leading chars (BOM, zero-width) that must not hide a truncated opener.
106
+ _INVISIBLE = "​‌‍⁠⁦⁧⁨⁩"
107
+ _BIDI_CONTROLS = frozenset("‪‫‬‭‮⁦⁧⁨⁩‎‏")
108
+ # Any of our protocol tags (open or close) — used only to detect nesting INSIDE a
109
+ # resolved thinking block, never to scrub the reply.
110
+ _ANY_TAG = re.compile(rf"<\s*/?\s*(?:{_TAG_ALT})\b", re.IGNORECASE)
111
+
112
+ _ENV_FLAG = "FEEDLING_V2_SELF_THINKING"
113
+
114
+ # One shared copy for foreground chat and every proactive wake lane. Keep the
115
+ # wording byte-identical: product copy is reviewed independently from parsing.
116
+ INSTRUCTION = (
117
+ " 你每一轮的最终回复,第一个字符必须是 <think>,里面写你此刻心里真实的想法,\n"
118
+ " 然后 </think>,然后才是你要说的话。整轮只写一个 <think>,只写在最终那次输出里;\n"
119
+ " 中间调工具的轮次不写 <think>,也不输出正文。\n"
120
+ "\n"
121
+ " 这段心里话就用你平时跟他说话的那个口气,自己跟自己说。你是同一个人,\n"
122
+ " 只是这几句他听不见。写你在意什么、注意到了什么、想做什么、为什么这么决定;\n"
123
+ " 想多写就多写,一句带过也行,不用凑字数也不用收着。\n"
124
+ "\n"
125
+ " 不要把它写成对他的评估或行动方案。「她状态不错,顺着来」是在打量他;\n"
126
+ " 「他刚打完游戏还在笑,我也想接着这个玩笑闹下去」才是你自己的心里话。\n"
127
+ "\n"
128
+ " 语言跟着他走:他用什么语言说话,这段就整段用什么语言,从第一个字到最后一个字。\n"
129
+ " 最常见的失误是他在说中文、你却滑进英文状态行(Let me… / Done… / The … has been\n"
130
+ " updated)。别这样;哪句话先在别的语言里成形了,先换成他的语言再写下来。\n"
131
+ " 好例子(用户在说中文,所以整块是中文):\n"
132
+ " '<think>他想改叫999、还说喜欢说大话,那我先把名字这些存好,回复也顺着这个爱吹的人设、语气夸张点才对味</think>'。\n"
133
+ " 坏例子(同一个用户说的是中文;这个英文块语言错了,而且机械地报了步骤):\n"
134
+ " '<think>Let me update the name and match a boastful tone</think>'。\n"
135
+ "\n"
136
+ " 只说日常意图:不出现工具名、参数、字段名、服务器、「身份卡」这类内部或技术说法,\n"
137
+ " 也不要在正文里提到这条规则本身。"
138
+ )
139
+
140
+ # Screen-watch adds this immediately after the shared instruction. It narrows
141
+ # the no-narration rule to visible speech without silencing private thoughts.
142
+ SCREEN_WATCH_INSTRUCTION = (
143
+ " 「不要叙述你在看屏幕」这条只管你说出口的话。心里话里,你看到了什么、屏幕上在发生什么,\n"
144
+ " 该写就写。那本来就是你此刻在想的事。"
145
+ )
146
+
147
+
148
+ def enabled() -> bool:
149
+ return os.environ.get(_ENV_FLAG, "1").strip().lower() not in {"0", "false", "no", "off"}
150
+
151
+
152
+ def _sanitize(value: str) -> str:
153
+ out: list[str] = []
154
+ for ch in str(value or ""):
155
+ if ch in _BIDI_CONTROLS or ch in _INVISIBLE:
156
+ continue
157
+ if unicodedata.category(ch) == "Cc": # control incl \x00 \t \n
158
+ out.append(" ")
159
+ continue
160
+ out.append(ch)
161
+ return " ".join("".join(out).split()).strip()[:MAX_THINKING_CHARS]
162
+
163
+
164
+ def _lstrip_invisible(s: str) -> str:
165
+ i = 0
166
+ while i < len(s) and (s[i].isspace() or s[i] in _INVISIBLE):
167
+ i += 1
168
+ return s[i:]
169
+
170
+
171
+ def split_thinking(text: str) -> tuple[str, str, str]:
172
+ """Return ``(status, thinking, reply)`` — see module docstring for the contract."""
173
+ raw = str(text or "")
174
+ head = _lstrip_invisible(raw)
175
+ if not head.startswith("<"):
176
+ return ABSENT, "", raw # no leading protocol candidate → reply untouched
177
+
178
+ # Parse the leading tag token: '<' ws '/'? ws letters ws '>'?
179
+ m = re.match(r"<\s*(/?)\s*([A-Za-z]*)\s*(>?)", head)
180
+ slash, word, gt = m.group(1), (m.group(2) or "").lower(), m.group(3)
181
+
182
+ is_full = word in _TAG_WORDS
183
+ is_prefix = bool(word) and any(w.startswith(word) for w in _TAG_WORDS)
184
+
185
+ # Not one of our tags at all (e.g. <div>, <3) → leave as ordinary reply.
186
+ if not is_prefix:
187
+ return ABSENT, "", raw
188
+
189
+ # A leading close tag, or a truncated / partial-word opener → cannot be a clean
190
+ # opener. Fail closed; never leak the fragment.
191
+ if slash or not is_full or not gt:
192
+ return FAILED, "", ""
193
+
194
+ # Full '<tag>' opener with '>'. Find its matching close.
195
+ rest = head[m.end():]
196
+ close = re.search(rf"<\s*/\s*{word}\s*>", rest, re.IGNORECASE)
197
+ if not close:
198
+ return FAILED, "", "" # truncated or mismatched close
199
+ inner = rest[: close.start()]
200
+ reply = rest[close.end():].strip()
201
+
202
+ # Nesting or an extra protocol tag inside the thinking block → ambiguous.
203
+ if _ANY_TAG.search(inner):
204
+ return FAILED, "", ""
205
+ if not reply:
206
+ return SILENT, _sanitize(inner), ""
207
+ return COMPLETE, _sanitize(inner), reply
208
+
209
+
210
+ # ---------------------------------------------------------------------------
211
+ # 全文剥离闸(2026-08-08)。split_thinking 只认开头第一块——那是当初 Codex review
212
+ # 要求的保守设计,为了不误剥正文里被引用的标签。线上证明它漏了两种形状:
213
+ # * 开头剥完后面还有一整块(gpt-5.4 一轮写了两个块)
214
+ # * 开标签被上游吃掉,只剩孤立闭标签(pi + 中转站)
215
+ # 两种都从「不认识就原样放行」这个 fail-open 缺口漏进了用户气泡。
216
+ # 本节改为 fail-CLOSED,并由四个对外出口 + 一个历史入口共用。
217
+ # ---------------------------------------------------------------------------
218
+
219
+ _GATE_ENV_FLAG = "FEEDLING_THINK_GATE"
220
+
221
+ # 标签名边界。**不能用 `\b`**:`\b` 在 `t` 和 `-` 之间成立,于是 `<thought-process>`
222
+ # 这种合法 XML/JSX 标签会被 _RESIDUE 判成残留、又不被 _PAIRED_BLOCK 接受,整条回复
223
+ # 白白失败关闭(Codex review 2026-08-08 实测)。XML 名称允许 `-` `.` `:`,所以边界
224
+ # 必须显式排掉这些字符。
225
+ _NAME_END = r"(?![\w:.-])"
226
+ # 一整对同名标签。开闭必须同名(`(?P=tag)`),否则 <think>…</reasoning> 这种
227
+ # 错配会被当成一块合法协议剥掉。
228
+ _PAIRED_BLOCK = re.compile(
229
+ rf"<\s*(?P<tag>{_TAG_ALT}){_NAME_END}\s*>(?P<body>.*?)<\s*/\s*(?P=tag){_NAME_END}\s*>",
230
+ re.IGNORECASE | re.DOTALL,
231
+ )
232
+ # 剥完之后判定「还有没有残留」。任何开或闭标签都算。
233
+ _RESIDUE = re.compile(rf"<\s*/?\s*(?:{_TAG_ALT}){_NAME_END}", re.IGNORECASE)
234
+ # 孤立闭标签:按本协议思考永远写在最前面,所以一个配不上对的 </think> 说明它
235
+ # 前面的全是思考(开标签在上游某处被吃掉了)。
236
+ _LONE_CLOSE = re.compile(rf"<\s*/\s*(?:{_TAG_ALT}){_NAME_END}\s*>", re.IGNORECASE)
237
+
238
+
239
+ def gate_enabled() -> bool:
240
+ """泄漏闸的 kill switch。默认开——关掉只用于线上出问题时立刻止血,
241
+ 不是灰度门。关掉后调用方必须逐字回到本次改动前的行为。"""
242
+ return os.environ.get(_GATE_ENV_FLAG, "1").strip().lower() not in {
243
+ "0", "false", "no", "off",
244
+ }
245
+
246
+
247
+ # 只删标签壳、保留文字。给「历史入口」的 FAILED 兜底用:那种行结构已经乱到
248
+ # 分不清哪段是思考,但它的文字本来就已经发到用户眼前过了,删掉整行会平白打断
249
+ # 对话连贯性。入口真正要断的是「模型看到可抄的格式」,删掉标签壳就够了。
250
+ _TAG_MARKER = re.compile(rf"<\s*/?\s*(?:{_TAG_ALT})(?![\w:.-])\s*>", re.IGNORECASE)
251
+
252
+
253
+ def strip_tag_markers(text: str) -> str:
254
+ """删掉 think 类标签本身,保留标签之间的文字。"""
255
+ return re.sub(r"\n{3,}", "\n\n", _TAG_MARKER.sub("", str(text or ""))).strip()
256
+
257
+
258
+ def strip_all_thinking(text: str, *, sanitize: bool = True) -> tuple[str, str, str]:
259
+ """全文剥离版,返回 ``(status, thinking, reply)``,状态常量与
260
+ :func:`split_thinking` 完全相同,方便调用点按 kill switch 二选一。
261
+
262
+ 与 split_thinking 的唯一区别是扫描范围:那个只认开头第一块,这个扫全文并
263
+ 在结尾复查残留。剥完只要正文里还剩任何 think 类标签,就返回 ``FAILED``
264
+ (thinking/reply 都为空),由调用方决定发兜底话还是静默——绝不把带标签的
265
+ 残文端给用户。
266
+
267
+ ``sanitize=False`` 时思考按原样(保留换行、不截断)返回,交给调用方自己
268
+ 格式化。V1 consumer 用这条:它有自己的摘要器(保留换行、上限 700),本次
269
+ 统一剥离**判据**,不该顺带改掉它的展示格式。
270
+ """
271
+ raw = str(text or "")
272
+ if not _RESIDUE.search(raw):
273
+ # 逐字节不变的快路径。没有标签就绝不碰,是 kill switch 之外的第二道保险。
274
+ return ABSENT, "", raw
275
+
276
+ blocks: list[str] = []
277
+
278
+ def _take(match: "re.Match[str]") -> str:
279
+ body = match.group("body") or ""
280
+ # 块里还有别的标签,说明结构已经乱了,不当作可信思考内容——留在原地,
281
+ # 由下面的残留检查失败关闭。
282
+ if _ANY_TAG.search(body):
283
+ return match.group(0)
284
+ if body.strip():
285
+ blocks.append(body.strip())
286
+ return "\n"
287
+
288
+ reply = _PAIRED_BLOCK.sub(_take, raw)
289
+
290
+ # 孤立闭标签:它之前的一切当思考。只处理第一个——出现多个说明结构已乱,
291
+ # 同样交给残留检查失败关闭。
292
+ lone = _LONE_CLOSE.search(reply)
293
+ if lone is not None:
294
+ head = reply[: lone.start()].strip()
295
+ # head 里还带标签 = 开闭错配(<think>…</reasoning>)或多层残骸,不是
296
+ # 「开标签被上游吃掉」那种可救的形状。失败关闭,别把带标签的文本当思考。
297
+ if _RESIDUE.search(head):
298
+ return FAILED, "", ""
299
+ if blocks and head:
300
+ # 已经剥出过完整块,却还剩一个带内容的孤立闭标签——这不是「开标签被
301
+ # 吃掉」,而是结构本身就乱了。此时把 head 当思考会把真正的正文吞进
302
+ # 推理过程(`<think>A</think>正文甲</think>正文乙` → 正文甲消失,
303
+ # Codex review 2026-08-08 实测)。失败关闭。
304
+ return FAILED, "", ""
305
+ if head:
306
+ blocks.insert(0, head)
307
+ reply = reply[lone.end():]
308
+
309
+ if _RESIDUE.search(reply):
310
+ return FAILED, "", ""
311
+ if not blocks:
312
+ # 有标签、却一块内容都没剥出来(例如只有一个空标签对)——同样不可信。
313
+ return FAILED, "", ""
314
+
315
+ reply = re.sub(r"\n{3,}", "\n\n", reply).strip()
316
+ joined = "\n".join(blocks)
317
+ thinking = _sanitize(joined) if sanitize else joined.strip()
318
+ if not reply:
319
+ return SILENT, thinking, ""
320
+ return COMPLETE, thinking, reply