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.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""压缩范围选择:尾部保留预算 + 工具配对平衡边界(纯函数,无 IO)。
|
|
2
|
+
|
|
3
|
+
这一层是整个压缩策略里最需要正确性的部分:一旦切点落在
|
|
4
|
+
"assistant 发了 tool_call,但它的 tool 结果还在切点之后"的位置,
|
|
5
|
+
被压头部就会留下没有结果的 tool_call、尾部会以孤儿 tool 消息开头,
|
|
6
|
+
两者都会让模型接口直接返回 400。因此这里用纯函数把边界算法固定下来,
|
|
7
|
+
不依赖网络即可充分单测。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
|
|
14
|
+
from agent_core.compaction.types import (
|
|
15
|
+
SUMMARY_CLOSE,
|
|
16
|
+
SUMMARY_OPEN,
|
|
17
|
+
SUMMARY_SOURCE,
|
|
18
|
+
)
|
|
19
|
+
from agent_core.protocol.messages import Message
|
|
20
|
+
|
|
21
|
+
# checkpoint 消息的固定前言:明确告诉模型这是既定背景,继续任务、不要回应
|
|
22
|
+
CHECKPOINT_PREAMBLE = (
|
|
23
|
+
"这是系统自动生成的早期对话检查点,用于释放上下文空间。"
|
|
24
|
+
"请把它当作既定背景直接使用,从其后的消息继续任务,"
|
|
25
|
+
"不要重述、不要回应、也不要提及本检查点。"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def estimate_message_tokens(message: Message, *, chars_per_token: int = 4) -> int:
|
|
30
|
+
"""按字符粗估单条消息的 token 数(含工具调用参数)。"""
|
|
31
|
+
chars = len(message.content or "")
|
|
32
|
+
for call in message.tool_calls:
|
|
33
|
+
chars += len(json.dumps(call, ensure_ascii=False))
|
|
34
|
+
return max(chars // chars_per_token, 1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def estimate_tokens(messages: list[Message], *, chars_per_token: int = 4) -> int:
|
|
38
|
+
"""按字符粗估一组消息的 token 数(用于尾部预算与"是否变小"判断)。"""
|
|
39
|
+
return sum(estimate_message_tokens(m, chars_per_token=chars_per_token) for m in messages)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def balanced_cut(messages: list[Message], proposed_cut: int) -> int:
|
|
43
|
+
"""把初步切点前移到最近的"工具配对平衡边界"。
|
|
44
|
+
|
|
45
|
+
平衡的含义:该位置之前所有 assistant 发起的 tool_call,都已经在该
|
|
46
|
+
位置之前拿到对应 tool 结果(在途调用计数为 0)。这样切出的头部不会
|
|
47
|
+
遗留无结果的 tool_call,尾部也不会以孤儿 tool 消息开头。
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
messages: 完整消息列表。
|
|
51
|
+
proposed_cut: 按尾部保留预算算出的初步切点(头部条数)。
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
修正后的切点,只可能前移、不会超过 proposed_cut;
|
|
55
|
+
因此尾部保留量只会 ≥ 预算,不会多压。
|
|
56
|
+
"""
|
|
57
|
+
limit = max(0, min(proposed_cut, len(messages)))
|
|
58
|
+
in_flight = 0
|
|
59
|
+
last_balanced = 0
|
|
60
|
+
for index, message in enumerate(messages):
|
|
61
|
+
if message.role == "assistant":
|
|
62
|
+
in_flight += len(message.tool_calls)
|
|
63
|
+
elif message.role == "tool":
|
|
64
|
+
in_flight = max(0, in_flight - 1)
|
|
65
|
+
|
|
66
|
+
# index + 1 表示"第 index+1 条之前"这个切口;计数归零即此处平衡
|
|
67
|
+
if in_flight == 0:
|
|
68
|
+
last_balanced = index + 1
|
|
69
|
+
if index + 1 >= limit:
|
|
70
|
+
break
|
|
71
|
+
|
|
72
|
+
return min(last_balanced, limit)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def select_cut(
|
|
76
|
+
messages: list[Message],
|
|
77
|
+
*,
|
|
78
|
+
retain_tokens: int,
|
|
79
|
+
chars_per_token: int = 4,
|
|
80
|
+
) -> int:
|
|
81
|
+
"""从尾部倒序累加 token,返回可压缩头部的条数(切点)。
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
messages: 完整消息列表。
|
|
85
|
+
retain_tokens: 尾部原文保留预算(窗口 × retain_ratio)。
|
|
86
|
+
chars_per_token: 字符/token 估算比例。
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
初步切点:messages[cut:] 逐字保留,messages[:cut] 待压成摘要。
|
|
90
|
+
若整段历史都在保留预算内,返回 0(表示没有可压缩的头部)。
|
|
91
|
+
"""
|
|
92
|
+
retained = 0
|
|
93
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
94
|
+
retained += estimate_message_tokens(messages[index], chars_per_token=chars_per_token)
|
|
95
|
+
if retained >= retain_tokens:
|
|
96
|
+
return index
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def extract_previous_summary(messages: list[Message], cut: int) -> str | None:
|
|
101
|
+
"""抽取被压头部里已有的摘要正文(滚动合并的基线)。
|
|
102
|
+
|
|
103
|
+
程序靠 ``<compacted-summary>`` 标签认出"上一轮的旧摘要":它本身就是
|
|
104
|
+
压缩产物,既不该被当成普通对话再压一遍,也不能拿来跟新摘要比大小。
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
messages: 完整消息列表。
|
|
108
|
+
cut: 压缩边界,只在 messages[:cut] 里找旧摘要。
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
旧摘要正文;头部没有旧摘要(首次压缩)时返回 None。
|
|
112
|
+
"""
|
|
113
|
+
for message in messages[:cut]:
|
|
114
|
+
text = message.content or ""
|
|
115
|
+
start = text.find(SUMMARY_OPEN)
|
|
116
|
+
end = text.find(SUMMARY_CLOSE)
|
|
117
|
+
if start >= 0 and end > start:
|
|
118
|
+
body = text[start + len(SUMMARY_OPEN) : end].strip()
|
|
119
|
+
if body:
|
|
120
|
+
return body
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def build_checkpoint(summary_text: str) -> Message:
|
|
125
|
+
"""把摘要正文包装成可写回历史的 checkpoint 消息。
|
|
126
|
+
|
|
127
|
+
用 user 角色而非 system:既满足"system 永远只有首条"的约束,
|
|
128
|
+
又能借前言让模型把摘要当作既定背景而不是新任务。
|
|
129
|
+
"""
|
|
130
|
+
return Message(
|
|
131
|
+
role="user",
|
|
132
|
+
name=SUMMARY_SOURCE,
|
|
133
|
+
content=f"{CHECKPOINT_PREAMBLE}\n\n{SUMMARY_OPEN}\n{summary_text}\n{SUMMARY_CLOSE}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def rebuild(
|
|
138
|
+
messages: list[Message],
|
|
139
|
+
cut: int,
|
|
140
|
+
checkpoint: Message,
|
|
141
|
+
) -> list[Message]:
|
|
142
|
+
"""用 checkpoint 替换头部:system + checkpoint + 尾部原文。"""
|
|
143
|
+
system_messages = [m for m in messages if m.role == "system"]
|
|
144
|
+
tail = [m for m in messages[cut:] if m.role != "system"]
|
|
145
|
+
return [*system_messages, checkpoint, *tail]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""外置存档(spill):把被瘦身的完整工具输出存到进程内 KV 存。
|
|
2
|
+
|
|
3
|
+
瘦身只保留头尾,中间内容被截断。如果模型后续需要完整内容(例如用户
|
|
4
|
+
追问"刚才那个航线列表的第三条详情"),可以通过 ``read_spilled_content``
|
|
5
|
+
工具按 spill_id 回读全文,而不是重新调一次业务接口。
|
|
6
|
+
|
|
7
|
+
存储是进程内的、按会话隔离的;进程重启后 spill 数据丢失——这在低空
|
|
8
|
+
Agent 场景下可接受:工具输出本就是实时数据,过期后重查比回读旧快照
|
|
9
|
+
更可靠。如果将来需要持久化,把 ``_store`` 换成 Redis / SQLite 即可,
|
|
10
|
+
接口不变。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import uuid
|
|
16
|
+
from collections import OrderedDict
|
|
17
|
+
from threading import Lock
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SpillStore:
|
|
21
|
+
"""线程安全的进程内 spill 存储(LRU 淘汰,防无界增长)。
|
|
22
|
+
|
|
23
|
+
用法:
|
|
24
|
+
store = SpillStore()
|
|
25
|
+
spill_id = store.put(big_json_string)
|
|
26
|
+
# … pruner 把 spill_id 写进瘦身标记 …
|
|
27
|
+
content = store.get(spill_id) # 模型通过工具回读
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, max_entries: int = 256, max_chars_per_entry: int = 512 * 1024) -> None:
|
|
31
|
+
"""Args:
|
|
32
|
+
max_entries: 最多保存多少条 spill;超出 LRU 淘汰最旧的。
|
|
33
|
+
max_chars_per_entry: 单条 spill 上限(字符),防止一条巨型
|
|
34
|
+
输出吃掉所有内存;超限的条目不存、只截断标记里不出现 ID。
|
|
35
|
+
"""
|
|
36
|
+
if max_entries <= 0:
|
|
37
|
+
raise ValueError("max_entries 必须为正整数")
|
|
38
|
+
if max_chars_per_entry <= 0:
|
|
39
|
+
raise ValueError("max_chars_per_entry 必须为正整数")
|
|
40
|
+
self._store: OrderedDict[str, str] = OrderedDict()
|
|
41
|
+
self._lock = Lock()
|
|
42
|
+
self._max_entries = max_entries
|
|
43
|
+
self._max_chars = max_chars_per_entry
|
|
44
|
+
|
|
45
|
+
def put(self, content: str) -> str | None:
|
|
46
|
+
"""存入完整内容,返回 spill_id;内容超限时返回 None(不存)。
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
content: 被瘦身的完整工具输出原文。
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
可用于回读的 spill_id;若内容超过 ``max_chars_per_entry``
|
|
53
|
+
则返回 None,调用方应回退到纯截断标记。
|
|
54
|
+
"""
|
|
55
|
+
if not content or len(content) > self._max_chars:
|
|
56
|
+
return None
|
|
57
|
+
spill_id = f"spill-{uuid.uuid4().hex[:12]}"
|
|
58
|
+
with self._lock:
|
|
59
|
+
# LRU:已满则弹出最旧条目
|
|
60
|
+
while len(self._store) >= self._max_entries:
|
|
61
|
+
self._store.popitem(last=False)
|
|
62
|
+
self._store[spill_id] = content
|
|
63
|
+
return spill_id
|
|
64
|
+
|
|
65
|
+
def get(self, spill_id: str) -> str | None:
|
|
66
|
+
"""按 spill_id 回读完整内容;不存在时返回 None。"""
|
|
67
|
+
with self._lock:
|
|
68
|
+
content = self._store.get(spill_id)
|
|
69
|
+
if content is not None:
|
|
70
|
+
# 命中后移到末尾(LRU 最近使用)
|
|
71
|
+
self._store.move_to_end(spill_id)
|
|
72
|
+
return content
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
with self._lock:
|
|
76
|
+
return len(self._store)
|
|
77
|
+
|
|
78
|
+
def clear(self) -> None:
|
|
79
|
+
"""清空所有 spill 条目。"""
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._store.clear()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""让 Agent 回读被上下文瘦身截断的完整工具输出。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agent_core.compaction.spill import SpillStore
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_spill_tool(spill_store: SpillStore) -> list[dict[str, Any]]:
|
|
14
|
+
"""构造与 ``ToolRegistry`` 兼容的 spill 回读工具定义。"""
|
|
15
|
+
|
|
16
|
+
async def read_spilled_content(spill_id: str) -> str:
|
|
17
|
+
content = spill_store.get(spill_id)
|
|
18
|
+
if content is None:
|
|
19
|
+
return f"存档 {spill_id} 不存在或已过期。"
|
|
20
|
+
logger.info("[Spill] 回读存档 %s(%d 字符)", spill_id, len(content))
|
|
21
|
+
return content
|
|
22
|
+
|
|
23
|
+
return [
|
|
24
|
+
{
|
|
25
|
+
"name": "read_spilled_content",
|
|
26
|
+
"func": read_spilled_content,
|
|
27
|
+
"description": "回读被瘦身截断的完整工具输出。消息中存在 spill_id 时可调用。",
|
|
28
|
+
"parameters": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"properties": {
|
|
31
|
+
"spill_id": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "瘦身标记中的 spill 存档 ID",
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"required": ["spill_id"],
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["build_spill_tool"]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""压缩摘要调用和结构校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
from agent_core.compaction.prompts import (
|
|
8
|
+
DEFAULT_COMPACTION_INSTRUCTION,
|
|
9
|
+
DEFAULT_SUMMARY_SECTIONS,
|
|
10
|
+
)
|
|
11
|
+
from agent_core.compaction.types import CompactionError
|
|
12
|
+
from agent_core.model import ModelAdapter
|
|
13
|
+
from agent_core.protocol.messages import Message, user_message
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_summarization_messages(
|
|
19
|
+
messages: list[Message],
|
|
20
|
+
*,
|
|
21
|
+
cut: int,
|
|
22
|
+
instruction: str = DEFAULT_COMPACTION_INSTRUCTION,
|
|
23
|
+
) -> list[Message]:
|
|
24
|
+
"""构造摘要调用的输入:原 system + 被压头部 + 末尾压缩指令。
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
messages: 压缩前的完整消息列表。
|
|
28
|
+
cut: 压缩边界,messages[:cut] 为待压缩的头部。
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
用于摘要模型的完整消息列表(system 仍在首位)。
|
|
32
|
+
"""
|
|
33
|
+
head = messages[:cut]
|
|
34
|
+
system_messages = [m for m in head if m.role == "system"]
|
|
35
|
+
body = [m for m in head if m.role != "system"]
|
|
36
|
+
return [*system_messages, *body, user_message(instruction)]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def normalize_summary(
|
|
40
|
+
text: str,
|
|
41
|
+
*,
|
|
42
|
+
sections: tuple[str, ...] = DEFAULT_SUMMARY_SECTIONS,
|
|
43
|
+
) -> str:
|
|
44
|
+
"""校验并规整摘要结构。
|
|
45
|
+
|
|
46
|
+
- 一个固定小节都没有 → 返回空串(调用方按失败处理,避免写入无效检查点);
|
|
47
|
+
- 只缺部分小节 → 用 (none) 补齐,保证后续轮次的滚动合并有稳定结构。
|
|
48
|
+
"""
|
|
49
|
+
body = (text or "").strip()
|
|
50
|
+
if not body:
|
|
51
|
+
return ""
|
|
52
|
+
|
|
53
|
+
present = [name for name in sections if f"## {name}" in body]
|
|
54
|
+
if not present:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
missing = [name for name in sections if name not in present]
|
|
58
|
+
if missing:
|
|
59
|
+
body += "\n\n" + "\n\n".join(f"## {name}\n(none)" for name in missing)
|
|
60
|
+
return body
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def summarize(
|
|
64
|
+
model: ModelAdapter,
|
|
65
|
+
messages: list[Message],
|
|
66
|
+
*,
|
|
67
|
+
cut: int,
|
|
68
|
+
max_tokens: int,
|
|
69
|
+
instruction: str = DEFAULT_COMPACTION_INSTRUCTION,
|
|
70
|
+
sections: tuple[str, ...] = DEFAULT_SUMMARY_SECTIONS,
|
|
71
|
+
) -> str:
|
|
72
|
+
"""调用摘要模型,把头部旧消息压成结构化检查点文本。
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
model: 摘要模型(默认复用主模型,也可换成更便宜的小模型)。
|
|
76
|
+
messages: 压缩前的完整消息列表。
|
|
77
|
+
cut: 压缩边界。
|
|
78
|
+
max_tokens: 摘要生成本身的输出上限。
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
结构化摘要正文(不含 checkpoint 包装)。
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
CompactionError: 调用失败或输出不符合结构要求。
|
|
85
|
+
"""
|
|
86
|
+
prompt = build_summarization_messages(messages, cut=cut, instruction=instruction)
|
|
87
|
+
try:
|
|
88
|
+
response = await model.chat(prompt, max_tokens=max_tokens)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise CompactionError(f"摘要模型调用失败:{exc}") from exc
|
|
91
|
+
|
|
92
|
+
summary = normalize_summary(response.content or "", sections=sections)
|
|
93
|
+
if not summary:
|
|
94
|
+
raise CompactionError("摘要输出不符合结构化要求")
|
|
95
|
+
return summary
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""上下文长度兜底中间件。
|
|
2
|
+
|
|
3
|
+
它负责最后一道硬截断;完整的摘要、spill 和恢复逻辑由
|
|
4
|
+
``CompactionMiddleware`` 负责。两者都属于上下文压缩域,不属于基础中间件协议。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from agent_core.middleware.base import Middleware, MiddlewareAction, MiddlewareContext, MiddlewareResult
|
|
12
|
+
from agent_core.protocol.messages import Message
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TokenLimitMiddleware(Middleware):
|
|
18
|
+
"""防止消息历史超出 token 限制。"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
max_tokens: int = 4000,
|
|
23
|
+
chars_per_token: int = 4,
|
|
24
|
+
keep_messages: int = 10,
|
|
25
|
+
) -> None:
|
|
26
|
+
if max_tokens <= 0:
|
|
27
|
+
raise ValueError("max_tokens 必须大于 0")
|
|
28
|
+
if chars_per_token <= 0:
|
|
29
|
+
raise ValueError("chars_per_token 必须大于 0")
|
|
30
|
+
if keep_messages < 1:
|
|
31
|
+
raise ValueError("keep_messages 必须大于 0")
|
|
32
|
+
self._max_tokens = max_tokens
|
|
33
|
+
self._chars_per_token = chars_per_token
|
|
34
|
+
self._keep_messages = keep_messages
|
|
35
|
+
|
|
36
|
+
def _estimate_tokens(self, messages: list[Message]) -> int:
|
|
37
|
+
total = sum(len(message.content) for message in messages if message.content)
|
|
38
|
+
return total // self._chars_per_token
|
|
39
|
+
|
|
40
|
+
def _trim_messages(self, messages: list[Message]) -> list[Message]:
|
|
41
|
+
"""保留 system 消息和最近消息,并避免留下孤儿 tool 结果。"""
|
|
42
|
+
system_messages = [message for message in messages if message.role == "system"]
|
|
43
|
+
other_messages = [message for message in messages if message.role != "system"]
|
|
44
|
+
if len(other_messages) <= self._keep_messages:
|
|
45
|
+
return system_messages + other_messages
|
|
46
|
+
|
|
47
|
+
start = len(other_messages) - self._keep_messages
|
|
48
|
+
# 工具结果必须和此前的 assistant tool_call 一起保留,否则提供商会拒绝请求。
|
|
49
|
+
while start > 0 and other_messages[start].role == "tool":
|
|
50
|
+
start -= 1
|
|
51
|
+
return system_messages + other_messages[start:]
|
|
52
|
+
|
|
53
|
+
async def before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
54
|
+
estimated = self._estimate_tokens(ctx.messages)
|
|
55
|
+
if estimated <= self._max_tokens:
|
|
56
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
57
|
+
|
|
58
|
+
trimmed = self._trim_messages(ctx.messages)
|
|
59
|
+
new_estimated = self._estimate_tokens(trimmed)
|
|
60
|
+
logger.warning(
|
|
61
|
+
"[TokenLimit] 消息历史过长 (%d tokens),裁剪:%d → %d 条,重新估算 %d tokens",
|
|
62
|
+
estimated,
|
|
63
|
+
len(ctx.messages),
|
|
64
|
+
len(trimmed),
|
|
65
|
+
new_estimated,
|
|
66
|
+
)
|
|
67
|
+
ctx.messages = trimmed
|
|
68
|
+
return MiddlewareResult(
|
|
69
|
+
action=MiddlewareAction.MODIFY,
|
|
70
|
+
data={"messages": trimmed, "trimmed_tokens": estimated - new_estimated},
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["TokenLimitMiddleware"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""上下文压缩的数据结构与常量。
|
|
2
|
+
|
|
3
|
+
本模块只放"值对象",不含任何算法与 IO,便于 region / summarizer /
|
|
4
|
+
middleware 三层共享,也便于单测直接构造。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
|
|
11
|
+
from agent_core.protocol.messages import Message
|
|
12
|
+
|
|
13
|
+
# ──────────────────────────────────────────────
|
|
14
|
+
# 标签与来源标记
|
|
15
|
+
# ──────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
# 摘要正文在 checkpoint 消息中的包裹标签;下一轮压缩时靠它识别"旧摘要"
|
|
18
|
+
SUMMARY_OPEN = "<compacted-summary>"
|
|
19
|
+
SUMMARY_CLOSE = "</compacted-summary>"
|
|
20
|
+
|
|
21
|
+
# 写入 Message.name,便于日志、前端展示与排查时识别自动生成的检查点
|
|
22
|
+
SUMMARY_SOURCE = "compaction_checkpoint"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CompactionConfig:
|
|
27
|
+
"""上下文压缩配置。
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
threshold_ratio: 窗口占用达到该比例即触发压缩。
|
|
31
|
+
retain_ratio: 压缩后保留的尾部原文比例(占窗口,必须小于阈值)。
|
|
32
|
+
summary_max_tokens: 摘要生成本身的输出上限。
|
|
33
|
+
max_retries: 压完仍超阈值的额外压缩轮数(0 表示只压一轮)。
|
|
34
|
+
max_overflow_retries: 模型报"上下文超长"时的强制压缩重试上限
|
|
35
|
+
(0 表示不接管该错误,交给上层抛出)。
|
|
36
|
+
enabled: 总开关,关闭后中间件直接放行。
|
|
37
|
+
chars_per_token: 无精确计数时的字符/token 估算比例(用于尾部预算
|
|
38
|
+
与"摘要是否更小"的判断)。
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
threshold_ratio: float = 0.75
|
|
42
|
+
retain_ratio: float = 0.20
|
|
43
|
+
summary_max_tokens: int = 2048
|
|
44
|
+
max_retries: int = 1
|
|
45
|
+
max_overflow_retries: int = 1
|
|
46
|
+
enabled: bool = True
|
|
47
|
+
chars_per_token: int = 4
|
|
48
|
+
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
if not 0 < self.threshold_ratio <= 1:
|
|
51
|
+
raise ValueError(f"compaction threshold_ratio 必须在 (0, 1] 区间,当前 {self.threshold_ratio}")
|
|
52
|
+
if not 0 < self.retain_ratio < self.threshold_ratio:
|
|
53
|
+
raise ValueError(
|
|
54
|
+
"compaction retain_ratio 必须大于 0 且小于 threshold_ratio,"
|
|
55
|
+
f"当前 retain={self.retain_ratio}, threshold={self.threshold_ratio}"
|
|
56
|
+
)
|
|
57
|
+
if self.summary_max_tokens <= 0:
|
|
58
|
+
raise ValueError("compaction summary_max_tokens 必须为正整数")
|
|
59
|
+
if self.max_retries < 0:
|
|
60
|
+
raise ValueError("compaction max_retries 不能为负数")
|
|
61
|
+
if self.max_overflow_retries < 0:
|
|
62
|
+
raise ValueError("compaction max_overflow_retries 不能为负数")
|
|
63
|
+
if self.chars_per_token <= 0:
|
|
64
|
+
raise ValueError("compaction chars_per_token 必须为正整数")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class CompactionResult:
|
|
69
|
+
"""一次压缩的产出。
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
compacted: 是否真的替换了消息(False 时 messages 为原列表)。
|
|
73
|
+
messages: 压缩后的消息列表。
|
|
74
|
+
before_tokens: 压缩前窗口占用(来自 Provider 的真实计数)。
|
|
75
|
+
after_tokens: 压缩后窗口占用(按字符估算的缩减量推算)。
|
|
76
|
+
cut: 被压成摘要的头部条数(压缩边界)。
|
|
77
|
+
reason: 未压缩或失败原因,供日志与降级事件使用。
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
compacted: bool
|
|
81
|
+
messages: list[Message]
|
|
82
|
+
before_tokens: int = 0
|
|
83
|
+
after_tokens: int = 0
|
|
84
|
+
cut: int = 0
|
|
85
|
+
reason: str = ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CompactionError(RuntimeError):
|
|
89
|
+
"""压缩过程中的可预期失败(摘要调用失败 / 结构不合规 / 未变小)。
|
|
90
|
+
|
|
91
|
+
中间件捕获它后返回 CONTINUE,把消息交给后续的硬截断中间件兜底,
|
|
92
|
+
因此不需要中断主流程。
|
|
93
|
+
"""
|
agent_core/docs.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""文档值对象、加载器和文本切分器。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.documents.loaders import (
|
|
4
|
+
BlobParser,
|
|
5
|
+
DocumentLoader,
|
|
6
|
+
TextBlobParser,
|
|
7
|
+
TextLoader,
|
|
8
|
+
)
|
|
9
|
+
from agent_core.documents.splitters import RecursiveCharacterTextSplitter, TextSplitter
|
|
10
|
+
from agent_core.documents.types import Blob, Document, DocumentChunk, DocumentLocator
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Blob",
|
|
14
|
+
"BlobParser",
|
|
15
|
+
"Document",
|
|
16
|
+
"DocumentChunk",
|
|
17
|
+
"DocumentLoader",
|
|
18
|
+
"DocumentLocator",
|
|
19
|
+
"RecursiveCharacterTextSplitter",
|
|
20
|
+
"TextBlobParser",
|
|
21
|
+
"TextLoader",
|
|
22
|
+
"TextSplitter",
|
|
23
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""文档加载和 Blob 解析协议,以及零依赖文本实现。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import mimetypes
|
|
7
|
+
import uuid
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Protocol, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from agent_core.documents.types import Blob, Document, DocumentLocator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class DocumentLoader(Protocol):
|
|
17
|
+
"""把外部资源加载为 Document。"""
|
|
18
|
+
|
|
19
|
+
async def load(self) -> list[Document]: ...
|
|
20
|
+
|
|
21
|
+
def lazy_load(self) -> AsyncIterator[Document]: ...
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@runtime_checkable
|
|
25
|
+
class BlobParser(Protocol):
|
|
26
|
+
"""把原始 Blob 解析为一个或多个 Document。"""
|
|
27
|
+
|
|
28
|
+
async def parse(self, blob: Blob) -> list[Document]: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TextBlobParser:
|
|
32
|
+
"""按指定字符集解析纯文本 Blob。"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, encoding: str = "utf-8") -> None:
|
|
35
|
+
self._encoding = encoding
|
|
36
|
+
|
|
37
|
+
async def parse(self, blob: Blob) -> list[Document]:
|
|
38
|
+
content = blob.data.decode(self._encoding)
|
|
39
|
+
source_key = blob.source or f"blob:{blob.checksum}"
|
|
40
|
+
return [
|
|
41
|
+
Document(
|
|
42
|
+
document_id=str(uuid.uuid5(uuid.NAMESPACE_URL, f"{source_key}:{blob.checksum}")),
|
|
43
|
+
content=content,
|
|
44
|
+
source=blob.source,
|
|
45
|
+
mime_type=blob.mime_type or "text/plain",
|
|
46
|
+
metadata={**blob.metadata, "source_checksum": blob.checksum},
|
|
47
|
+
locator=DocumentLocator(uri=blob.source) if blob.source else None,
|
|
48
|
+
)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TextLoader:
|
|
53
|
+
"""从本地文件加载 UTF 文本,不引入第三方解析库。"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
path: str | Path,
|
|
58
|
+
*,
|
|
59
|
+
encoding: str = "utf-8",
|
|
60
|
+
metadata: dict[str, object] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._path = Path(path)
|
|
63
|
+
self._parser = TextBlobParser(encoding)
|
|
64
|
+
self._metadata = dict(metadata or {})
|
|
65
|
+
|
|
66
|
+
async def load(self) -> list[Document]:
|
|
67
|
+
data = await asyncio.to_thread(self._path.read_bytes)
|
|
68
|
+
mime_type, _ = mimetypes.guess_type(self._path.name)
|
|
69
|
+
blob = Blob(
|
|
70
|
+
data=data,
|
|
71
|
+
source=str(self._path.resolve()),
|
|
72
|
+
mime_type=mime_type or "text/plain",
|
|
73
|
+
metadata=self._metadata,
|
|
74
|
+
)
|
|
75
|
+
return await self._parser.parse(blob)
|
|
76
|
+
|
|
77
|
+
async def lazy_load(self) -> AsyncIterator[Document]:
|
|
78
|
+
for document in await self.load():
|
|
79
|
+
yield document
|