workbuddy2api 2.0.0__py3-none-any.whl
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.
- codebuddy_proxy/__main__.py +1572 -0
- codebuddy_proxy/anthropic_adapter.py +439 -0
- codebuddy_proxy/codebuddy_client_demo.py +312 -0
- codebuddy_proxy/desensitize.py +532 -0
- codebuddy_proxy/dsml_parser.py +888 -0
- codebuddy_proxy/projection_metadata.py +410 -0
- codebuddy_proxy/responses_adapter.py +487 -0
- codebuddy_proxy/responses_projection.py +746 -0
- workbuddy2api-2.0.0.dist-info/METADATA +634 -0
- workbuddy2api-2.0.0.dist-info/RECORD +14 -0
- workbuddy2api-2.0.0.dist-info/WHEEL +5 -0
- workbuddy2api-2.0.0.dist-info/entry_points.txt +2 -0
- workbuddy2api-2.0.0.dist-info/licenses/LICENSE +21 -0
- workbuddy2api-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
"""
|
|
2
|
+
responses_projection — /v1/responses 的后端投影层。
|
|
3
|
+
|
|
4
|
+
目标
|
|
5
|
+
----
|
|
6
|
+
Codex CLI 会把大量运行时提示、完整工具 schema、长历史、以及工具输出一并塞进
|
|
7
|
+
/v1/responses 请求里。腾讯后端对这类 agentic payload 很容易触发内容审核,或者
|
|
8
|
+
因为上下文过长而表现不稳定。
|
|
9
|
+
|
|
10
|
+
本模块在保持外部 OpenAI Responses 兼容的前提下,只对发往后端的 Chat body 做
|
|
11
|
+
"最小语义闭包"投影:
|
|
12
|
+
|
|
13
|
+
- 固定短 system 摘要替换 Codex/Claude Code harness
|
|
14
|
+
- 保留最新用户意图
|
|
15
|
+
- 保留最近一段真实 assistant/tool 链路
|
|
16
|
+
- 把更早历史压缩成规则摘要
|
|
17
|
+
- 把超长 tool output / tool arguments 压缩成可继续推理的摘要
|
|
18
|
+
|
|
19
|
+
截断标记格式
|
|
20
|
+
------------
|
|
21
|
+
本模块在截断/压缩文本时会插入人类可读的标记字符串,客户端解析时需要识别这些格式:
|
|
22
|
+
|
|
23
|
+
1. **工具输出行截断** (超过 24 行):
|
|
24
|
+
- 格式: `"... [omitted N lines] ..."`
|
|
25
|
+
- 位置: 工具输出摘要中,头部和尾部之间
|
|
26
|
+
- 示例: `"Key output:\nline1\n... [omitted 100 lines] ...\nRecent tail:\nlast_line"`
|
|
27
|
+
|
|
28
|
+
2. **自由文本字符截断**:
|
|
29
|
+
- 格式: `"... [N chars omitted] ..."`
|
|
30
|
+
- 位置: 长文本的头部和尾部之间
|
|
31
|
+
- 示例: `"start text...\n... [500 chars omitted] ...\n...end text"`
|
|
32
|
+
|
|
33
|
+
3. **文本硬截断**:
|
|
34
|
+
- 格式: `" ... [truncated N chars]"`
|
|
35
|
+
- 位置: 文本末尾
|
|
36
|
+
- 示例: `"long text content ... [truncated 200 chars]"`
|
|
37
|
+
|
|
38
|
+
4. **JSON 深度限制**:
|
|
39
|
+
- 格式: `"<omitted>"` (字符串)
|
|
40
|
+
- 位置: JSON 嵌套深度超过 4 层时
|
|
41
|
+
- 示例: `{"a": {"b": {"c": {"d": {"e": "<omitted>"}}}}}`
|
|
42
|
+
|
|
43
|
+
5. **JSON 键数量限制**:
|
|
44
|
+
- 格式: `{"_omitted_keys": N}` (结构化字段)
|
|
45
|
+
- 位置: 字典超过 12 个键时
|
|
46
|
+
- 示例: `{"key1": "val1", ..., "key12": "val12", "_omitted_keys": 5}`
|
|
47
|
+
|
|
48
|
+
注意: 这些标记是设计特性,确保在上下文受限时仍能传递关键语义信息。
|
|
49
|
+
客户端通常只展示这些文本,无需特殊解析。未来版本可能提供结构化元数据替代方案。
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import json
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
AGENTIC_TOOL_NAMES = {
|
|
59
|
+
"exec_command",
|
|
60
|
+
"write_stdin",
|
|
61
|
+
"update_plan",
|
|
62
|
+
"request_user_input",
|
|
63
|
+
"view_image",
|
|
64
|
+
"get_goal",
|
|
65
|
+
"create_goal",
|
|
66
|
+
"update_goal",
|
|
67
|
+
"apply_patch",
|
|
68
|
+
"tool_search_tool",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
HARNESS_USER_MARKERS = (
|
|
72
|
+
"# AGENTS.md instructions",
|
|
73
|
+
"<environment_context>",
|
|
74
|
+
"<permissions instructions>",
|
|
75
|
+
"<collaboration_mode>",
|
|
76
|
+
"<skills_instructions>",
|
|
77
|
+
"<system-reminder>",
|
|
78
|
+
"# claudeMd",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
HARNESS_SYSTEM_MARKERS = (
|
|
82
|
+
"You are a coding agent running in the Codex CLI",
|
|
83
|
+
"Within this context, Codex refers to",
|
|
84
|
+
"# AGENTS.md spec",
|
|
85
|
+
"<permissions instructions>",
|
|
86
|
+
"<collaboration_mode>",
|
|
87
|
+
"<skills_instructions>",
|
|
88
|
+
"The following deferred tools are now available via ToolSearch.",
|
|
89
|
+
"### Available skills",
|
|
90
|
+
"## request_user_input availability",
|
|
91
|
+
"You are Claude Code",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
BASE_SYSTEM_PROMPT = (
|
|
95
|
+
"You are a coding assistant serving an OpenAI-compatible CLI. "
|
|
96
|
+
"Be precise, concise, safe, and action-oriented. "
|
|
97
|
+
"Use available tools when needed, follow repository instructions and durable user context, "
|
|
98
|
+
"and continue from the preserved recent context. "
|
|
99
|
+
"If earlier history was condensed, rely on the preserved recent messages and rerun tools when exact old details are required."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
HISTORY_PREFIX = "Earlier conversation summary (condensed):"
|
|
103
|
+
|
|
104
|
+
MAX_SYSTEM_GUIDANCE_CHARS = 1200
|
|
105
|
+
MAX_USER_CHARS = 3200
|
|
106
|
+
MAX_ASSISTANT_CHARS = 1800
|
|
107
|
+
MAX_TOOL_OUTPUT_CHARS = 1600
|
|
108
|
+
MAX_TOOL_ARGS_CHARS = 900
|
|
109
|
+
MAX_HISTORY_SUMMARY_CHARS = 2200
|
|
110
|
+
MAX_HISTORY_ITEMS = 10
|
|
111
|
+
MAX_TAIL_MESSAGES = 8
|
|
112
|
+
MAX_TAIL_CHARS = 7000
|
|
113
|
+
|
|
114
|
+
SCHEMA_KEEP_KEYS = {
|
|
115
|
+
"type",
|
|
116
|
+
"properties",
|
|
117
|
+
"required",
|
|
118
|
+
"items",
|
|
119
|
+
"enum",
|
|
120
|
+
"oneOf",
|
|
121
|
+
"anyOf",
|
|
122
|
+
"allOf",
|
|
123
|
+
"additionalProperties",
|
|
124
|
+
"format",
|
|
125
|
+
"minimum",
|
|
126
|
+
"maximum",
|
|
127
|
+
"minItems",
|
|
128
|
+
"maxItems",
|
|
129
|
+
"minLength",
|
|
130
|
+
"maxLength",
|
|
131
|
+
"nullable",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def project_responses_chat_body(body: dict) -> tuple[dict, dict]:
|
|
136
|
+
"""把 Responses 转出来的 Chat body 投影成更适合腾讯后端的最小上下文。"""
|
|
137
|
+
projected = dict(body)
|
|
138
|
+
messages = list(body.get("messages") or [])
|
|
139
|
+
tools = list(body.get("tools") or [])
|
|
140
|
+
|
|
141
|
+
projected_tools, tool_stats = _project_tools(tools)
|
|
142
|
+
if projected_tools:
|
|
143
|
+
projected["tools"] = projected_tools
|
|
144
|
+
elif "tools" in projected:
|
|
145
|
+
projected["tools"] = []
|
|
146
|
+
|
|
147
|
+
aggressive = _looks_like_agentic_cli(messages, tools)
|
|
148
|
+
if not aggressive:
|
|
149
|
+
projected["messages"] = _project_messages_conservative(messages)
|
|
150
|
+
return projected, {
|
|
151
|
+
"mode": "conservative",
|
|
152
|
+
"aggressive": False,
|
|
153
|
+
"original_messages": len(messages),
|
|
154
|
+
"projected_messages": len(projected["messages"]),
|
|
155
|
+
"original_message_chars": _messages_size(messages),
|
|
156
|
+
"projected_message_chars": _messages_size(projected["messages"]),
|
|
157
|
+
**tool_stats,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
tool_name_by_call_id = _build_tool_call_name_map(messages)
|
|
161
|
+
preserved_guidance: list[str] = []
|
|
162
|
+
conversation: list[dict] = []
|
|
163
|
+
dropped_harness_messages = 0
|
|
164
|
+
|
|
165
|
+
for msg in messages:
|
|
166
|
+
if not isinstance(msg, dict):
|
|
167
|
+
continue
|
|
168
|
+
role = msg.get("role")
|
|
169
|
+
text = _content_to_text(msg.get("content", ""))
|
|
170
|
+
|
|
171
|
+
if role == "system":
|
|
172
|
+
if _looks_like_harness_system(text):
|
|
173
|
+
dropped_harness_messages += 1
|
|
174
|
+
continue
|
|
175
|
+
guidance = _truncate_text(text, MAX_SYSTEM_GUIDANCE_CHARS)
|
|
176
|
+
if guidance:
|
|
177
|
+
preserved_guidance.append(guidance)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if role == "user" and _looks_like_harness_user(text):
|
|
181
|
+
dropped_harness_messages += 1
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
projected_msg = _project_conversation_message(msg)
|
|
185
|
+
if projected_msg is not None:
|
|
186
|
+
conversation.append(projected_msg)
|
|
187
|
+
|
|
188
|
+
if not conversation:
|
|
189
|
+
conversation = _project_messages_conservative(messages)
|
|
190
|
+
|
|
191
|
+
tail_start = _choose_tail_start(conversation)
|
|
192
|
+
tail_start = _expand_tail_for_tool_context(conversation, tail_start)
|
|
193
|
+
latest_user_idx = _latest_user_index(conversation)
|
|
194
|
+
|
|
195
|
+
anchor_user = None
|
|
196
|
+
if latest_user_idx is not None and latest_user_idx < tail_start:
|
|
197
|
+
anchor_user = dict(conversation[latest_user_idx])
|
|
198
|
+
|
|
199
|
+
omitted: list[dict] = []
|
|
200
|
+
for idx, msg in enumerate(conversation):
|
|
201
|
+
if idx >= tail_start:
|
|
202
|
+
break
|
|
203
|
+
if latest_user_idx is not None and idx == latest_user_idx and anchor_user is not None:
|
|
204
|
+
continue
|
|
205
|
+
omitted.append(msg)
|
|
206
|
+
|
|
207
|
+
final_messages: list[dict] = [{"role": "system", "content": BASE_SYSTEM_PROMPT}]
|
|
208
|
+
guidance_message = _merge_guidance_messages(preserved_guidance)
|
|
209
|
+
if guidance_message:
|
|
210
|
+
final_messages.append({"role": "system", "content": guidance_message})
|
|
211
|
+
|
|
212
|
+
history_summary = _build_history_summary(omitted, tool_name_by_call_id)
|
|
213
|
+
if history_summary:
|
|
214
|
+
final_messages.append({"role": "system", "content": history_summary})
|
|
215
|
+
|
|
216
|
+
if anchor_user is not None:
|
|
217
|
+
final_messages.append(anchor_user)
|
|
218
|
+
|
|
219
|
+
final_messages.extend(conversation[tail_start:])
|
|
220
|
+
projected["messages"] = final_messages
|
|
221
|
+
|
|
222
|
+
return projected, {
|
|
223
|
+
"mode": "aggressive",
|
|
224
|
+
"aggressive": True,
|
|
225
|
+
"dropped_harness_messages": dropped_harness_messages,
|
|
226
|
+
"preserved_guidance_messages": len(preserved_guidance),
|
|
227
|
+
"summarized_history_messages": len(omitted),
|
|
228
|
+
"anchor_user_preserved": anchor_user is not None,
|
|
229
|
+
"tail_messages": len(conversation[tail_start:]),
|
|
230
|
+
"original_messages": len(messages),
|
|
231
|
+
"projected_messages": len(final_messages),
|
|
232
|
+
"original_message_chars": _messages_size(messages),
|
|
233
|
+
"projected_message_chars": _messages_size(final_messages),
|
|
234
|
+
**tool_stats,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _looks_like_agentic_cli(messages: list[dict], tools: list[dict]) -> bool:
|
|
239
|
+
tool_names = {
|
|
240
|
+
_tool_name(tool)
|
|
241
|
+
for tool in tools
|
|
242
|
+
if _tool_name(tool)
|
|
243
|
+
}
|
|
244
|
+
if tool_names & AGENTIC_TOOL_NAMES:
|
|
245
|
+
return True
|
|
246
|
+
|
|
247
|
+
for msg in messages:
|
|
248
|
+
if not isinstance(msg, dict):
|
|
249
|
+
continue
|
|
250
|
+
text = _content_to_text(msg.get("content", ""))
|
|
251
|
+
if _looks_like_harness_user(text) or _looks_like_harness_system(text):
|
|
252
|
+
return True
|
|
253
|
+
return False
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _project_messages_conservative(messages: list[dict]) -> list[dict]:
|
|
257
|
+
out: list[dict] = []
|
|
258
|
+
for msg in messages:
|
|
259
|
+
projected = _project_conversation_message(msg, conservative=True)
|
|
260
|
+
if projected is not None:
|
|
261
|
+
out.append(projected)
|
|
262
|
+
return out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _project_conversation_message(msg: dict, conservative: bool = False) -> dict | None:
|
|
266
|
+
if not isinstance(msg, dict):
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
role = msg.get("role")
|
|
270
|
+
out = dict(msg)
|
|
271
|
+
|
|
272
|
+
if role == "system":
|
|
273
|
+
text = _content_to_text(msg.get("content", ""))
|
|
274
|
+
out["content"] = _truncate_text(text, MAX_SYSTEM_GUIDANCE_CHARS)
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
if role == "user":
|
|
278
|
+
text = _content_to_text(msg.get("content", ""))
|
|
279
|
+
out["content"] = _truncate_text(text, MAX_USER_CHARS)
|
|
280
|
+
return out
|
|
281
|
+
|
|
282
|
+
if role == "assistant":
|
|
283
|
+
text = _content_to_text(msg.get("content", ""))
|
|
284
|
+
out["content"] = _summarize_free_text(text, MAX_ASSISTANT_CHARS)
|
|
285
|
+
tool_calls = []
|
|
286
|
+
for tool_call in msg.get("tool_calls") or []:
|
|
287
|
+
projected_call = _project_tool_call(tool_call)
|
|
288
|
+
if projected_call is not None:
|
|
289
|
+
tool_calls.append(projected_call)
|
|
290
|
+
if tool_calls:
|
|
291
|
+
out["tool_calls"] = tool_calls
|
|
292
|
+
elif "tool_calls" in out:
|
|
293
|
+
out.pop("tool_calls", None)
|
|
294
|
+
return out
|
|
295
|
+
|
|
296
|
+
if role == "tool":
|
|
297
|
+
out["content"] = _summarize_tool_output(_content_to_text(msg.get("content", "")))
|
|
298
|
+
return out
|
|
299
|
+
|
|
300
|
+
if conservative:
|
|
301
|
+
text = _content_to_text(msg.get("content", ""))
|
|
302
|
+
out["content"] = _truncate_text(text, MAX_ASSISTANT_CHARS)
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _project_tool_call(tool_call: dict) -> dict | None:
|
|
309
|
+
if not isinstance(tool_call, dict):
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
function = tool_call.get("function") or {}
|
|
313
|
+
name = function.get("name", "")
|
|
314
|
+
arguments = function.get("arguments", "")
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"id": tool_call.get("id"),
|
|
318
|
+
"type": tool_call.get("type", "function"),
|
|
319
|
+
"function": {
|
|
320
|
+
"name": name,
|
|
321
|
+
"arguments": _summarize_tool_arguments(name, arguments),
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _summarize_tool_arguments(name: str, arguments: Any) -> str:
|
|
327
|
+
if not isinstance(arguments, str):
|
|
328
|
+
try:
|
|
329
|
+
return json.dumps(arguments, ensure_ascii=False)
|
|
330
|
+
except Exception:
|
|
331
|
+
return json.dumps({"summary": _truncate_text(str(arguments), 240)}, ensure_ascii=False)
|
|
332
|
+
|
|
333
|
+
if len(arguments) <= MAX_TOOL_ARGS_CHARS:
|
|
334
|
+
return arguments
|
|
335
|
+
|
|
336
|
+
if name == "apply_patch":
|
|
337
|
+
return json.dumps(
|
|
338
|
+
{"summary": "Large apply_patch payload omitted; a patch was prepared or applied in a previous step."},
|
|
339
|
+
ensure_ascii=False,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
parsed = json.loads(arguments)
|
|
344
|
+
except Exception:
|
|
345
|
+
return json.dumps({"summary": _truncate_text(arguments, 320)}, ensure_ascii=False)
|
|
346
|
+
|
|
347
|
+
return json.dumps(_shrink_json_value(parsed), ensure_ascii=False)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _shrink_json_value(value: Any, depth: int = 0, key: str = "") -> Any:
|
|
351
|
+
if depth >= 4:
|
|
352
|
+
return "<omitted>"
|
|
353
|
+
|
|
354
|
+
if isinstance(value, dict):
|
|
355
|
+
out = {}
|
|
356
|
+
items = list(value.items())
|
|
357
|
+
for idx, (item_key, item_value) in enumerate(items):
|
|
358
|
+
if idx >= 12:
|
|
359
|
+
out["_omitted_keys"] = len(items) - idx
|
|
360
|
+
break
|
|
361
|
+
out[item_key] = _shrink_json_value(item_value, depth + 1, item_key)
|
|
362
|
+
return out
|
|
363
|
+
|
|
364
|
+
if isinstance(value, list):
|
|
365
|
+
# 截断长列表,但不添加字符串占位符(保持类型一致)
|
|
366
|
+
max_list = 6
|
|
367
|
+
if len(value) > max_list:
|
|
368
|
+
return [_shrink_json_value(item, depth + 1, key) for item in value[:max_list]]
|
|
369
|
+
return [_shrink_json_value(item, depth + 1, key) for item in value]
|
|
370
|
+
|
|
371
|
+
if isinstance(value, str):
|
|
372
|
+
limit = 240 if key in {"cmd", "chars", "patch", "content", "text", "question"} else 120
|
|
373
|
+
return _truncate_text(value, limit)
|
|
374
|
+
|
|
375
|
+
return value
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _project_tools(tools: list[dict]) -> tuple[list[dict], dict]:
|
|
379
|
+
projected = []
|
|
380
|
+
original_chars = _tools_size(tools)
|
|
381
|
+
|
|
382
|
+
for tool in tools:
|
|
383
|
+
if not isinstance(tool, dict):
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if tool.get("type") != "function":
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
function = tool.get("function") or tool
|
|
390
|
+
name = function.get("name")
|
|
391
|
+
if not name:
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
projected_function: dict[str, Any] = {"name": name}
|
|
395
|
+
if "parameters" in function:
|
|
396
|
+
projected_function["parameters"] = _project_schema(function.get("parameters"))
|
|
397
|
+
if "strict" in function:
|
|
398
|
+
projected_function["strict"] = function.get("strict")
|
|
399
|
+
|
|
400
|
+
projected.append({"type": "function", "function": projected_function})
|
|
401
|
+
|
|
402
|
+
return projected, {
|
|
403
|
+
"original_tools": len(tools),
|
|
404
|
+
"projected_tools": len(projected),
|
|
405
|
+
"original_tool_chars": original_chars,
|
|
406
|
+
"projected_tool_chars": _tools_size(projected),
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _project_schema(schema: Any, depth: int = 0) -> Any:
|
|
411
|
+
if depth >= 6:
|
|
412
|
+
return {"type": "object"}
|
|
413
|
+
|
|
414
|
+
if isinstance(schema, dict):
|
|
415
|
+
out: dict[str, Any] = {}
|
|
416
|
+
for key, value in schema.items():
|
|
417
|
+
if key not in SCHEMA_KEEP_KEYS:
|
|
418
|
+
continue
|
|
419
|
+
if key == "properties" and isinstance(value, dict):
|
|
420
|
+
out["properties"] = {
|
|
421
|
+
prop: _project_schema(prop_schema, depth + 1)
|
|
422
|
+
for prop, prop_schema in value.items()
|
|
423
|
+
}
|
|
424
|
+
elif key == "items":
|
|
425
|
+
out["items"] = _project_schema(value, depth + 1)
|
|
426
|
+
elif key in {"oneOf", "anyOf", "allOf"} and isinstance(value, list):
|
|
427
|
+
out[key] = [_project_schema(item, depth + 1) for item in value[:6]]
|
|
428
|
+
elif key == "additionalProperties" and isinstance(value, dict):
|
|
429
|
+
out[key] = _project_schema(value, depth + 1)
|
|
430
|
+
else:
|
|
431
|
+
out[key] = value
|
|
432
|
+
return out or {"type": "object"}
|
|
433
|
+
|
|
434
|
+
if isinstance(schema, list):
|
|
435
|
+
return [_project_schema(item, depth + 1) for item in schema[:6]]
|
|
436
|
+
|
|
437
|
+
return schema
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _choose_tail_start(messages: list[dict]) -> int:
|
|
441
|
+
if not messages:
|
|
442
|
+
return 0
|
|
443
|
+
|
|
444
|
+
start = len(messages) - 1
|
|
445
|
+
total_chars = 0
|
|
446
|
+
kept = 0
|
|
447
|
+
|
|
448
|
+
for idx in range(len(messages) - 1, -1, -1):
|
|
449
|
+
cost = _message_cost(messages[idx])
|
|
450
|
+
if kept > 0 and (kept >= MAX_TAIL_MESSAGES or total_chars + cost > MAX_TAIL_CHARS):
|
|
451
|
+
break
|
|
452
|
+
start = idx
|
|
453
|
+
total_chars += cost
|
|
454
|
+
kept += 1
|
|
455
|
+
return start
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _expand_tail_for_tool_context(messages: list[dict], start: int) -> int:
|
|
459
|
+
if start <= 0 or not messages:
|
|
460
|
+
return start
|
|
461
|
+
|
|
462
|
+
needed_call_ids = {
|
|
463
|
+
msg.get("tool_call_id")
|
|
464
|
+
for msg in messages[start:]
|
|
465
|
+
if isinstance(msg, dict) and msg.get("role") == "tool" and msg.get("tool_call_id")
|
|
466
|
+
}
|
|
467
|
+
if not needed_call_ids:
|
|
468
|
+
return start
|
|
469
|
+
|
|
470
|
+
expanded = start
|
|
471
|
+
for idx in range(start - 1, -1, -1):
|
|
472
|
+
msg = messages[idx]
|
|
473
|
+
if msg.get("role") != "assistant":
|
|
474
|
+
continue
|
|
475
|
+
call_ids = {
|
|
476
|
+
tool_call.get("id")
|
|
477
|
+
for tool_call in msg.get("tool_calls") or []
|
|
478
|
+
if isinstance(tool_call, dict)
|
|
479
|
+
}
|
|
480
|
+
if call_ids & needed_call_ids:
|
|
481
|
+
expanded = idx
|
|
482
|
+
needed_call_ids -= call_ids
|
|
483
|
+
if not needed_call_ids:
|
|
484
|
+
break
|
|
485
|
+
return expanded
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _latest_user_index(messages: list[dict]) -> int | None:
|
|
489
|
+
for idx in range(len(messages) - 1, -1, -1):
|
|
490
|
+
if messages[idx].get("role") == "user":
|
|
491
|
+
return idx
|
|
492
|
+
return None
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _build_history_summary(messages: list[dict], tool_name_by_call_id: dict[str, str]) -> str:
|
|
496
|
+
lines: list[str] = []
|
|
497
|
+
total_chars = 0
|
|
498
|
+
summarized = 0
|
|
499
|
+
|
|
500
|
+
for msg in messages:
|
|
501
|
+
line = _history_line(msg, tool_name_by_call_id)
|
|
502
|
+
if not line:
|
|
503
|
+
continue
|
|
504
|
+
if summarized >= MAX_HISTORY_ITEMS or total_chars + len(line) > MAX_HISTORY_SUMMARY_CHARS:
|
|
505
|
+
break
|
|
506
|
+
lines.append(f"- {line}")
|
|
507
|
+
total_chars += len(line)
|
|
508
|
+
summarized += 1
|
|
509
|
+
|
|
510
|
+
remaining = len(messages) - summarized
|
|
511
|
+
if remaining > 0:
|
|
512
|
+
lines.append(f"- {remaining} earlier messages or tool results were further condensed.")
|
|
513
|
+
|
|
514
|
+
if not lines:
|
|
515
|
+
return ""
|
|
516
|
+
return HISTORY_PREFIX + "\n" + "\n".join(lines)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _history_line(msg: dict, tool_name_by_call_id: dict[str, str]) -> str:
|
|
520
|
+
role = msg.get("role")
|
|
521
|
+
text = _content_to_text(msg.get("content", ""))
|
|
522
|
+
|
|
523
|
+
if role == "user":
|
|
524
|
+
return f"User asked: {_truncate_text(text, 220)}"
|
|
525
|
+
|
|
526
|
+
if role == "assistant":
|
|
527
|
+
tool_names = [
|
|
528
|
+
(tool_call.get("function") or {}).get("name")
|
|
529
|
+
for tool_call in msg.get("tool_calls") or []
|
|
530
|
+
if isinstance(tool_call, dict)
|
|
531
|
+
]
|
|
532
|
+
tool_names = [name for name in tool_names if name]
|
|
533
|
+
if text and tool_names:
|
|
534
|
+
return f"Assistant replied: {_truncate_text(text, 160)} Then called tools: {', '.join(tool_names[:4])}."
|
|
535
|
+
if tool_names:
|
|
536
|
+
return f"Assistant called tools: {', '.join(tool_names[:4])}."
|
|
537
|
+
if text:
|
|
538
|
+
return f"Assistant replied: {_truncate_text(text, 180)}"
|
|
539
|
+
return ""
|
|
540
|
+
|
|
541
|
+
if role == "tool":
|
|
542
|
+
tool_name = tool_name_by_call_id.get(msg.get("tool_call_id", ""), "tool")
|
|
543
|
+
summary = _tool_output_inline_summary(text)
|
|
544
|
+
return f"Tool {tool_name} returned: {summary}"
|
|
545
|
+
|
|
546
|
+
if role == "system":
|
|
547
|
+
return f"System guidance: {_truncate_text(text, 180)}"
|
|
548
|
+
|
|
549
|
+
return ""
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _build_tool_call_name_map(messages: list[dict]) -> dict[str, str]:
|
|
553
|
+
mapping: dict[str, str] = {}
|
|
554
|
+
for msg in messages:
|
|
555
|
+
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
|
556
|
+
continue
|
|
557
|
+
for tool_call in msg.get("tool_calls") or []:
|
|
558
|
+
if not isinstance(tool_call, dict):
|
|
559
|
+
continue
|
|
560
|
+
call_id = tool_call.get("id")
|
|
561
|
+
name = (tool_call.get("function") or {}).get("name")
|
|
562
|
+
if call_id and name:
|
|
563
|
+
mapping[call_id] = name
|
|
564
|
+
return mapping
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _merge_guidance_messages(messages: list[str]) -> str:
|
|
568
|
+
merged: list[str] = []
|
|
569
|
+
total = 0
|
|
570
|
+
for message in messages[:2]:
|
|
571
|
+
text = message.strip()
|
|
572
|
+
if not text:
|
|
573
|
+
continue
|
|
574
|
+
if total + len(text) > MAX_SYSTEM_GUIDANCE_CHARS:
|
|
575
|
+
text = _truncate_text(text, MAX_SYSTEM_GUIDANCE_CHARS - total)
|
|
576
|
+
merged.append(text)
|
|
577
|
+
total += len(text)
|
|
578
|
+
if total >= MAX_SYSTEM_GUIDANCE_CHARS:
|
|
579
|
+
break
|
|
580
|
+
if not merged:
|
|
581
|
+
return ""
|
|
582
|
+
if len(merged) == 1:
|
|
583
|
+
return merged[0]
|
|
584
|
+
return "Additional instructions:\n" + "\n\n".join(merged)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _summarize_tool_output(text: str) -> str:
|
|
588
|
+
"""
|
|
589
|
+
压缩工具输出到 MAX_TOOL_OUTPUT_CHARS (1024) 字符以内。
|
|
590
|
+
|
|
591
|
+
截断策略:
|
|
592
|
+
- 保留前 10 行 + 后 6 行
|
|
593
|
+
- 中间省略部分插入: "... [omitted N lines] ..."
|
|
594
|
+
- 如果最终仍超长,再用 _truncate_text 硬截断
|
|
595
|
+
|
|
596
|
+
客户端解析: 这些标记字符串是人类可读的语义提示,通常直接展示即可。
|
|
597
|
+
"""
|
|
598
|
+
if not text:
|
|
599
|
+
return ""
|
|
600
|
+
if len(text) <= MAX_TOOL_OUTPUT_CHARS and text.count("\n") <= 24:
|
|
601
|
+
return text
|
|
602
|
+
|
|
603
|
+
lines = text.splitlines()
|
|
604
|
+
exit_line = next((line.strip() for line in lines if "Process exited with code" in line), "")
|
|
605
|
+
useful_lines = []
|
|
606
|
+
saw_output = False
|
|
607
|
+
for line in lines:
|
|
608
|
+
stripped = line.rstrip()
|
|
609
|
+
if stripped == "Output:":
|
|
610
|
+
saw_output = True
|
|
611
|
+
continue
|
|
612
|
+
if (
|
|
613
|
+
stripped.startswith("Chunk ID:")
|
|
614
|
+
or stripped.startswith("Wall time:")
|
|
615
|
+
or stripped.startswith("Original token count:")
|
|
616
|
+
or stripped.startswith("Process exited with code")
|
|
617
|
+
):
|
|
618
|
+
continue
|
|
619
|
+
useful_lines.append(stripped)
|
|
620
|
+
|
|
621
|
+
body_lines = useful_lines
|
|
622
|
+
|
|
623
|
+
head = body_lines[:10]
|
|
624
|
+
tail = body_lines[-6:] if len(body_lines) > 16 else []
|
|
625
|
+
omitted = max(len(body_lines) - len(head) - len(tail), 0)
|
|
626
|
+
|
|
627
|
+
parts: list[str] = []
|
|
628
|
+
if exit_line:
|
|
629
|
+
parts.append(exit_line)
|
|
630
|
+
if head:
|
|
631
|
+
parts.append("Key output:")
|
|
632
|
+
parts.extend(head)
|
|
633
|
+
if omitted:
|
|
634
|
+
parts.append(f"... [omitted {omitted} lines] ...")
|
|
635
|
+
if tail:
|
|
636
|
+
parts.append("Recent tail:")
|
|
637
|
+
parts.extend(tail)
|
|
638
|
+
|
|
639
|
+
summary = "\n".join(part for part in parts if part).strip()
|
|
640
|
+
return _truncate_text(summary or text, MAX_TOOL_OUTPUT_CHARS)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _tool_output_inline_summary(text: str) -> str:
|
|
644
|
+
summarized = _summarize_tool_output(text)
|
|
645
|
+
summarized = summarized.replace("\n", " | ")
|
|
646
|
+
return _truncate_text(summarized, 220)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _summarize_free_text(text: str, limit: int) -> str:
|
|
650
|
+
"""
|
|
651
|
+
压缩自由文本到指定字符限制。
|
|
652
|
+
|
|
653
|
+
截断策略:
|
|
654
|
+
- 保留前 limit//2 字符 + 后 limit//3 字符
|
|
655
|
+
- 中间插入: "... [N chars omitted] ..."
|
|
656
|
+
|
|
657
|
+
客户端解析: 标记字符串是人类可读的,直接展示即可。
|
|
658
|
+
"""
|
|
659
|
+
if not text:
|
|
660
|
+
return ""
|
|
661
|
+
if len(text) <= limit:
|
|
662
|
+
return text
|
|
663
|
+
|
|
664
|
+
head = text[: limit // 2].rstrip()
|
|
665
|
+
tail = text[-(limit // 3):].lstrip()
|
|
666
|
+
omitted = len(text) - len(head) - len(tail)
|
|
667
|
+
return f"{head}\n... [{omitted} chars omitted] ...\n{tail}"
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _truncate_text(text: str, limit: int) -> str:
|
|
671
|
+
"""
|
|
672
|
+
硬截断文本到指定字符限制。
|
|
673
|
+
|
|
674
|
+
截断策略:
|
|
675
|
+
- 保留前 (limit - 24) 字符
|
|
676
|
+
- 末尾追加: " ... [truncated N chars]"
|
|
677
|
+
|
|
678
|
+
客户端解析: 标记字符串是人类可读的,直接展示即可。
|
|
679
|
+
"""
|
|
680
|
+
if not text:
|
|
681
|
+
return ""
|
|
682
|
+
if len(text) <= limit:
|
|
683
|
+
return text
|
|
684
|
+
return text[: max(limit - 24, 0)].rstrip() + f" ... [truncated {len(text) - max(limit - 24, 0)} chars]"
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _content_to_text(content: Any) -> str:
|
|
688
|
+
if content is None:
|
|
689
|
+
return ""
|
|
690
|
+
if isinstance(content, str):
|
|
691
|
+
return content
|
|
692
|
+
if isinstance(content, list):
|
|
693
|
+
parts = []
|
|
694
|
+
for block in content:
|
|
695
|
+
if isinstance(block, dict):
|
|
696
|
+
if "text" in block:
|
|
697
|
+
parts.append(str(block.get("text", "")))
|
|
698
|
+
elif "output" in block:
|
|
699
|
+
parts.append(str(block.get("output", "")))
|
|
700
|
+
elif isinstance(block, str):
|
|
701
|
+
parts.append(block)
|
|
702
|
+
return "".join(parts)
|
|
703
|
+
return str(content)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _looks_like_harness_user(text: str) -> bool:
|
|
707
|
+
return any(marker in text for marker in HARNESS_USER_MARKERS)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _looks_like_harness_system(text: str) -> bool:
|
|
711
|
+
return any(marker in text for marker in HARNESS_SYSTEM_MARKERS)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _message_cost(msg: dict) -> int:
|
|
715
|
+
cost = len(_content_to_text(msg.get("content", "")))
|
|
716
|
+
for tool_call in msg.get("tool_calls") or []:
|
|
717
|
+
if not isinstance(tool_call, dict):
|
|
718
|
+
continue
|
|
719
|
+
function = tool_call.get("function") or {}
|
|
720
|
+
cost += len(function.get("name", ""))
|
|
721
|
+
cost += len(function.get("arguments", ""))
|
|
722
|
+
return cost
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _messages_size(messages: list[dict]) -> int:
|
|
726
|
+
total = 0
|
|
727
|
+
for msg in messages:
|
|
728
|
+
if not isinstance(msg, dict):
|
|
729
|
+
continue
|
|
730
|
+
total += _message_cost(msg)
|
|
731
|
+
total += len(msg.get("role", ""))
|
|
732
|
+
return total
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _tool_name(tool: dict) -> str:
|
|
736
|
+
if not isinstance(tool, dict):
|
|
737
|
+
return ""
|
|
738
|
+
function = tool.get("function") or tool
|
|
739
|
+
return str(function.get("name", "") or "")
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _tools_size(tools: list[dict]) -> int:
|
|
743
|
+
try:
|
|
744
|
+
return len(json.dumps(tools, ensure_ascii=False))
|
|
745
|
+
except Exception:
|
|
746
|
+
return 0
|