agstack 2.2.0__tar.gz → 2.4.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.4.0}/PKG-INFO +1 -1
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/client.py +23 -2
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/__init__.py +5 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/agent.py +2 -0
- agstack-2.4.0/agstack/llm/flow/guards.py +421 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/registry.py +12 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/tool.py +32 -18
- agstack-2.4.0/agstack/llm/harness/__init__.py +71 -0
- agstack-2.4.0/agstack/llm/harness/events.py +222 -0
- agstack-2.4.0/agstack/llm/harness/ports.py +198 -0
- agstack-2.4.0/agstack/llm/harness/projection.py +141 -0
- agstack-2.4.0/agstack/llm/harness/spill.py +104 -0
- agstack-2.4.0/agstack/llm/harness/tokens.py +87 -0
- agstack-2.4.0/agstack/llm/harness/truncation.py +93 -0
- agstack-2.4.0/agstack/llm/hooks.py +112 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-2.2.0 → agstack-2.4.0}/agstack.egg-info/SOURCES.txt +14 -0
- {agstack-2.2.0 → agstack-2.4.0}/pyproject.toml +1 -1
- agstack-2.4.0/tests/test_agent_guards.py +229 -0
- agstack-2.4.0/tests/test_harness_events_projection_tokens.py +238 -0
- agstack-2.4.0/tests/test_harness_ports_truncation.py +90 -0
- agstack-2.4.0/tests/test_harness_spill.py +136 -0
- agstack-2.4.0/tests/test_llm_call_hooks.py +185 -0
- {agstack-2.2.0 → agstack-2.4.0}/LICENSE +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/README.md +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/cache/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/cache/base.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/cache/memory.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/cache/redis.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/config/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/config/logger.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/config/manager.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/config/types.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/contexts.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/decorators.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/events.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/exceptions.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/fastapi/exception.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/fastapi/offline.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/fastapi/sse.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/context.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/event.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/flow.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/iterator_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/records.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/state.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/flow/trace.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/prompts.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/llm/token.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/messagebus/base.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/messagebus/memory.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/messagebus/redis.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/schema.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/security/__init__.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/security/casbin.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/security/crypt.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack/status.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/setup.cfg +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_agent_parallel_tools.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_agent_request_overrides.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_cache_memory.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_cache_redis.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_flow_cancellation.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_flow_error_semantics.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_flow_io.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_flow_iterator.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_llm_usage_callback.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_messagebus_memory.py +0 -0
- {agstack-2.2.0 → agstack-2.4.0}/tests/test_messagebus_redis.py +0 -0
- {agstack-2.2.0 → agstack-2.4.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.4.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}")
|
|
@@ -17,6 +17,7 @@ from .exceptions import (
|
|
|
17
17
|
)
|
|
18
18
|
from .factory import create_agent, create_tool
|
|
19
19
|
from .flow import Flow
|
|
20
|
+
from .guards import AgentGuards, GuardedToolCalls, GuardState, buffer_plan_text
|
|
20
21
|
from .loader import FlowLoader
|
|
21
22
|
from .nodes import NodeHandler
|
|
22
23
|
from .records import Record, Status
|
|
@@ -35,6 +36,10 @@ __all__ = [
|
|
|
35
36
|
"ToolHook",
|
|
36
37
|
"Deny",
|
|
37
38
|
"Agent",
|
|
39
|
+
"AgentGuards",
|
|
40
|
+
"GuardState",
|
|
41
|
+
"GuardedToolCalls",
|
|
42
|
+
"buffer_plan_text",
|
|
38
43
|
"Flow",
|
|
39
44
|
"FlowContext",
|
|
40
45
|
"Usage",
|
|
@@ -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
|
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# Copyright (c) 2020-2026 XtraVisions, All rights reserved.
|
|
2
|
+
|
|
3
|
+
"""Agent 工具调用守卫(AgentGuards)
|
|
4
|
+
|
|
5
|
+
「红线在代码」的一层:模型在给到的工具里自由决策,但以下几条**不靠提示词**,由本模块在
|
|
6
|
+
``Agent._stream_tool_call`` 前后执行:
|
|
7
|
+
|
|
8
|
+
- 同名同参重复调用直接退回上次结果(:attr:`AgentGuards.duplicate_kind`);
|
|
9
|
+
- 受上限工具族累计调用达上限后返回「预算用尽」不再执行(:attr:`AgentGuards.cap_kind`);
|
|
10
|
+
- 应用自定义的执行前守卫(:attr:`AgentGuards.checks`,如「先库后网」);
|
|
11
|
+
- 工具结果累计 token 超预算时把最早的结果折叠为摘要(:attr:`AgentGuards.fold_kind`;最新一条永不折叠);
|
|
12
|
+
- 工具结果末尾附应用给的提示(:attr:`AgentGuards.hint`,如「够了就停」的取材提示)。
|
|
13
|
+
|
|
14
|
+
每个守卫动作写一条与 Tool 管线同形的执行记录进 ``context.execution_records``(随节点 trace 持久化,审计按
|
|
15
|
+
``tool_name`` 呈现)。本模块只有机制;受上限的工具族、上限值、折叠渲染、提示文案、自定义规则全由应用在
|
|
16
|
+
:class:`AgentGuards` 里给。
|
|
17
|
+
|
|
18
|
+
另附 :func:`buffer_plan_text`:按轮缓冲助手文字——轮以工具调用结束时文字只进 trace(「这次为什么调它」),轮以
|
|
19
|
+
文字结束时放流;轮次耗尽且无可展示文字时按应用给的 closing_line 收尾,不把空串或已记为计划的文字当回答交出去。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from . import event
|
|
31
|
+
from .context import FlowContext
|
|
32
|
+
from .event import EventType
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
#: 守卫动作的缺省 ``tool_name``(审计呈现用;应用可在 :class:`AgentGuards` 覆盖)
|
|
38
|
+
GUARD_DUPLICATE = "guard_duplicate_call"
|
|
39
|
+
GUARD_CAP = "guard_call_cap"
|
|
40
|
+
GUARD_FOLD = "guard_fold_results"
|
|
41
|
+
#: 计划记录的缺省 ``tool_name``
|
|
42
|
+
PLAN_RECORD = "agent_plan"
|
|
43
|
+
|
|
44
|
+
#: 执行前守卫:``(state, tool_name, raw_arguments) -> (守卫种类, 退给模型的结果) | None``;None 放行
|
|
45
|
+
type GuardCheck = Callable[["GuardState", str, str], tuple[str, dict[str, Any]] | None]
|
|
46
|
+
#: 折叠渲染:``(context, 原工具结果文本) -> 折叠后文本``
|
|
47
|
+
type FoldRenderer = Callable[[FlowContext, str], str]
|
|
48
|
+
#: 结果提示:``(state, tool_name, 结果文本) -> 追加到末尾的提示 | None``
|
|
49
|
+
type HintBuilder = Callable[["GuardState", str, str], str | None]
|
|
50
|
+
#: token 计数:``(text, model) -> tokens``
|
|
51
|
+
type TokenCounter = Callable[[str, str], int]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class GuardState:
|
|
56
|
+
"""一次 Agent 运行的守卫计数(每次 ``stream`` 开始时 :meth:`reset`)
|
|
57
|
+
|
|
58
|
+
:param cap: 本次运行的工具族上限;None 取 :attr:`AgentGuards.cap`
|
|
59
|
+
:param seen_calls: 调用签名 → tool_call_id(重复调用判定)
|
|
60
|
+
:param calls: 工具名 → 累计放行次数
|
|
61
|
+
:param family_calls: 受上限工具族累计放行次数
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
cap: int | None = None
|
|
65
|
+
seen_calls: dict[str, str] = field(default_factory=dict)
|
|
66
|
+
calls: dict[str, int] = field(default_factory=dict)
|
|
67
|
+
family_calls: int = 0
|
|
68
|
+
|
|
69
|
+
def reset(self) -> None:
|
|
70
|
+
self.seen_calls = {}
|
|
71
|
+
self.calls = {}
|
|
72
|
+
self.family_calls = 0
|
|
73
|
+
|
|
74
|
+
def calls_of(self, *names: str) -> int:
|
|
75
|
+
"""给定工具名的累计放行次数之和"""
|
|
76
|
+
return sum(self.calls.get(n, 0) for n in names)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _default_duplicate_payload(previous_call_id: str) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"note": "与此前一次调用的工具与参数完全相同,结果未变(见前一次结果);请换查询或直接作答",
|
|
82
|
+
"previous_tool_call_id": previous_call_id,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_cap_payload(cap: int) -> dict[str, Any]:
|
|
87
|
+
return {
|
|
88
|
+
"error": "CALL_BUDGET_EXHAUSTED",
|
|
89
|
+
"hint": f"本轮该类工具调用次数已达上限({cap} 次),请基于已取得的结果作答并说明未覆盖之处",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class AgentGuards:
|
|
95
|
+
"""守卫策略(应用给值)
|
|
96
|
+
|
|
97
|
+
:param capped_family: 累计调用受上限的工具族(空=不限)
|
|
98
|
+
:param cap: 工具族累计调用上限缺省值(:attr:`GuardState.cap` 可按次运行覆盖)
|
|
99
|
+
:param checks: 应用自定义执行前守卫,在重复调用与上限之后按序执行
|
|
100
|
+
:param hint: 工具结果末尾附加的提示构造器(None=不附)
|
|
101
|
+
:param fold_budget_ratio: 工具结果累计 token 预算 = ``context_length // ratio``(0=不折叠)
|
|
102
|
+
:param fold_renderer: 折叠后文本的渲染(None=固定一句通知)
|
|
103
|
+
:param count_tokens: 折叠判定用的 token 计数(None=不折叠)
|
|
104
|
+
:param context_length_var / model_var: 从 ``context.variables`` 取上下文窗口与模型名的键
|
|
105
|
+
:param duplicate_kind / cap_kind / fold_kind: 三类守卫动作写执行记录时的 ``tool_name``
|
|
106
|
+
:param duplicate_payload / cap_payload: 退给模型的结果构造
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
capped_family: tuple[str, ...] = ()
|
|
110
|
+
cap: int = 5
|
|
111
|
+
checks: tuple[GuardCheck, ...] = ()
|
|
112
|
+
hint: HintBuilder | None = None
|
|
113
|
+
fold_budget_ratio: int = 3
|
|
114
|
+
fold_renderer: FoldRenderer | None = None
|
|
115
|
+
count_tokens: TokenCounter | None = None
|
|
116
|
+
context_length_var: str = "context_length"
|
|
117
|
+
context_length_default: int = 32768
|
|
118
|
+
model_var: str = "llm_model"
|
|
119
|
+
duplicate_kind: str = GUARD_DUPLICATE
|
|
120
|
+
cap_kind: str = GUARD_CAP
|
|
121
|
+
fold_kind: str = GUARD_FOLD
|
|
122
|
+
duplicate_payload: Callable[[str], dict[str, Any]] = _default_duplicate_payload
|
|
123
|
+
cap_payload: Callable[[int], dict[str, Any]] = _default_cap_payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ── 纯函数 ──
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def call_signature(name: str, arguments: str) -> str:
|
|
130
|
+
"""同名同参重复调用的判定键:参数 JSON 规范化(键排序)后与工具名拼接"""
|
|
131
|
+
try:
|
|
132
|
+
parsed = json.loads(arguments) if arguments else {}
|
|
133
|
+
except ValueError:
|
|
134
|
+
parsed = arguments
|
|
135
|
+
return f"{name}:{json.dumps(parsed, ensure_ascii=False, sort_keys=True)}"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def check_tool_call(
|
|
139
|
+
guards: AgentGuards, state: GuardState, name: str, arguments: str
|
|
140
|
+
) -> tuple[str, dict[str, Any]] | None:
|
|
141
|
+
"""执行前守卫:返回 ``(守卫种类, 退给模型的结果)``;None 表示放行
|
|
142
|
+
|
|
143
|
+
判定顺序:重复调用 → 工具族上限 → 应用自定义 checks。只读 ``state``,放行后由 :func:`note_tool_call` 记账。
|
|
144
|
+
"""
|
|
145
|
+
previous = state.seen_calls.get(call_signature(name, arguments))
|
|
146
|
+
if previous is not None:
|
|
147
|
+
return guards.duplicate_kind, guards.duplicate_payload(previous)
|
|
148
|
+
cap = state.cap if state.cap is not None else guards.cap
|
|
149
|
+
if name in guards.capped_family and state.family_calls >= cap:
|
|
150
|
+
return guards.cap_kind, guards.cap_payload(cap)
|
|
151
|
+
for check in guards.checks:
|
|
152
|
+
hit = check(state, name, arguments)
|
|
153
|
+
if hit is not None:
|
|
154
|
+
return hit
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def note_tool_call(guards: AgentGuards, state: GuardState, name: str, arguments: str, call_id: str) -> None:
|
|
159
|
+
"""放行后记账:登记签名、累计工具与工具族次数"""
|
|
160
|
+
state.seen_calls[call_signature(name, arguments)] = call_id
|
|
161
|
+
state.calls[name] = state.calls.get(name, 0) + 1
|
|
162
|
+
if name in guards.capped_family:
|
|
163
|
+
state.family_calls += 1
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
_FOLDED_NOTE = "(该工具结果已折叠以节省上下文)"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fold_tool_messages(
|
|
170
|
+
context: FlowContext,
|
|
171
|
+
agent_name: str,
|
|
172
|
+
*,
|
|
173
|
+
budget_tokens: int,
|
|
174
|
+
model: str,
|
|
175
|
+
count_tokens: TokenCounter,
|
|
176
|
+
render: FoldRenderer | None = None,
|
|
177
|
+
) -> int:
|
|
178
|
+
"""工具结果累计超预算时,从最早的工具消息起折叠,返回折叠条数
|
|
179
|
+
|
|
180
|
+
最新一条工具消息永不折叠(模型正要读它);已折叠的消息(``_folded``)不重复处理。
|
|
181
|
+
"""
|
|
182
|
+
messages = context.messages.get(agent_name) or []
|
|
183
|
+
tool_idx = [i for i, m in enumerate(messages) if m.get("role") == "tool"]
|
|
184
|
+
if len(tool_idx) < 2:
|
|
185
|
+
return 0
|
|
186
|
+
sizes = {i: count_tokens(str(messages[i].get("content") or ""), model) for i in tool_idx}
|
|
187
|
+
total = sum(sizes.values())
|
|
188
|
+
if total <= budget_tokens:
|
|
189
|
+
return 0
|
|
190
|
+
folded = 0
|
|
191
|
+
for i in tool_idx[:-1]:
|
|
192
|
+
if total <= budget_tokens:
|
|
193
|
+
break
|
|
194
|
+
msg = messages[i]
|
|
195
|
+
if msg.get("_folded"):
|
|
196
|
+
continue
|
|
197
|
+
content = str(msg.get("content") or "")
|
|
198
|
+
replacement = render(context, content) if render else _FOLDED_NOTE
|
|
199
|
+
messages[i] = {**msg, "content": replacement, "_folded": True}
|
|
200
|
+
total -= sizes[i] - count_tokens(replacement, model)
|
|
201
|
+
folded += 1
|
|
202
|
+
if folded:
|
|
203
|
+
logger.info("[%s] folded %d tool results (total=%d budget=%d)", agent_name, folded, total, budget_tokens)
|
|
204
|
+
return folded
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def add_trace_record(
|
|
208
|
+
context: FlowContext,
|
|
209
|
+
tool_name: str,
|
|
210
|
+
*,
|
|
211
|
+
args: dict[str, Any] | None = None,
|
|
212
|
+
result: str = "",
|
|
213
|
+
summary: str | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""守卫动作 / 调用计划写一条执行记录(与 Tool 管线写的记录同形,随节点 trace 的 ``tool_calls`` 持久化)"""
|
|
216
|
+
context.execution_records.append(
|
|
217
|
+
{
|
|
218
|
+
"agent_call_id": context.get_variable("_agent_call_id"),
|
|
219
|
+
"tool_name": tool_name,
|
|
220
|
+
"tool_args": args or {},
|
|
221
|
+
"success": True,
|
|
222
|
+
"result": result,
|
|
223
|
+
"error": None,
|
|
224
|
+
"duration_ms": 0,
|
|
225
|
+
"summary": summary,
|
|
226
|
+
}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# ── Agent mixin ──
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class GuardedToolCalls:
|
|
234
|
+
"""给 ``Agent`` 子类接守卫的 mixin:宿主须有 ``guards: AgentGuards``、``guard: GuardState``、``name``、``model``,
|
|
235
|
+
并在 MRO 上先于 ``Agent``
|
|
236
|
+
|
|
237
|
+
``_stream_tool_call`` 执行前跑 :func:`check_tool_call`,放行后记账并交给基类执行;执行后给结果附提示,
|
|
238
|
+
再按上下文窗口预算折叠最早的工具结果。
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
guards: AgentGuards
|
|
242
|
+
guard: GuardState
|
|
243
|
+
name: str
|
|
244
|
+
model: str
|
|
245
|
+
|
|
246
|
+
async def _stream_tool_call(
|
|
247
|
+
self,
|
|
248
|
+
context: FlowContext,
|
|
249
|
+
tool_call: dict[str, Any],
|
|
250
|
+
message_sink: list[dict[str, Any]] | None = None,
|
|
251
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
252
|
+
name = tool_call["name"]
|
|
253
|
+
arguments = tool_call.get("arguments") or ""
|
|
254
|
+
hit = check_tool_call(self.guards, self.guard, name, arguments)
|
|
255
|
+
if hit is not None:
|
|
256
|
+
kind, payload = hit
|
|
257
|
+
content = json.dumps(payload, ensure_ascii=False)
|
|
258
|
+
if message_sink is None:
|
|
259
|
+
context.add_message(self.name, "tool", content=content, tool_call_id=tool_call["id"])
|
|
260
|
+
else:
|
|
261
|
+
message_sink.append({"content": content, "tool_call_id": tool_call["id"]})
|
|
262
|
+
add_trace_record(
|
|
263
|
+
context, kind, args={"tool": name, "arguments": arguments}, result=content, summary=f"守卫拦截:{name}"
|
|
264
|
+
)
|
|
265
|
+
yield event.tool_call_result(tool_call_id=tool_call["id"], content=content)
|
|
266
|
+
return
|
|
267
|
+
note_tool_call(self.guards, self.guard, name, arguments, tool_call["id"])
|
|
268
|
+
async for evt in super()._stream_tool_call(context, tool_call, message_sink): # type: ignore[misc]
|
|
269
|
+
yield evt
|
|
270
|
+
self._append_hint(context, name, tool_call["id"], message_sink)
|
|
271
|
+
self._fold(context)
|
|
272
|
+
|
|
273
|
+
def _fold(self, context: FlowContext) -> None:
|
|
274
|
+
guards = self.guards
|
|
275
|
+
if guards.count_tokens is None or guards.fold_budget_ratio <= 0:
|
|
276
|
+
return
|
|
277
|
+
context_length = int(
|
|
278
|
+
context.get_variable(guards.context_length_var, guards.context_length_default)
|
|
279
|
+
or guards.context_length_default
|
|
280
|
+
)
|
|
281
|
+
budget = context_length // guards.fold_budget_ratio
|
|
282
|
+
folded = fold_tool_messages(
|
|
283
|
+
context,
|
|
284
|
+
self.name,
|
|
285
|
+
budget_tokens=budget,
|
|
286
|
+
model=str(context.get_variable(guards.model_var) or self.model),
|
|
287
|
+
count_tokens=guards.count_tokens,
|
|
288
|
+
render=guards.fold_renderer,
|
|
289
|
+
)
|
|
290
|
+
if folded:
|
|
291
|
+
add_trace_record(
|
|
292
|
+
context,
|
|
293
|
+
guards.fold_kind,
|
|
294
|
+
args={"folded": folded, "budget_tokens": budget},
|
|
295
|
+
summary=f"守卫折叠:最早 {folded} 条工具结果折叠为摘要",
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _append_hint(
|
|
299
|
+
self, context: FlowContext, name: str, call_id: str, message_sink: list[dict[str, Any]] | None
|
|
300
|
+
) -> None:
|
|
301
|
+
"""把提示追加到刚写回的 tool 消息末尾(消息在 sink 或 context 里,按 tool_call_id 定位)"""
|
|
302
|
+
if self.guards.hint is None:
|
|
303
|
+
return
|
|
304
|
+
target: dict[str, Any] | None = None
|
|
305
|
+
if message_sink is not None:
|
|
306
|
+
target = next((m for m in reversed(message_sink) if m.get("tool_call_id") == call_id), None)
|
|
307
|
+
else:
|
|
308
|
+
messages = context.messages.get(self.name) or []
|
|
309
|
+
target = next(
|
|
310
|
+
(m for m in reversed(messages) if m.get("role") == "tool" and m.get("tool_call_id") == call_id), None
|
|
311
|
+
)
|
|
312
|
+
if target is None:
|
|
313
|
+
return
|
|
314
|
+
content = str(target.get("content") or "")
|
|
315
|
+
hint = self.guards.hint(self.guard, name, content)
|
|
316
|
+
if hint:
|
|
317
|
+
target["content"] = content + "\n" + hint
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── 过程话语缓冲 ──
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
async def buffer_plan_text(
|
|
324
|
+
events: AsyncIterator[dict[str, Any]],
|
|
325
|
+
context: FlowContext,
|
|
326
|
+
*,
|
|
327
|
+
agent_name: str,
|
|
328
|
+
buffer_chars: int = 200,
|
|
329
|
+
plan_kind: str = PLAN_RECORD,
|
|
330
|
+
closing_line: Callable[[Sequence[dict[str, Any]]], str | None] | None = None,
|
|
331
|
+
truncated_line: str = "本轮未能在规定步骤内整理出回答,请换种说法再试一次。",
|
|
332
|
+
empty_line: str = "本轮没有生成出回答(模型输出为空),请重试或换种说法。",
|
|
333
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
334
|
+
"""包装 ``Agent.stream`` 的事件流:按轮缓冲助手文字,把「调工具前的过程话语」记为计划而不放流
|
|
335
|
+
|
|
336
|
+
- 轮以工具调用结束:缓冲内的文字写一条 ``plan_kind`` 执行记录(``args.next_tool`` 为随后调用的工具、
|
|
337
|
+
``args.streamed`` 标记文字是否已有部分放流),不交给用户;
|
|
338
|
+
- 轮以文字结束:缓冲整体放流;单轮文字超过 ``buffer_chars`` 时从该点起实时放流(长答不等整段);
|
|
339
|
+
- 轮次耗尽(输出带 ``truncated``)或末轮无文字:按 ``closing_line(messages)`` 收尾,其返回 None 时用
|
|
340
|
+
``truncated_line`` / ``empty_line``,并把输出 ``result`` 改写为该句。
|
|
341
|
+
"""
|
|
342
|
+
buffer: list[str] = []
|
|
343
|
+
streamed = "" # 本轮已放流的文字(超过缓冲阈值后开始放流)
|
|
344
|
+
plan_done = False # 本轮文字已记为计划(该轮有工具调用)
|
|
345
|
+
results_seen = False # 本轮已出现工具结果:下一个 TEXT / TOOL_CALL_START 属于新一轮
|
|
346
|
+
async for evt in events:
|
|
347
|
+
etype = evt.get("type")
|
|
348
|
+
if etype == EventType.TEXT_MESSAGE_CONTENT:
|
|
349
|
+
if results_seen:
|
|
350
|
+
buffer, streamed, plan_done, results_seen = [], "", False, False
|
|
351
|
+
delta = str(evt.get("delta") or "")
|
|
352
|
+
if streamed:
|
|
353
|
+
streamed += delta
|
|
354
|
+
yield evt
|
|
355
|
+
continue
|
|
356
|
+
buffer.append(delta)
|
|
357
|
+
if sum(len(piece) for piece in buffer) >= buffer_chars:
|
|
358
|
+
streamed = "".join(buffer)
|
|
359
|
+
buffer = []
|
|
360
|
+
yield event.text_message_content(message_id=str(evt.get("messageId") or ""), delta=streamed)
|
|
361
|
+
continue
|
|
362
|
+
if etype == EventType.TOOL_CALL_START:
|
|
363
|
+
if results_seen:
|
|
364
|
+
buffer, streamed, plan_done, results_seen = [], "", False, False
|
|
365
|
+
if not plan_done:
|
|
366
|
+
plan_done = True
|
|
367
|
+
text = (streamed + "".join(buffer)).strip()
|
|
368
|
+
buffer = []
|
|
369
|
+
if text:
|
|
370
|
+
add_trace_record(
|
|
371
|
+
context,
|
|
372
|
+
plan_kind,
|
|
373
|
+
args={"next_tool": str(evt.get("toolCallName") or ""), "streamed": bool(streamed)},
|
|
374
|
+
result=text,
|
|
375
|
+
summary=("(已展示给用户)" if streamed else "") + text[:80],
|
|
376
|
+
)
|
|
377
|
+
yield evt
|
|
378
|
+
continue
|
|
379
|
+
if etype == EventType.TOOL_CALL_RESULT:
|
|
380
|
+
results_seen = True
|
|
381
|
+
yield evt
|
|
382
|
+
continue
|
|
383
|
+
if etype == EventType.TEXT_MESSAGE_END:
|
|
384
|
+
msg_id = str(evt.get("messageId") or "")
|
|
385
|
+
if buffer:
|
|
386
|
+
streamed += "".join(buffer)
|
|
387
|
+
yield event.text_message_content(message_id=msg_id, delta="".join(buffer))
|
|
388
|
+
buffer = []
|
|
389
|
+
out = context.outputs.get(agent_name)
|
|
390
|
+
if isinstance(out, dict) and not streamed.strip():
|
|
391
|
+
# 轮次耗尽:最后一轮文字若已被记为计划(未展示),不能当回答交出去;末轮 content 为空同样不能交出空串
|
|
392
|
+
line = closing_line(context.get_messages(agent_name)) if closing_line else None
|
|
393
|
+
if out.get("truncated"):
|
|
394
|
+
line = line or truncated_line
|
|
395
|
+
else:
|
|
396
|
+
line = line or empty_line
|
|
397
|
+
logger.warning("[%s] empty final content, fallback line emitted", agent_name)
|
|
398
|
+
yield event.text_message_content(message_id=msg_id, delta=line)
|
|
399
|
+
context.set_output(agent_name, {**out, "result": line})
|
|
400
|
+
yield evt
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
__all__ = [
|
|
404
|
+
"GUARD_CAP",
|
|
405
|
+
"GUARD_DUPLICATE",
|
|
406
|
+
"GUARD_FOLD",
|
|
407
|
+
"PLAN_RECORD",
|
|
408
|
+
"AgentGuards",
|
|
409
|
+
"FoldRenderer",
|
|
410
|
+
"GuardCheck",
|
|
411
|
+
"GuardState",
|
|
412
|
+
"GuardedToolCalls",
|
|
413
|
+
"HintBuilder",
|
|
414
|
+
"TokenCounter",
|
|
415
|
+
"add_trace_record",
|
|
416
|
+
"buffer_plan_text",
|
|
417
|
+
"call_signature",
|
|
418
|
+
"check_tool_call",
|
|
419
|
+
"fold_tool_messages",
|
|
420
|
+
"note_tool_call",
|
|
421
|
+
]
|
|
@@ -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:
|