zero-agent-core 0.2.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.
Files changed (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,428 @@
1
+ """CompactionMiddleware:窗口压力下的自动上下文压缩。
2
+
3
+ 挂在 before_model 上,每次模型调用前做一次判断:
4
+
5
+ 计数 → 未达阈值放行 → 达阈值则先瘦身超长工具输出、再把头部旧消息
6
+ 压成结构化检查点 → MODIFY
7
+
8
+ 另外实现 handle_exception:模型侧真报"上下文超长"时(计数低估、工具
9
+ schema 变大等情况),无视阈值强制压一轮并让循环重试。
10
+
11
+ 设计基调与项目一致:主路径优雅,失败有兜底。任何一步出问题(计数不可用、
12
+ 摘要失败、结构不合规、摘要没变小)都只记录日志并返回 CONTINUE,
13
+ 让后续的 TokenLimitMiddleware 继续执行硬截断,主流程与用户都无感知;
14
+ 唯一的例外是工具输出瘦身成功但摘要失败——此时保留瘦身结果。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Awaitable, Callable
21
+ from typing import Any
22
+
23
+ from agent_core.compaction.prompts import (
24
+ DEFAULT_COMPACTION_INSTRUCTION,
25
+ DEFAULT_SUMMARY_SECTIONS,
26
+ )
27
+ from agent_core.compaction.pruner import (
28
+ PruneConfig,
29
+ prune_tool_results,
30
+ )
31
+ from agent_core.compaction.region import (
32
+ balanced_cut,
33
+ build_checkpoint,
34
+ estimate_tokens,
35
+ extract_previous_summary,
36
+ rebuild,
37
+ select_cut,
38
+ )
39
+ from agent_core.compaction.spill import SpillStore
40
+ from agent_core.compaction.summarizer import summarize
41
+ from agent_core.compaction.types import (
42
+ SUMMARY_SOURCE,
43
+ CompactionConfig,
44
+ CompactionError,
45
+ CompactionResult,
46
+ )
47
+ from agent_core.middleware.base import (
48
+ Middleware,
49
+ MiddlewareAction,
50
+ MiddlewareContext,
51
+ MiddlewareResult,
52
+ )
53
+ from agent_core.model import ContextUsage, ModelAdapter
54
+ from agent_core.protocol.messages import Message
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ # 计数能力:接收 (messages, tools=...) 并返回真实上下文占用
59
+ CountTokens = Callable[..., Awaitable[ContextUsage]]
60
+ # 可观测事件上报钩子(独立使用中间件时的兜底;正常情况下走 ctx.emit)
61
+ EmitHook = Callable[[str, dict[str, Any]], None]
62
+
63
+ # ctx.metadata 中记录"上下文超长重试"次数的键
64
+ OVERFLOW_RETRY_KEY = "compaction_overflow_retries"
65
+
66
+ # 各家模型"输入超长"报错的共同特征(OpenAI / Anthropic / Qwen / Gemini 措辞不一)
67
+ _OVERFLOW_MARKERS = (
68
+ "context_length_exceeded",
69
+ "context length",
70
+ "context window",
71
+ "maximum context",
72
+ "prompt is too long",
73
+ "too many tokens",
74
+ "input length exceeds",
75
+ "reduce the length of the messages",
76
+ "maximum number of tokens",
77
+ )
78
+
79
+
80
+ def is_context_overflow_error(exc: BaseException) -> bool:
81
+ """判断异常是否属于"上下文超长"。"""
82
+ text = str(exc).lower()
83
+ return any(marker in text for marker in _OVERFLOW_MARKERS)
84
+
85
+
86
+ class CompactionMiddleware(Middleware):
87
+ """把窗口压力下的头部旧消息压成结构化摘要。
88
+
89
+ 依赖由构造注入(中间件的 before_model 拿不到 LLM):
90
+ llm : 计数来源,默认取其 context_usage;
91
+ summarizer : 摘要模型,默认复用主模型(可换成更便宜的小模型);
92
+ tool_definitions: 计数时需要带上工具 schema,才能算出真实占用。
93
+
94
+ 用法:
95
+ agent = ReActAgent(
96
+ middleware=[
97
+ CompactionMiddleware(llm, tool_definitions=tool_definitions),
98
+ TokenLimitMiddleware(...), # 兜底:压缩失败时硬截断
99
+ ],
100
+ )
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ llm: ModelAdapter,
106
+ *,
107
+ config: CompactionConfig | None = None,
108
+ tool_definitions: list[dict[str, Any]] | None = None,
109
+ count_tokens: CountTokens | None = None,
110
+ summarizer: ModelAdapter | None = None,
111
+ prune_config: PruneConfig | None = None,
112
+ spill_store: SpillStore | None = None,
113
+ emit: EmitHook | None = None,
114
+ summary_instruction: str = DEFAULT_COMPACTION_INSTRUCTION,
115
+ summary_sections: tuple[str, ...] = DEFAULT_SUMMARY_SECTIONS,
116
+ ) -> None:
117
+ self._config = config or CompactionConfig()
118
+ self._tool_definitions = tool_definitions
119
+ self._count_tokens: CountTokens = count_tokens or llm.context_usage
120
+ self._summarizer = summarizer or llm
121
+ self._prune_config = prune_config or PruneConfig()
122
+ self._spill_store = spill_store
123
+ self._emit_hook = emit
124
+ self._summary_instruction = summary_instruction
125
+ self._summary_sections = summary_sections
126
+ # 最近一次量到的窗口大小:溢出恢复路径拿不到 usage 时用它兜底
127
+ self._last_window: int | None = None
128
+
129
+ # ──────────────────────────────────────────────
130
+ # 入口
131
+ # ──────────────────────────────────────────────
132
+
133
+ async def before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
134
+ config = self._config
135
+ if not config.enabled or not ctx.messages:
136
+ return MiddlewareResult(action=MiddlewareAction.CONTINUE)
137
+
138
+ usage = await self._safe_measure(ctx.messages)
139
+ if usage is None or not usage.input_tokens or not usage.context_window_tokens:
140
+ # 没有真实占用/窗口信息就无法判断压力,交给兜底中间件
141
+ return MiddlewareResult(action=MiddlewareAction.CONTINUE)
142
+
143
+ window = usage.context_window_tokens
144
+ self._last_window = window
145
+ before_tokens = usage.input_tokens
146
+ ratio = before_tokens / window
147
+ if ratio < config.threshold_ratio:
148
+ return MiddlewareResult(action=MiddlewareAction.CONTINUE)
149
+
150
+ self._emit(
151
+ ctx,
152
+ "compaction_started",
153
+ before_tokens=before_tokens,
154
+ window=window,
155
+ ratio=round(ratio, 4),
156
+ )
157
+
158
+ try:
159
+ result = await self._compact(ctx.messages, window=window, before_tokens=before_tokens)
160
+ except Exception as exc:
161
+ logger.warning("[Compaction] 压缩失败,降级到硬截断:%s", exc)
162
+ self._emit(ctx, "compaction_failed", reason=str(exc))
163
+ self._emit(ctx, "compaction_fallback", strategy="trim")
164
+ return MiddlewareResult(action=MiddlewareAction.CONTINUE)
165
+
166
+ if not result.compacted:
167
+ logger.info("[Compaction] 本次未压缩:%s", result.reason)
168
+ self._emit(ctx, "compaction_failed", reason=result.reason)
169
+ self._emit(ctx, "compaction_fallback", strategy="trim")
170
+ return MiddlewareResult(action=MiddlewareAction.CONTINUE)
171
+
172
+ logger.info(
173
+ "[Compaction] 压缩完成:%d → %d 条消息,压入摘要 %d 条,"
174
+ "窗口占用 %d → %d tokens(%.0f%%),reason=%s",
175
+ len(ctx.messages),
176
+ len(result.messages),
177
+ result.cut,
178
+ result.before_tokens,
179
+ result.after_tokens,
180
+ result.after_tokens / window * 100,
181
+ result.reason,
182
+ )
183
+ self._emit(
184
+ ctx,
185
+ "compaction_succeeded",
186
+ before_tokens=result.before_tokens,
187
+ after_tokens=result.after_tokens,
188
+ cut=result.cut,
189
+ ratio=round(result.after_tokens / window, 4),
190
+ )
191
+
192
+ ctx.messages = result.messages
193
+ return MiddlewareResult(
194
+ action=MiddlewareAction.MODIFY,
195
+ data={"messages": result.messages, "compaction": result},
196
+ )
197
+
198
+ # ──────────────────────────────────────────────
199
+ # 异常接管:模型报"上下文超长"时强制压缩后重试
200
+ # ──────────────────────────────────────────────
201
+
202
+ async def handle_exception(self, exc: Exception, ctx: MiddlewareContext) -> bool:
203
+ """接住模型侧的"上下文超长",强制压一轮并让循环重试当前轮。
204
+
205
+ before_model 的阈值判断依赖计数;计数可能低估(估算口径、工具
206
+ schema 变化、多模态内容),所以必须能兜住模型侧的真实报错。
207
+
208
+ Returns:
209
+ True 表示已改写 ctx.messages,调用方应重试本次模型调用;
210
+ False 表示不接管,异常按原路径抛出。
211
+ """
212
+ config = self._config
213
+ if not config.enabled or not ctx.messages:
214
+ return False
215
+ if not is_context_overflow_error(exc):
216
+ return False
217
+
218
+ attempts = int(ctx.metadata.get(OVERFLOW_RETRY_KEY, 0))
219
+ if attempts >= config.max_overflow_retries:
220
+ logger.warning("[Compaction] 上下文超长重试已达上限(%d),不再强制压缩", attempts)
221
+ return False
222
+ ctx.metadata[OVERFLOW_RETRY_KEY] = attempts + 1
223
+
224
+ usage = await self._safe_measure(ctx.messages)
225
+ window = None
226
+ before_tokens = 0
227
+ if usage is not None:
228
+ window = usage.context_window_tokens
229
+ before_tokens = usage.input_tokens or 0
230
+ window = window or self._last_window
231
+ if not window:
232
+ logger.warning("[Compaction] 无窗口信息,无法处理上下文超长错误")
233
+ return False
234
+ if not before_tokens:
235
+ before_tokens = estimate_tokens(ctx.messages, chars_per_token=config.chars_per_token)
236
+
237
+ self._emit(
238
+ ctx,
239
+ "compaction_started",
240
+ before_tokens=before_tokens,
241
+ window=window,
242
+ ratio=round(before_tokens / window, 4),
243
+ forced=True,
244
+ )
245
+ try:
246
+ result = await self._compact(ctx.messages, window=window, before_tokens=before_tokens)
247
+ except Exception as compact_exc:
248
+ logger.warning("[Compaction] 溢出恢复压缩失败:%s", compact_exc)
249
+ self._emit(ctx, "compaction_failed", reason=str(compact_exc))
250
+ return False
251
+
252
+ if not result.compacted:
253
+ logger.warning("[Compaction] 溢出恢复未能压缩:%s", result.reason)
254
+ self._emit(ctx, "compaction_failed", reason=result.reason)
255
+ return False
256
+
257
+ ctx.messages = result.messages
258
+ logger.info(
259
+ "[Compaction] 溢出恢复压缩完成:%d → %d 条消息,占用 %d → %d tokens",
260
+ len(result.messages) + result.cut - 1,
261
+ len(result.messages),
262
+ result.before_tokens,
263
+ result.after_tokens,
264
+ )
265
+ self._emit(
266
+ ctx,
267
+ "compaction_succeeded",
268
+ before_tokens=result.before_tokens,
269
+ after_tokens=result.after_tokens,
270
+ cut=result.cut,
271
+ ratio=round(result.after_tokens / window, 4),
272
+ forced=True,
273
+ )
274
+ return True
275
+
276
+ # ──────────────────────────────────────────────
277
+ # 压缩主流程
278
+ # ──────────────────────────────────────────────
279
+
280
+ async def _compact(
281
+ self,
282
+ messages: list[Message],
283
+ *,
284
+ window: int,
285
+ before_tokens: int,
286
+ ) -> CompactionResult:
287
+ """压一轮(必要时按 max_retries 再压一轮),返回压缩结果。
288
+
289
+ 顺序是"先瘦身超长工具输出,再压摘要":瘦身粒度细、零成本,
290
+ 摘要粒度粗、要调模型,先做便宜的能少调一次模型。
291
+
292
+ Raises:
293
+ CompactionError: 摘要失败 / 结构不合规,由调用方降级处理。
294
+ """
295
+ config = self._config
296
+ threshold_tokens = int(window * config.threshold_ratio)
297
+ retain_tokens = int(window * config.retain_ratio)
298
+
299
+ # 1. 瘦身:只截断 role == "tool" 的超长输出,不改语义
300
+ pruned, pruned_count = prune_tool_results(
301
+ messages, config=self._prune_config, spill_store=self._spill_store
302
+ )
303
+ pruned_saved = 0
304
+ if pruned_count:
305
+ pruned_saved = max(
306
+ estimate_tokens(messages, chars_per_token=config.chars_per_token)
307
+ - estimate_tokens(pruned, chars_per_token=config.chars_per_token),
308
+ 0,
309
+ )
310
+ logger.info(
311
+ "[Compaction] 工具输出瘦身:%d 条,估算省下 %d tokens",
312
+ pruned_count,
313
+ pruned_saved,
314
+ )
315
+
316
+ current = pruned
317
+ cut = 0
318
+ after_tokens = max(before_tokens - pruned_saved, 0)
319
+
320
+ async def fallback(reason: str) -> CompactionResult:
321
+ """摘要不可用时收口:瘦身有效则单独采纳,否则判定未压缩。"""
322
+ if pruned_saved <= 0:
323
+ return CompactionResult(False, messages, before_tokens=before_tokens, reason=reason)
324
+ measured = await self._remaining_tokens(pruned)
325
+ return CompactionResult(
326
+ True,
327
+ pruned,
328
+ before_tokens=before_tokens,
329
+ after_tokens=measured if measured is not None else after_tokens,
330
+ reason=f"prune_only:{reason}",
331
+ )
332
+
333
+ # 2. 摘要:压头部旧消息,必要时重复到低于阈值
334
+ for _ in range(config.max_retries + 1):
335
+ # 先按尾部预算取初步切点,再前移到最近的"工具配对平衡边界",
336
+ # 保证被压头部不遗留无结果的 tool_call、尾部不以孤儿 tool 开头
337
+ cut = balanced_cut(
338
+ current,
339
+ select_cut(
340
+ current,
341
+ retain_tokens=retain_tokens,
342
+ chars_per_token=config.chars_per_token,
343
+ ),
344
+ )
345
+ head_messages = current[:cut]
346
+ head_tokens = estimate_tokens(head_messages, chars_per_token=config.chars_per_token)
347
+ if cut <= 1 or head_tokens <= 0:
348
+ return await fallback("没有可压缩的头部区域")
349
+
350
+ # 旧摘要(上一轮写回的 checkpoint)是压缩产物,不算"待压原文":
351
+ # 它只作为滚动合并的基线,也不参与"新摘要是否更小"的比较。
352
+ previous_summary = extract_previous_summary(current, cut)
353
+ origin_messages = [m for m in head_messages if m.role != "system" and m.name != SUMMARY_SOURCE]
354
+ origin_tokens = estimate_tokens(origin_messages, chars_per_token=config.chars_per_token)
355
+ if origin_tokens <= 0:
356
+ reason = (
357
+ "头部只剩已有摘要,重压无收益" if previous_summary is not None else "头部没有可压原文"
358
+ )
359
+ return await fallback(reason)
360
+ if previous_summary is not None:
361
+ logger.info(
362
+ "[Compaction] 检测到旧摘要(%d 字符),本次按滚动合并处理",
363
+ len(previous_summary),
364
+ )
365
+
366
+ try:
367
+ summary = await summarize(
368
+ self._summarizer,
369
+ current,
370
+ cut=cut,
371
+ max_tokens=config.summary_max_tokens,
372
+ instruction=self._summary_instruction,
373
+ sections=self._summary_sections,
374
+ )
375
+ except CompactionError as exc:
376
+ logger.warning("[Compaction] 摘要失败:%s", exc)
377
+ return await fallback(f"摘要失败:{exc}")
378
+
379
+ checkpoint = build_checkpoint(summary)
380
+ checkpoint_tokens = estimate_tokens([checkpoint], chars_per_token=config.chars_per_token)
381
+ if checkpoint_tokens >= origin_tokens:
382
+ return await fallback("摘要未比原文更小")
383
+
384
+ current = rebuild(current, cut, checkpoint)
385
+ after_tokens = max(before_tokens - pruned_saved - (head_tokens - checkpoint_tokens), 0)
386
+
387
+ remaining = await self._remaining_tokens(current)
388
+ if remaining is not None:
389
+ after_tokens = remaining
390
+ if after_tokens < threshold_tokens:
391
+ break
392
+
393
+ reason = "ok" if pruned_count == 0 else f"ok(pruned {pruned_count} tool results)"
394
+ return CompactionResult(
395
+ True,
396
+ current,
397
+ before_tokens=before_tokens,
398
+ after_tokens=after_tokens,
399
+ cut=cut,
400
+ reason=reason,
401
+ )
402
+
403
+ async def _remaining_tokens(self, messages: list[Message]) -> int | None:
404
+ """压缩后再量一次真实占用;量不到就返回 None,由估算值兜底。"""
405
+ usage = await self._safe_measure(messages)
406
+ return usage.input_tokens if usage is not None else None
407
+
408
+ async def _safe_measure(self, messages: list[Message]) -> ContextUsage | None:
409
+ """计数失败不应影响主流程,统一吞掉异常转为 None。"""
410
+ try:
411
+ return await self._count_tokens(messages, tools=self._tool_definitions)
412
+ except Exception as exc:
413
+ logger.warning("[Compaction] token 计数失败:%s", exc)
414
+ return None
415
+
416
+ def _emit(self, ctx: MiddlewareContext, event_type: str, **payload: Any) -> None:
417
+ """上报可观测事件;上报本身出问题也不能影响压缩结果。
418
+
419
+ 优先走 ctx.emit(与 model_retry / model_finished 落在同一事件序列),
420
+ 没有注入时退回构造参数 emit(独立使用 / 单测场景)。
421
+ """
422
+ try:
423
+ if ctx.emit is not None:
424
+ ctx.emit(event_type, **payload)
425
+ elif self._emit_hook is not None:
426
+ self._emit_hook(event_type, payload)
427
+ except Exception as exc:
428
+ logger.debug("[Compaction] 事件上报失败:%s", exc)
@@ -0,0 +1,32 @@
1
+ """通用上下文摘要模板。
2
+
3
+ 应用可以把自己的领域模板注入 ``CompactionMiddleware``,例如低空 Agent 的
4
+ 飞行器、航线、审批章节。Core 的默认模板只要求保留任务、事实、决策、状态和待办。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ DEFAULT_SUMMARY_SECTIONS: tuple[str, ...] = (
10
+ "任务目标",
11
+ "关键事实与实体",
12
+ "已执行动作及结果",
13
+ "决策与约束",
14
+ "当前状态",
15
+ "待办与下一步",
16
+ )
17
+
18
+ DEFAULT_COMPACTION_INSTRUCTION = "\n".join(
19
+ [
20
+ "请把上面的早期对话压缩成结构化检查点,使用中文,输出且仅输出下列小节。",
21
+ "",
22
+ "要求:",
23
+ "- 用精炼要点记录事实、用户约束、工具结果和未完成事项。",
24
+ "- 保留精确编号、时间、数值、标识符和用户纠正,不要编造。",
25
+ "- 空小节写 (none),但一节都不能少。",
26
+ "- 不要提及压缩过程。",
27
+ "",
28
+ *(f"## {section}" for section in DEFAULT_SUMMARY_SECTIONS),
29
+ ]
30
+ )
31
+
32
+ __all__ = ["DEFAULT_COMPACTION_INSTRUCTION", "DEFAULT_SUMMARY_SECTIONS"]
@@ -0,0 +1,121 @@
1
+ """超长工具输出瘦身(P3,纯函数、无 IO)。
2
+
3
+ 压缩是"整段历史 → 摘要",粒度粗;但真正挤爆窗口的往往是几条超长工具
4
+ 输出(Cesium 场景列表、航线 JSON、批量查询结果)。这些内容通常头部是
5
+ 元信息、尾部是关键字段,中间是可再获取的明细,因此在摘要之前先做一层
6
+ 保留式瘦身:头部 + 标记 + 尾部。
7
+
8
+ 与摘要的区别:瘦身不生成新信息、不改写语义,只做截断,因此即便后续
9
+ 摘要失败也可以单独采纳(见 CompactionMiddleware._compact)。
10
+
11
+ 如果注入了 SpillStore,被截断的完整内容会存到进程内 KV 存,标记里
12
+ 带上 spill_id;模型可通过 read_spilled_content 工具按 ID 回读全文,
13
+ 而不是重新调一次业务接口。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, replace
19
+ from typing import TYPE_CHECKING
20
+
21
+ from agent_core.protocol.messages import Message
22
+
23
+ if TYPE_CHECKING:
24
+ from agent_core.compaction.spill import SpillStore
25
+
26
+ # 被省略内容处的替换标记;显式写明省略量,避免模型误以为结果本就如此
27
+ PRUNE_MARKER = "\n\n…[工具输出已瘦身,中间省略 {dropped} 字符]…\n\n"
28
+ # spill 模式下的标记:多了 spill_id,指引模型用工具回读
29
+ PRUNE_MARKER_SPILL = (
30
+ "\n\n…[工具输出已瘦身,中间省略 {dropped} 字符,"
31
+ "完整内容可用 read_spilled_content 工具按 ID {spill_id} 回读]…\n\n"
32
+ )
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class PruneConfig:
37
+ """工具输出瘦身阈值。
38
+
39
+ Attributes:
40
+ threshold_chars: 超过该长度的工具输出才瘦身。
41
+ head_chars: 保留的头部字符数(元信息、字段说明)。
42
+ tail_chars: 保留的尾部字符数(统计量、末尾条目)。
43
+ """
44
+
45
+ threshold_chars: int = 8192
46
+ head_chars: int = 4096
47
+ tail_chars: int = 1024
48
+
49
+ def __post_init__(self) -> None:
50
+ if self.threshold_chars <= 0:
51
+ raise ValueError("prune threshold_chars 必须为正整数")
52
+ if self.head_chars <= 0 or self.tail_chars < 0:
53
+ raise ValueError("prune head_chars 必须为正、tail_chars 不能为负数")
54
+ if self.head_chars + self.tail_chars >= self.threshold_chars:
55
+ raise ValueError(
56
+ "prune head_chars + tail_chars 必须小于 threshold_chars,"
57
+ f"当前 {self.head_chars}+{self.tail_chars} >= {self.threshold_chars}"
58
+ )
59
+
60
+
61
+ def prune_text(
62
+ text: str,
63
+ config: PruneConfig,
64
+ *,
65
+ spill_store: SpillStore | None = None,
66
+ ) -> str:
67
+ """按阈值截断单段文本;未超阈值时原样返回。
68
+
69
+ 如果注入了 spill_store,被截断的完整内容会存入 store,标记里带上
70
+ spill_id 供模型回读;store 不接受(超限)时退回纯截断标记。
71
+ """
72
+ if len(text) <= config.threshold_chars:
73
+ return text
74
+
75
+ head = text[: config.head_chars]
76
+ tail = text[len(text) - config.tail_chars :] if config.tail_chars else ""
77
+ dropped = len(text) - len(head) - len(tail)
78
+ if dropped <= 0:
79
+ return text
80
+
81
+ if spill_store is not None:
82
+ spill_id = spill_store.put(text)
83
+ if spill_id is not None:
84
+ return f"{head}{PRUNE_MARKER_SPILL.format(dropped=dropped, spill_id=spill_id)}{tail}"
85
+
86
+ return f"{head}{PRUNE_MARKER.format(dropped=dropped)}{tail}"
87
+
88
+
89
+ def prune_tool_results(
90
+ messages: list[Message],
91
+ *,
92
+ config: PruneConfig,
93
+ spill_store: SpillStore | None = None,
94
+ ) -> tuple[list[Message], int]:
95
+ """瘦身消息列表中的超长工具输出。
96
+
97
+ 只处理 role == "tool" 的消息,且仅在结果确实更短时才替换(返回新
98
+ 列表),否则原样返回入参列表,方便调用方用 ``is`` 判断"是否发生瘦身"。
99
+
100
+ 如果注入了 spill_store,被截断的完整内容会存入 store,标记里带上
101
+ spill_id 供模型通过 read_spilled_content 工具回读。
102
+
103
+ Returns:
104
+ (消息列表, 被瘦身的条数)。
105
+ """
106
+ pruned_count = 0
107
+ result: list[Message] = []
108
+ for message in messages:
109
+ if message.role != "tool" or not message.content:
110
+ result.append(message)
111
+ continue
112
+ shorter = prune_text(message.content, config, spill_store=spill_store)
113
+ if len(shorter) < len(message.content):
114
+ result.append(replace(message, content=shorter))
115
+ pruned_count += 1
116
+ else:
117
+ result.append(message)
118
+
119
+ if pruned_count == 0:
120
+ return messages, 0
121
+ return result, pruned_count