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.
@@ -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