agstack 2.2.0__tar.gz → 2.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agstack-2.2.0 → agstack-2.3.0}/PKG-INFO +1 -1
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/client.py +23 -2
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/agent.py +2 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/registry.py +12 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/tool.py +32 -18
- agstack-2.3.0/agstack/llm/harness/__init__.py +51 -0
- agstack-2.3.0/agstack/llm/harness/ports.py +198 -0
- agstack-2.3.0/agstack/llm/harness/spill.py +104 -0
- agstack-2.3.0/agstack/llm/harness/truncation.py +93 -0
- agstack-2.3.0/agstack/llm/hooks.py +112 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-2.2.0 → agstack-2.3.0}/agstack.egg-info/SOURCES.txt +8 -0
- {agstack-2.2.0 → agstack-2.3.0}/pyproject.toml +1 -1
- agstack-2.3.0/tests/test_harness_ports_truncation.py +90 -0
- agstack-2.3.0/tests/test_harness_spill.py +136 -0
- agstack-2.3.0/tests/test_llm_call_hooks.py +185 -0
- {agstack-2.2.0 → agstack-2.3.0}/LICENSE +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/README.md +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/cache/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/cache/base.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/cache/memory.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/cache/redis.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/config/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/config/logger.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/config/manager.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/config/types.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/contexts.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/decorators.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/events.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/exceptions.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/fastapi/exception.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/fastapi/offline.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/fastapi/sse.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/context.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/event.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/flow.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/iterator_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/records.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/state.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/flow/trace.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/prompts.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/llm/token.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/messagebus/base.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/messagebus/memory.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/messagebus/redis.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/schema.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/security/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/security/casbin.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/security/crypt.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack/status.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/setup.cfg +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_agent_parallel_tools.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_agent_request_overrides.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_cache_memory.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_cache_redis.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_flow_cancellation.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_flow_error_semantics.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_flow_io.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_flow_iterator.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_llm_usage_callback.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_messagebus_memory.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_messagebus_redis.py +0 -0
- {agstack-2.2.0 → agstack-2.3.0}/tests/test_tool_hooks.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agstack
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Production-ready toolkit for building FastAPI and LLM applications
|
|
5
5
|
Author-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
6
6
|
Maintainer-email: XtraVisions <gitadmin@xtravisions.com>, Chen Hao <chenhao@xtravisions.com>
|
|
@@ -14,6 +14,7 @@ from openai.types.chat import ChatCompletionMessageParam
|
|
|
14
14
|
|
|
15
15
|
from ..contexts import get_request_id
|
|
16
16
|
from ..exceptions import AppException
|
|
17
|
+
from .hooks import CallMeta, StreamSummary, has_llm_hooks, run_after_call, run_before_call
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
@@ -207,12 +208,24 @@ class LLMClient:
|
|
|
207
208
|
model_name = model
|
|
208
209
|
# 内部调用类型标记(vision 经由 chat 转发时传入,不透传给推理后端)
|
|
209
210
|
usage_kind = kwargs.pop("usage_kind", "chat")
|
|
211
|
+
# 调用方附带给 LLM 钩子的上下文(Agent 传 agent / turn / retry / context),不透传给推理后端
|
|
212
|
+
hook_extra: dict[str, Any] = kwargs.pop("hook_meta", None) or {}
|
|
213
|
+
stream_kind = "chat_stream" if usage_kind == "chat" else usage_kind
|
|
214
|
+
meta = CallMeta(
|
|
215
|
+
model=model_name,
|
|
216
|
+
kind=stream_kind if stream else usage_kind,
|
|
217
|
+
stream=stream,
|
|
218
|
+
request_id=get_request_id(),
|
|
219
|
+
extra=hook_extra,
|
|
220
|
+
)
|
|
221
|
+
# F6 before 链:请求发出前改写消息;钩子异常即失败(fail closed),不包进 LLMError 以保留原异常类型
|
|
222
|
+
if has_llm_hooks():
|
|
223
|
+
messages = await run_before_call(messages, kwargs.get("tools"), meta)
|
|
210
224
|
|
|
211
225
|
try:
|
|
212
226
|
if stream:
|
|
213
|
-
stream_kind = "chat_stream" if usage_kind == "chat" else usage_kind
|
|
214
227
|
return self._chat_stream(
|
|
215
|
-
messages, model_name, temperature, max_tokens, start, usage_kind=stream_kind, **kwargs
|
|
228
|
+
messages, model_name, temperature, max_tokens, start, usage_kind=stream_kind, meta=meta, **kwargs
|
|
216
229
|
)
|
|
217
230
|
|
|
218
231
|
@autoretry(
|
|
@@ -242,6 +255,8 @@ class LLMClient:
|
|
|
242
255
|
else:
|
|
243
256
|
logger.info(f"LLM: model={model_name}, duration={duration_ms}ms")
|
|
244
257
|
_emit_usage(model_name, usage_kind, usage, duration_ms)
|
|
258
|
+
if has_llm_hooks():
|
|
259
|
+
await run_after_call(response, meta)
|
|
245
260
|
|
|
246
261
|
return response
|
|
247
262
|
|
|
@@ -324,10 +339,12 @@ class LLMClient:
|
|
|
324
339
|
max_tokens: int | None,
|
|
325
340
|
start_time: float,
|
|
326
341
|
usage_kind: str = "chat_stream",
|
|
342
|
+
meta: CallMeta | None = None,
|
|
327
343
|
**kwargs: Any,
|
|
328
344
|
) -> AsyncIterator["ChatCompletionChunk"]:
|
|
329
345
|
"""流式响应"""
|
|
330
346
|
final_usage = None
|
|
347
|
+
finish_reason: str | None = None
|
|
331
348
|
|
|
332
349
|
try:
|
|
333
350
|
# noinspection PyTypeChecker
|
|
@@ -346,6 +363,8 @@ class LLMClient:
|
|
|
346
363
|
# 收集 token 统计(usage 通常在末尾 chunk 返回)
|
|
347
364
|
if chunk.usage:
|
|
348
365
|
final_usage = chunk.usage
|
|
366
|
+
if chunk.choices and chunk.choices[0].finish_reason:
|
|
367
|
+
finish_reason = chunk.choices[0].finish_reason
|
|
349
368
|
|
|
350
369
|
yield chunk
|
|
351
370
|
|
|
@@ -354,6 +373,8 @@ class LLMClient:
|
|
|
354
373
|
total_tokens = final_usage.total_tokens if final_usage else 0
|
|
355
374
|
logger.info(f"LLM stream: model={model}, tokens={total_tokens}, duration={duration_ms}ms")
|
|
356
375
|
_emit_usage(model, usage_kind, final_usage, duration_ms)
|
|
376
|
+
if meta is not None and has_llm_hooks():
|
|
377
|
+
await run_after_call(StreamSummary(usage=final_usage, finish_reason=finish_reason), meta)
|
|
357
378
|
|
|
358
379
|
except APITimeoutError as e:
|
|
359
380
|
logger.error(f"LLM stream timeout: {e}")
|
|
@@ -322,6 +322,8 @@ class Agent:
|
|
|
322
322
|
kwargs["tool_choice"] = self.tool_choice
|
|
323
323
|
|
|
324
324
|
self._apply_overrides(kwargs, self.request_overrides(context, turn, retry=attempt > 0) or {})
|
|
325
|
+
# F6 LLM 钩子的调用方上下文(client 弹出,不透传给推理后端)
|
|
326
|
+
kwargs["hook_meta"] = {"agent": self.name, "turn": turn, "retry": attempt > 0, "context": context}
|
|
325
327
|
|
|
326
328
|
stream = await client.chat(stream=True, **kwargs)
|
|
327
329
|
|
|
@@ -7,6 +7,7 @@ from __future__ import annotations
|
|
|
7
7
|
import copy
|
|
8
8
|
from typing import Any, cast
|
|
9
9
|
|
|
10
|
+
from ..hooks import LLMCallHook, clear_llm_hooks, register_llm_hook
|
|
10
11
|
from .agent import Agent
|
|
11
12
|
from .tool import Tool, ToolHook, clear_tool_hooks, register_tool_hook
|
|
12
13
|
|
|
@@ -58,6 +59,17 @@ class FlowRegistry:
|
|
|
58
59
|
"""清空全局工具钩子(测试隔离用)"""
|
|
59
60
|
clear_tool_hooks()
|
|
60
61
|
|
|
62
|
+
def register_llm_hook(self, hook: LLMCallHook, *, prepend: bool = False) -> None:
|
|
63
|
+
"""注册全局 LLM 调用钩子(F6):before_call 按注册顺序改写消息,after_call 逆序观察
|
|
64
|
+
|
|
65
|
+
钩子链存于 ``llm.hooks``(保持 registry → hooks 单向导入),此处仅转发注册。
|
|
66
|
+
"""
|
|
67
|
+
register_llm_hook(hook, prepend=prepend)
|
|
68
|
+
|
|
69
|
+
def clear_llm_hooks(self) -> None:
|
|
70
|
+
"""清空全局 LLM 调用钩子(测试隔离用)"""
|
|
71
|
+
clear_llm_hooks()
|
|
72
|
+
|
|
61
73
|
def register_agent(
|
|
62
74
|
self, name: str, agent_class: type[Agent], *, label: str | None = None, echo: bool = False
|
|
63
75
|
) -> None:
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import json
|
|
6
6
|
import logging
|
|
7
7
|
import time
|
|
8
|
-
from dataclasses import dataclass
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
9
|
from typing import TYPE_CHECKING, Any, Callable
|
|
10
10
|
|
|
11
11
|
|
|
@@ -26,6 +26,8 @@ class ToolResult:
|
|
|
26
26
|
error: str | None = None
|
|
27
27
|
content: str | None = None
|
|
28
28
|
summary: str | None = None
|
|
29
|
+
#: 钩子 / 工具附带的结构化信息(如 spill 落盘引用),不喂给模型
|
|
30
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
class Deny:
|
|
@@ -61,6 +63,10 @@ class ToolHook:
|
|
|
61
63
|
execution_records 三个出口同时生效。抛异常记日志并放行原结果
|
|
62
64
|
(fail open:审计钩子的 bug 不毁掉主流程)。
|
|
63
65
|
Deny 产生的失败结果同样穿过 post 链(审计要看到被拒绝的调用)。
|
|
66
|
+
|
|
67
|
+
进入 post 链时 ``result.content``(喂给模型的字符串)已按 result_formatter 算好:
|
|
68
|
+
钩子可直接改写 content(spill / 截断);钩子若换掉 ``result.result`` 而未动 content,
|
|
69
|
+
Tool 会按新 result 重算 content(2.3 起;2.1 的「改 result 即改 content」语义保留)。
|
|
64
70
|
"""
|
|
65
71
|
return result
|
|
66
72
|
|
|
@@ -161,9 +167,13 @@ class Tool:
|
|
|
161
167
|
if result is None:
|
|
162
168
|
result = await self._execute(context, args)
|
|
163
169
|
|
|
170
|
+
# 先算 LLM 消费内容,post 钩子据此判定 / 改写(spill、截断看到的是模型将看到的字符串)
|
|
171
|
+
result.content = self._render_content(result)
|
|
172
|
+
|
|
164
173
|
# post 钩子链(逆序):可改写结果;抛异常=放行原结果(fail open)。
|
|
165
174
|
# Deny 的失败结果同样穿过 post 链,审计钩子能看到被拒绝的调用。
|
|
166
175
|
for hook in reversed(_TOOL_HOOKS):
|
|
176
|
+
before_result, before_content = result.result, result.content
|
|
167
177
|
try:
|
|
168
178
|
revised = await hook.post_execute(context, self, result)
|
|
169
179
|
except Exception as e:
|
|
@@ -173,26 +183,16 @@ class Tool:
|
|
|
173
183
|
result = revised
|
|
174
184
|
else:
|
|
175
185
|
logger.warning("Tool hook post_execute for %s returned %r, ignored", self.name, type(revised))
|
|
186
|
+
continue
|
|
187
|
+
# 钩子换了 result 却没给新 content(None 或原样):按新 result 重算
|
|
188
|
+
# (保持 2.1「改 result 即改模型所见」语义)
|
|
189
|
+
if result.result is not before_result and (result.content is None or result.content == before_content):
|
|
190
|
+
result.content = self._render_content(result)
|
|
191
|
+
if result.content is None:
|
|
192
|
+
result.content = self._render_content(result)
|
|
176
193
|
|
|
177
194
|
_duration_ms = int((time.perf_counter() - _t0) * 1000)
|
|
178
195
|
|
|
179
|
-
# 计算 LLM 消费内容
|
|
180
|
-
if self.result_formatter:
|
|
181
|
-
try:
|
|
182
|
-
result.content = self.result_formatter(result)
|
|
183
|
-
except Exception:
|
|
184
|
-
result.content = (
|
|
185
|
-
json.dumps(result.result, ensure_ascii=False)
|
|
186
|
-
if result.success
|
|
187
|
-
else json.dumps({"error": result.error}, ensure_ascii=False)
|
|
188
|
-
)
|
|
189
|
-
else:
|
|
190
|
-
result.content = (
|
|
191
|
-
json.dumps(result.result, ensure_ascii=False)
|
|
192
|
-
if result.success
|
|
193
|
-
else json.dumps({"error": result.error}, ensure_ascii=False)
|
|
194
|
-
)
|
|
195
|
-
|
|
196
196
|
# 生成面向用户的摘要
|
|
197
197
|
if self.summary_fn:
|
|
198
198
|
try:
|
|
@@ -215,6 +215,20 @@ class Tool:
|
|
|
215
215
|
|
|
216
216
|
return result
|
|
217
217
|
|
|
218
|
+
def _render_content(self, result: ToolResult) -> str:
|
|
219
|
+
"""按 result_formatter(失败回退 JSON)算喂给模型的内容"""
|
|
220
|
+
fallback = (
|
|
221
|
+
json.dumps(result.result, ensure_ascii=False)
|
|
222
|
+
if result.success
|
|
223
|
+
else json.dumps({"error": result.error}, ensure_ascii=False)
|
|
224
|
+
)
|
|
225
|
+
if not self.result_formatter:
|
|
226
|
+
return fallback
|
|
227
|
+
try:
|
|
228
|
+
return self.result_formatter(result)
|
|
229
|
+
except Exception:
|
|
230
|
+
return fallback
|
|
231
|
+
|
|
218
232
|
async def _execute(self, context: "FlowContext", inputs: dict[str, Any]) -> ToolResult:
|
|
219
233
|
"""实际执行逻辑,子类应覆写此方法"""
|
|
220
234
|
try:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""agstack.llm.harness——模型无关、表无关、产品无关的运行时部件
|
|
4
|
+
|
|
5
|
+
- :mod:`.ports`:存储端口声明(SessionLog / SpillStore / UsageSink / KVStore)与 ``register_ports``,
|
|
6
|
+
由应用实现并在进程入口注册;
|
|
7
|
+
- :mod:`.truncation`:工具结果截断设施(保头尾截断、按相关度整条丢弃),策略数值由调用方给;
|
|
8
|
+
- :mod:`.spill`:超长工具结果落盘的 ToolHook(prepend 链头、按内联 token 上限判定、头尾保留 + 固定格式通知、
|
|
9
|
+
存储失败保留内联)。
|
|
10
|
+
|
|
11
|
+
2.3 只收这三块零状态模块与端口声明;events / projection / tokens / AgentGuards 排 2.4,context / overflow 排 3.0。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .ports import (
|
|
15
|
+
KVStore,
|
|
16
|
+
LogEvent,
|
|
17
|
+
Ports,
|
|
18
|
+
SessionLog,
|
|
19
|
+
SpillOwner,
|
|
20
|
+
SpillRef,
|
|
21
|
+
SpillSource,
|
|
22
|
+
SpillStore,
|
|
23
|
+
TokenAnchor,
|
|
24
|
+
UsageSink,
|
|
25
|
+
clear_ports,
|
|
26
|
+
get_ports,
|
|
27
|
+
register_ports,
|
|
28
|
+
)
|
|
29
|
+
from .spill import SpillHook, SpillPolicy
|
|
30
|
+
from .truncation import clamp_results, truncate_middle
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"KVStore",
|
|
35
|
+
"LogEvent",
|
|
36
|
+
"Ports",
|
|
37
|
+
"SessionLog",
|
|
38
|
+
"SpillHook",
|
|
39
|
+
"SpillOwner",
|
|
40
|
+
"SpillPolicy",
|
|
41
|
+
"SpillRef",
|
|
42
|
+
"SpillSource",
|
|
43
|
+
"SpillStore",
|
|
44
|
+
"TokenAnchor",
|
|
45
|
+
"UsageSink",
|
|
46
|
+
"clamp_results",
|
|
47
|
+
"clear_ports",
|
|
48
|
+
"get_ports",
|
|
49
|
+
"register_ports",
|
|
50
|
+
"truncate_middle",
|
|
51
|
+
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""存储端口(SPI):库与应用之间的分层缝,唯一实现、由应用在进程入口注册
|
|
4
|
+
|
|
5
|
+
agstack 只声明 Protocol 与数据类型,不含任何存储实现;应用(如 MetaMatrix 的 ``common.harness``)用自己的表 /
|
|
6
|
+
文件系统实现后经 :func:`register_ports` 注入。端口刻意少:会话日志(追加 / 读取 / 遮蔽 / 锚点)、落盘(spill)、
|
|
7
|
+
用量汇、可选 KV。
|
|
8
|
+
|
|
9
|
+
``LogEvent`` 借「仅追加日志 + 投影」思路但不做事件溯源:``kind`` 取值见 :data:`LOG_KINDS`;被重试 / 溢出恢复 / 折叠
|
|
10
|
+
取代的行不删,由 ``shadowed_by`` 指向取代它的事件序号,投影只消费 ``shadowed_by is None`` 的行。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any, Protocol, runtime_checkable
|
|
16
|
+
from uuid import UUID
|
|
17
|
+
|
|
18
|
+
from ..client import UsageEvent, set_usage_callback
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
#: 日志事件类型
|
|
22
|
+
LOG_KINDS = frozenset(
|
|
23
|
+
{"message", "event", "tool_call", "tool_result", "summary", "fold", "attempt", "system_snapshot", "phase_marker"}
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
#: 遮蔽原因
|
|
27
|
+
SHADOW_KINDS = frozenset({"retry", "overflow_recovery", "fold"})
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class LogEvent:
|
|
32
|
+
"""会话日志的一条事件
|
|
33
|
+
|
|
34
|
+
:param kind: 事件类型(:data:`LOG_KINDS`)
|
|
35
|
+
:param role: 可投影为模型消息时的角色(system / user / assistant / tool / event),否则 None
|
|
36
|
+
:param content: 正文(消息文本 / 工具结果 / 摘要)
|
|
37
|
+
:param metadata: 结构化附带(tool_calls、tool_call_id、来源、锚点等)
|
|
38
|
+
:param seq: 会话内单调序号,append 时由实现分配
|
|
39
|
+
:param shadowed_by: 遮蔽它的事件序号(None 为有效行)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
kind: str
|
|
43
|
+
role: str | None = None
|
|
44
|
+
content: str | None = None
|
|
45
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
46
|
+
seq: int | None = None
|
|
47
|
+
shadowed_by: int | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class TokenAnchor:
|
|
52
|
+
"""上一次真实请求的 token 锚点:历史主体用锚点值,只对锚点之后的新增内容做估算"""
|
|
53
|
+
|
|
54
|
+
seq: int
|
|
55
|
+
prompt_tokens: int
|
|
56
|
+
model: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True, slots=True)
|
|
60
|
+
class SpillOwner:
|
|
61
|
+
"""落盘内容的归属(权限与密级由实现按此判定)"""
|
|
62
|
+
|
|
63
|
+
user_id: UUID | None
|
|
64
|
+
session_id: str | None = None
|
|
65
|
+
task_id: str | None = None
|
|
66
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class SpillSource:
|
|
71
|
+
"""落盘内容的来源(哪个工具、哪次调用)"""
|
|
72
|
+
|
|
73
|
+
tool: str
|
|
74
|
+
call_id: str | None = None
|
|
75
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class SpillRef:
|
|
80
|
+
"""落盘结果的引用:locator 对模型不透明,读取经实现的权限检查"""
|
|
81
|
+
|
|
82
|
+
locator: str
|
|
83
|
+
chars: int
|
|
84
|
+
tokens: int
|
|
85
|
+
name: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@runtime_checkable
|
|
89
|
+
class SessionLog(Protocol):
|
|
90
|
+
"""会话日志端口"""
|
|
91
|
+
|
|
92
|
+
async def append(self, session_id: UUID, events: Sequence[LogEvent]) -> int:
|
|
93
|
+
"""追加事件,返回末 seq"""
|
|
94
|
+
...
|
|
95
|
+
|
|
96
|
+
async def read(self, session_id: UUID, *, after_seq: int = 0, limit: int | None = None) -> Sequence[LogEvent]:
|
|
97
|
+
"""按 seq 升序读取(含被遮蔽行,投影方自行过滤)"""
|
|
98
|
+
...
|
|
99
|
+
|
|
100
|
+
async def shadow(self, session_id: UUID, target_seqs: Sequence[int], by_seq: int, kind: str) -> None:
|
|
101
|
+
"""把 target_seqs 标为被 by_seq 遮蔽;kind ∈ SHADOW_KINDS"""
|
|
102
|
+
...
|
|
103
|
+
|
|
104
|
+
async def latest_anchor(self, session_id: UUID) -> TokenAnchor | None:
|
|
105
|
+
"""最近一次 token 锚点"""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@runtime_checkable
|
|
110
|
+
class SpillStore(Protocol):
|
|
111
|
+
"""落盘端口"""
|
|
112
|
+
|
|
113
|
+
async def save_text(
|
|
114
|
+
self, owner: SpillOwner, source: SpillSource, suggested_name: str, content: str
|
|
115
|
+
) -> SpillRef: ...
|
|
116
|
+
|
|
117
|
+
async def read_text(self, locator: str, *, offset: int = 0, limit: int | None = None) -> str: ...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: 用量汇:与 client.set_usage_callback 同一回调类型,纳入同一注册面
|
|
121
|
+
UsageSink = Callable[[UsageEvent], None]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@runtime_checkable
|
|
125
|
+
class KVStore(Protocol):
|
|
126
|
+
"""可选 KV 端口(投影缓存、守卫状态)"""
|
|
127
|
+
|
|
128
|
+
async def get(self, scope: str, key: str) -> Any | None: ...
|
|
129
|
+
|
|
130
|
+
async def put(self, scope: str, key: str, value: Any) -> None: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True)
|
|
134
|
+
class Ports:
|
|
135
|
+
"""已注册的端口集合"""
|
|
136
|
+
|
|
137
|
+
session_log: SessionLog | None = None
|
|
138
|
+
spill: SpillStore | None = None
|
|
139
|
+
usage: UsageSink | None = None
|
|
140
|
+
kv: KVStore | None = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
_PORTS = Ports()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register_ports(
|
|
147
|
+
*,
|
|
148
|
+
session_log: SessionLog | None = None,
|
|
149
|
+
spill: SpillStore | None = None,
|
|
150
|
+
usage: UsageSink | None = None,
|
|
151
|
+
kv: KVStore | None = None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""注册端口实现(进程级单例;只覆盖传入的项,传 None 的项保持不变)
|
|
154
|
+
|
|
155
|
+
``usage`` 同时写入 :func:`agstack.llm.client.set_usage_callback`,两处始终一致。
|
|
156
|
+
"""
|
|
157
|
+
if session_log is not None:
|
|
158
|
+
_PORTS.session_log = session_log
|
|
159
|
+
if spill is not None:
|
|
160
|
+
_PORTS.spill = spill
|
|
161
|
+
if usage is not None:
|
|
162
|
+
_PORTS.usage = usage
|
|
163
|
+
set_usage_callback(usage)
|
|
164
|
+
if kv is not None:
|
|
165
|
+
_PORTS.kv = kv
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_ports() -> Ports:
|
|
169
|
+
"""当前端口集合(未注册的项为 None,消费方自行降级)"""
|
|
170
|
+
return _PORTS
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def clear_ports() -> None:
|
|
174
|
+
"""清空全部端口(测试隔离用),usage 回调同步注销"""
|
|
175
|
+
_PORTS.session_log = None
|
|
176
|
+
_PORTS.spill = None
|
|
177
|
+
_PORTS.usage = None
|
|
178
|
+
_PORTS.kv = None
|
|
179
|
+
set_usage_callback(None)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
__all__ = [
|
|
183
|
+
"LOG_KINDS",
|
|
184
|
+
"SHADOW_KINDS",
|
|
185
|
+
"KVStore",
|
|
186
|
+
"LogEvent",
|
|
187
|
+
"Ports",
|
|
188
|
+
"SessionLog",
|
|
189
|
+
"SpillOwner",
|
|
190
|
+
"SpillRef",
|
|
191
|
+
"SpillSource",
|
|
192
|
+
"SpillStore",
|
|
193
|
+
"TokenAnchor",
|
|
194
|
+
"UsageSink",
|
|
195
|
+
"clear_ports",
|
|
196
|
+
"get_ports",
|
|
197
|
+
"register_ports",
|
|
198
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""超长工具结果落盘(spill):ToolHook post 钩子
|
|
4
|
+
|
|
5
|
+
纪律(借 dsh spill-policy):``prepend`` 挂链头使其成为最外层 post;按 ``max_inline_tokens`` 判定;全文经
|
|
6
|
+
:class:`~.ports.SpillStore` 落盘;上下文保头尾 + 固定格式通知(含 locator,模型可用应用提供的读取工具回读);
|
|
7
|
+
read 类工具排除(回读结果再落盘会死循环);**存储失败只 warn 保留内联**(可用性优先于预算)。
|
|
8
|
+
|
|
9
|
+
与截断的关系:clamp 是「丢弃」,spill 是「移位」,两者并存——先 spill 保全文,再 clamp 决定内联多少。
|
|
10
|
+
钩子作用于 ``ToolResult.content``(喂给模型的字符串,Tool 在 post 链前已算好),不改 ``result.result`` 的形状;
|
|
11
|
+
落盘引用写进 ``ToolResult.metadata["spill"]``。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from ..flow.tool import ToolHook, ToolResult
|
|
20
|
+
from .ports import SpillOwner, SpillRef, SpillSource, get_ports
|
|
21
|
+
from .truncation import TokenCounter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from ..flow.context import FlowContext
|
|
26
|
+
from ..flow.tool import Tool
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
#: 通知模板;{tokens} / {chars} 原文规模,{locator} 回读定位符,{name} 建议文件名
|
|
31
|
+
DEFAULT_NOTICE = (
|
|
32
|
+
"\n[... 工具结果约 {tokens} tokens / {chars} 字符,超出内联上限,全文已存为 {name}(locator={locator});"
|
|
33
|
+
"此处只保留开头与结尾,需要细节时按 locator 分段读取 ...]\n"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(slots=True)
|
|
38
|
+
class SpillPolicy:
|
|
39
|
+
"""落盘策略(数值与名单由应用给)
|
|
40
|
+
|
|
41
|
+
:param max_inline_tokens: 内联上限;``content`` 超过即落盘
|
|
42
|
+
:param exclude_tools: 不落盘的工具名(read 类回读工具必须在列)
|
|
43
|
+
:param head_chars / tail_chars: 内联保留的头尾字符数
|
|
44
|
+
:param count_tokens: token 计数函数(应用按模型绑定)
|
|
45
|
+
:param owner_of: 从 FlowContext 解析归属(用户 / 会话 / 任务),实现据此定目录与密级
|
|
46
|
+
:param model_of: 从 FlowContext 解析模型名(仅供日志)
|
|
47
|
+
:param notice: 通知模板
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
max_inline_tokens: int
|
|
51
|
+
count_tokens: TokenCounter
|
|
52
|
+
owner_of: Callable[["FlowContext"], SpillOwner]
|
|
53
|
+
exclude_tools: frozenset[str] = field(default_factory=frozenset)
|
|
54
|
+
head_chars: int = 1200
|
|
55
|
+
tail_chars: int = 400
|
|
56
|
+
notice: str = DEFAULT_NOTICE
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SpillHook(ToolHook):
|
|
60
|
+
"""post 钩子:content 超内联上限时经 SpillStore 落盘并替换为头尾 + 通知;未注册 SpillStore 时为空操作"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, policy: SpillPolicy):
|
|
63
|
+
self.policy = policy
|
|
64
|
+
|
|
65
|
+
async def post_execute(self, context: "FlowContext", tool: "Tool", result: ToolResult) -> ToolResult:
|
|
66
|
+
policy = self.policy
|
|
67
|
+
if tool.name in policy.exclude_tools or not result.success:
|
|
68
|
+
return result
|
|
69
|
+
content = result.content
|
|
70
|
+
if not content:
|
|
71
|
+
return result
|
|
72
|
+
store = get_ports().spill
|
|
73
|
+
if store is None:
|
|
74
|
+
return result
|
|
75
|
+
tokens = policy.count_tokens(content)
|
|
76
|
+
if tokens <= policy.max_inline_tokens:
|
|
77
|
+
return result
|
|
78
|
+
if len(content) <= policy.head_chars + policy.tail_chars:
|
|
79
|
+
return result
|
|
80
|
+
name = f"{tool.name}-{context.get_variable('_agent_call_id') or context.context_id[:8]}.txt"
|
|
81
|
+
try:
|
|
82
|
+
ref = await store.save_text(
|
|
83
|
+
policy.owner_of(context),
|
|
84
|
+
SpillSource(tool=tool.name, call_id=context.get_variable("_agent_call_id"), arguments=result.arguments),
|
|
85
|
+
name,
|
|
86
|
+
content,
|
|
87
|
+
)
|
|
88
|
+
except Exception as e: # 存储失败只 warn,保留内联
|
|
89
|
+
logger.warning("spill store failed for tool %s, keeping inline content: %s", tool.name, e, exc_info=True)
|
|
90
|
+
return result
|
|
91
|
+
result.content = render_spilled(content, ref, policy)
|
|
92
|
+
result.metadata["spill"] = {"locator": ref.locator, "chars": ref.chars, "tokens": ref.tokens, "name": ref.name}
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def render_spilled(content: str, ref: SpillRef, policy: SpillPolicy) -> str:
|
|
97
|
+
"""头尾保留 + 固定格式通知"""
|
|
98
|
+
head = content[: policy.head_chars]
|
|
99
|
+
tail = content[len(content) - policy.tail_chars :]
|
|
100
|
+
notice = policy.notice.format(tokens=ref.tokens, chars=ref.chars, locator=ref.locator, name=ref.name)
|
|
101
|
+
return head + notice + tail
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
__all__ = ["DEFAULT_NOTICE", "SpillHook", "SpillPolicy", "render_spilled"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""工具结果截断设施(写入侧防线;零状态、模型无关)
|
|
4
|
+
|
|
5
|
+
截断的**设施**在此,**调用**留在各工具体内——结果列表按相关度丢弃并回写丢弃数、渲染串截断、单字段截断三种形态
|
|
6
|
+
语义各异,且语义化截断依赖工具内知识,不适合做成盲 post 钩子。上限数值(单条 / 合计占窗口比例)由调用方给。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
#: token 计数函数:text -> tokens(调用方按模型绑定,如 functools.partial(count_tokens, model=...))
|
|
17
|
+
TokenCounter = Callable[[str], int]
|
|
18
|
+
|
|
19
|
+
#: 截断标注模板;{tokens} / {lines} 为原文规模
|
|
20
|
+
DEFAULT_MARKER = "\n[... 原文约 {tokens} tokens / {lines} 行,已截断中段;如需完整内容请缩小查询范围重试 ...]\n"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def truncate_middle(text: str, max_tokens: int, count_tokens: TokenCounter, *, marker: str = DEFAULT_MARKER) -> str:
|
|
24
|
+
"""超限文本保头尾截断,中间段替换为规模标注(截断绝不静默);未超限原样返回"""
|
|
25
|
+
total = count_tokens(text)
|
|
26
|
+
if total <= max_tokens:
|
|
27
|
+
return text
|
|
28
|
+
lines = text.count("\n") + 1
|
|
29
|
+
# 按 token 比例折算保留字符数,留 10% 余量给标注与估算误差
|
|
30
|
+
keep_chars = max(int(len(text) * max_tokens / total * 0.9), 200)
|
|
31
|
+
head_chars = int(keep_chars * 0.7)
|
|
32
|
+
head = text[:head_chars]
|
|
33
|
+
tail = text[len(text) - (keep_chars - head_chars) :]
|
|
34
|
+
return head + marker.format(tokens=total, lines=lines) + tail
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def clamp_results(
|
|
38
|
+
results: list[Any],
|
|
39
|
+
*,
|
|
40
|
+
per_item_max: int,
|
|
41
|
+
total_max: int,
|
|
42
|
+
count_tokens: TokenCounter,
|
|
43
|
+
content_key: str = "content",
|
|
44
|
+
relevance_key: str = "relevance_score",
|
|
45
|
+
tool: str = "",
|
|
46
|
+
) -> tuple[list[Any], int]:
|
|
47
|
+
"""结果列表写入侧统一截断:单条 ``content`` 超 per_item_max 保头尾截断;合计超 total_max 从低相关度端整条丢弃
|
|
48
|
+
|
|
49
|
+
返回 ``(处理后的 results, 丢弃条数)``,丢弃数由调用方显式呈现给模型。
|
|
50
|
+
"""
|
|
51
|
+
if not results:
|
|
52
|
+
return results, 0
|
|
53
|
+
clamped: list[Any] = []
|
|
54
|
+
tokens: list[int] = []
|
|
55
|
+
for item in results:
|
|
56
|
+
if isinstance(item, dict) and isinstance(item.get(content_key), str) and item[content_key]:
|
|
57
|
+
content = truncate_middle(item[content_key], per_item_max, count_tokens)
|
|
58
|
+
if content != item[content_key]:
|
|
59
|
+
item = {**item, content_key: content}
|
|
60
|
+
clamped.append(item)
|
|
61
|
+
tokens.append(count_tokens(item[content_key]))
|
|
62
|
+
else:
|
|
63
|
+
clamped.append(item)
|
|
64
|
+
tokens.append(0)
|
|
65
|
+
|
|
66
|
+
total = sum(tokens)
|
|
67
|
+
if total <= total_max:
|
|
68
|
+
return clamped, 0
|
|
69
|
+
|
|
70
|
+
def _relevance(i: int) -> float:
|
|
71
|
+
item = clamped[i]
|
|
72
|
+
if isinstance(item, dict):
|
|
73
|
+
score = item.get(relevance_key)
|
|
74
|
+
if isinstance(score, (int, float)):
|
|
75
|
+
return float(score)
|
|
76
|
+
return 0.0
|
|
77
|
+
|
|
78
|
+
dropped: set[int] = set()
|
|
79
|
+
for i in sorted(range(len(clamped)), key=_relevance):
|
|
80
|
+
if total <= total_max:
|
|
81
|
+
break
|
|
82
|
+
if tokens[i] == 0:
|
|
83
|
+
continue
|
|
84
|
+
dropped.add(i)
|
|
85
|
+
total -= tokens[i]
|
|
86
|
+
if dropped:
|
|
87
|
+
logger.warning(
|
|
88
|
+
"[%s] tool results exceed %d tokens, dropped %d low-relevance items", tool, total_max, len(dropped)
|
|
89
|
+
)
|
|
90
|
+
return [item for i, item in enumerate(clamped) if i not in dropped], len(dropped)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
__all__ = ["DEFAULT_MARKER", "TokenCounter", "clamp_results", "truncate_middle"]
|