agstack 2.1.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.1.0 → agstack-2.3.0}/PKG-INFO +1 -1
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/client.py +23 -2
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/agent.py +120 -84
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/registry.py +12 -0
- {agstack-2.1.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.1.0 → agstack-2.3.0}/agstack.egg-info/PKG-INFO +1 -1
- {agstack-2.1.0 → agstack-2.3.0}/agstack.egg-info/SOURCES.txt +9 -0
- {agstack-2.1.0 → agstack-2.3.0}/pyproject.toml +1 -1
- agstack-2.3.0/tests/test_agent_request_overrides.py +50 -0
- 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.1.0 → agstack-2.3.0}/LICENSE +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/README.md +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/cache/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/cache/base.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/cache/memory.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/cache/redis.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/config/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/config/logger.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/config/manager.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/config/types.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/contexts.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/decorators.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/events.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/exceptions.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/fastapi/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/fastapi/exception.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/fastapi/middleware.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/fastapi/offline.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/fastapi/sse.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/infra/db/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/infra/es/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/infra/kg/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/infra/mq/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/context.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/event.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/exceptions.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/factory.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/flow.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/loader.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/agent_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/base.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/detect_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/echo_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/iterator_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_chat_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_embed_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/llm_rerank_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/python_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/subflow_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/switch_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/nodes/tool_node.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/records.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/sandbox.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/state.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/flow/trace.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/prompts.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/llm/token.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/messagebus/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/messagebus/base.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/messagebus/memory.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/messagebus/redis.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/schema.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/security/__init__.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/security/casbin.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/security/crypt.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack/status.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack.egg-info/dependency_links.txt +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack.egg-info/requires.txt +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/agstack.egg-info/top_level.txt +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/setup.cfg +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_agent_parallel_tools.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_cache_memory.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_cache_redis.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_flow_cancellation.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_flow_error_semantics.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_flow_io.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_flow_iterator.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_flow_switch_subflow.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_llm_usage_callback.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_messagebus_memory.py +0 -0
- {agstack-2.1.0 → agstack-2.3.0}/tests/test_messagebus_redis.py +0 -0
- {agstack-2.1.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}")
|
|
@@ -34,6 +34,7 @@ class Agent:
|
|
|
34
34
|
*,
|
|
35
35
|
tool_choice: str = "auto",
|
|
36
36
|
on_max_turns: str = "finalize",
|
|
37
|
+
retry_empty_response: bool = False,
|
|
37
38
|
label: str | None = None,
|
|
38
39
|
echo: bool = False,
|
|
39
40
|
):
|
|
@@ -47,6 +48,8 @@ class Agent:
|
|
|
47
48
|
:param max_tokens: 最大 token 数
|
|
48
49
|
:param max_turns: 最大轮次
|
|
49
50
|
:param on_max_turns: max_turns 耗尽时的行为,"finalize"(降级输出并标记 truncated)或 "error"(抛出异常)
|
|
51
|
+
:param retry_empty_response: 一轮既无文字也无 tool_calls 时(如推理模型把输出预算耗尽在 reasoning 上)
|
|
52
|
+
以 ``request_overrides(..., retry=True)`` 的覆盖参数重试一次
|
|
50
53
|
:param label: 面向用户的展示名称(控制 STEP 进度事件可见性)
|
|
51
54
|
:param echo: 是否转发 TEXT_MESSAGE 给用户
|
|
52
55
|
"""
|
|
@@ -59,6 +62,7 @@ class Agent:
|
|
|
59
62
|
self.max_turns = max_turns
|
|
60
63
|
self.tool_choice = tool_choice
|
|
61
64
|
self.on_max_turns = on_max_turns
|
|
65
|
+
self.retry_empty_response = retry_empty_response
|
|
62
66
|
self.label = label
|
|
63
67
|
self.echo = echo
|
|
64
68
|
|
|
@@ -70,6 +74,27 @@ class Agent:
|
|
|
70
74
|
"""获取工具 schema"""
|
|
71
75
|
return [tool.to_openai_tool() for tool in self.tools]
|
|
72
76
|
|
|
77
|
+
def request_overrides(self, context: "FlowContext", turn: int, *, retry: bool = False) -> dict[str, Any]:
|
|
78
|
+
"""按轮覆盖本次模型请求参数的钩子(子类实现,默认不覆盖)
|
|
79
|
+
|
|
80
|
+
返回值合并进 ``client.chat`` 的 kwargs:``extra_body`` 按键合并,其余键直接覆盖。
|
|
81
|
+
典型用法:决策轮 / 作答轮分别设置 ``extra_body={"enable_thinking": ...}`` 与 ``max_tokens``;
|
|
82
|
+
``retry=True`` 表示上一次请求空响应后的重试。
|
|
83
|
+
|
|
84
|
+
:param turn: 本 agent 本次运行内的轮次,从 1 起
|
|
85
|
+
"""
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _apply_overrides(kwargs: dict[str, Any], overrides: dict[str, Any]) -> None:
|
|
90
|
+
for key, value in overrides.items():
|
|
91
|
+
if key == "extra_body" and isinstance(value, dict):
|
|
92
|
+
merged = dict(kwargs.get("extra_body") or {})
|
|
93
|
+
merged.update(value)
|
|
94
|
+
kwargs["extra_body"] = merged
|
|
95
|
+
else:
|
|
96
|
+
kwargs[key] = value
|
|
97
|
+
|
|
73
98
|
def get_tool_by_name(self, name: str) -> "Tool | None":
|
|
74
99
|
"""根据名称获取工具"""
|
|
75
100
|
for tool in self.tools:
|
|
@@ -265,7 +290,7 @@ class Agent:
|
|
|
265
290
|
|
|
266
291
|
# Agent 循环
|
|
267
292
|
assistant_content = ""
|
|
268
|
-
for
|
|
293
|
+
for turn in range(1, self.max_turns + 1):
|
|
269
294
|
# 协作式取消检查点:不再开始新的 LLM 轮次
|
|
270
295
|
if context.is_cancelled:
|
|
271
296
|
if not context.get_variable("_cancel_emitted"):
|
|
@@ -275,96 +300,107 @@ class Agent:
|
|
|
275
300
|
|
|
276
301
|
context.increment_turn()
|
|
277
302
|
|
|
278
|
-
#
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
303
|
+
# 调用模型;空响应(无文字无 tool_calls)且开启 retry_empty_response 时以重试覆盖参数再请求一次
|
|
304
|
+
attempt = 0
|
|
305
|
+
while True:
|
|
306
|
+
assistant_content = ""
|
|
307
|
+
tool_calls: list[dict[str, Any]] = []
|
|
308
|
+
tool_calls_buffer: dict[int, dict[str, Any]] = {}
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
kwargs: dict[str, Any] = {
|
|
312
|
+
"messages": messages,
|
|
313
|
+
"model": self.model,
|
|
314
|
+
"temperature": self.temperature,
|
|
315
|
+
}
|
|
282
316
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if tools_schema:
|
|
294
|
-
kwargs["tools"] = tools_schema
|
|
295
|
-
kwargs["tool_choice"] = self.tool_choice
|
|
296
|
-
|
|
297
|
-
stream = await client.chat(stream=True, **kwargs)
|
|
298
|
-
|
|
299
|
-
async for chunk in stream:
|
|
300
|
-
if not chunk.choices:
|
|
301
|
-
continue
|
|
302
|
-
|
|
303
|
-
choice = chunk.choices[0]
|
|
304
|
-
delta = choice.delta
|
|
305
|
-
|
|
306
|
-
# 内容增量 - AG-UI: TEXT_MESSAGE_CONTENT
|
|
307
|
-
if delta.content:
|
|
308
|
-
assistant_content += delta.content
|
|
309
|
-
yield event.text_message_content(
|
|
310
|
-
message_id=msg_id,
|
|
311
|
-
delta=delta.content,
|
|
312
|
-
)
|
|
313
|
-
|
|
314
|
-
# 工具调用
|
|
315
|
-
if delta.tool_calls:
|
|
316
|
-
for tool_call_delta in delta.tool_calls:
|
|
317
|
-
idx = tool_call_delta.index # noqa
|
|
318
|
-
if idx not in tool_calls_buffer:
|
|
319
|
-
tool_calls_buffer[idx] = {
|
|
320
|
-
"id": tool_call_delta.id or "", # noqa
|
|
321
|
-
"name": "",
|
|
322
|
-
"arguments": "",
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
if tool_call_delta.id: # noqa
|
|
326
|
-
tool_calls_buffer[idx]["id"] = tool_call_delta.id # noqa
|
|
327
|
-
if tool_call_delta.function and tool_call_delta.function.name: # noqa
|
|
328
|
-
tool_calls_buffer[idx]["name"] = tool_call_delta.function.name # noqa
|
|
329
|
-
if tool_call_delta.function and tool_call_delta.function.arguments: # noqa
|
|
330
|
-
tool_calls_buffer[idx]["arguments"] += tool_call_delta.function.arguments # noqa
|
|
331
|
-
|
|
332
|
-
# 完成
|
|
333
|
-
if choice.finish_reason:
|
|
334
|
-
# AG-UI: 工具调用事件
|
|
335
|
-
for tool_call_data in tool_calls_buffer.values():
|
|
336
|
-
tool_calls.append(tool_call_data)
|
|
337
|
-
|
|
338
|
-
# TOOL_CALL_START
|
|
339
|
-
yield event.tool_call_start(
|
|
340
|
-
tool_call_id=tool_call_data["id"],
|
|
341
|
-
tool_call_name=tool_call_data["name"],
|
|
342
|
-
)
|
|
317
|
+
if self.max_tokens:
|
|
318
|
+
kwargs["max_tokens"] = self.max_tokens
|
|
319
|
+
|
|
320
|
+
if tools_schema:
|
|
321
|
+
kwargs["tools"] = tools_schema
|
|
322
|
+
kwargs["tool_choice"] = self.tool_choice
|
|
323
|
+
|
|
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}
|
|
343
327
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
328
|
+
stream = await client.chat(stream=True, **kwargs)
|
|
329
|
+
|
|
330
|
+
async for chunk in stream:
|
|
331
|
+
if not chunk.choices:
|
|
332
|
+
continue
|
|
333
|
+
|
|
334
|
+
choice = chunk.choices[0]
|
|
335
|
+
delta = choice.delta
|
|
336
|
+
|
|
337
|
+
# 内容增量 - AG-UI: TEXT_MESSAGE_CONTENT
|
|
338
|
+
if delta.content:
|
|
339
|
+
assistant_content += delta.content
|
|
340
|
+
yield event.text_message_content(
|
|
341
|
+
message_id=msg_id,
|
|
342
|
+
delta=delta.content,
|
|
348
343
|
)
|
|
349
344
|
|
|
350
|
-
|
|
351
|
-
|
|
345
|
+
# 工具调用
|
|
346
|
+
if delta.tool_calls:
|
|
347
|
+
for tool_call_delta in delta.tool_calls:
|
|
348
|
+
idx = tool_call_delta.index # noqa
|
|
349
|
+
if idx not in tool_calls_buffer:
|
|
350
|
+
tool_calls_buffer[idx] = {
|
|
351
|
+
"id": tool_call_delta.id or "", # noqa
|
|
352
|
+
"name": "",
|
|
353
|
+
"arguments": "",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if tool_call_delta.id: # noqa
|
|
357
|
+
tool_calls_buffer[idx]["id"] = tool_call_delta.id # noqa
|
|
358
|
+
if tool_call_delta.function and tool_call_delta.function.name: # noqa
|
|
359
|
+
tool_calls_buffer[idx]["name"] = tool_call_delta.function.name # noqa
|
|
360
|
+
if tool_call_delta.function and tool_call_delta.function.arguments: # noqa
|
|
361
|
+
tool_calls_buffer[idx]["arguments"] += tool_call_delta.function.arguments # noqa
|
|
362
|
+
|
|
363
|
+
# 完成
|
|
364
|
+
if choice.finish_reason:
|
|
365
|
+
# AG-UI: 工具调用事件
|
|
366
|
+
for tool_call_data in tool_calls_buffer.values():
|
|
367
|
+
tool_calls.append(tool_call_data)
|
|
368
|
+
|
|
369
|
+
# TOOL_CALL_START
|
|
370
|
+
yield event.tool_call_start(
|
|
371
|
+
tool_call_id=tool_call_data["id"],
|
|
372
|
+
tool_call_name=tool_call_data["name"],
|
|
373
|
+
)
|
|
352
374
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
prompt_tokens=chunk.usage.prompt_tokens or 0,
|
|
358
|
-
completion_tokens=chunk.usage.completion_tokens or 0,
|
|
359
|
-
total_tokens=chunk.usage.total_tokens or 0,
|
|
375
|
+
# TOOL_CALL_ARGS
|
|
376
|
+
yield event.tool_call_args(
|
|
377
|
+
tool_call_id=tool_call_data["id"],
|
|
378
|
+
delta=tool_call_data["arguments"],
|
|
360
379
|
)
|
|
361
|
-
)
|
|
362
380
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
381
|
+
# TOOL_CALL_END
|
|
382
|
+
yield event.tool_call_end(tool_call_id=tool_call_data["id"])
|
|
383
|
+
|
|
384
|
+
# 更新 usage
|
|
385
|
+
if hasattr(chunk, "usage") and chunk.usage:
|
|
386
|
+
context.add_usage(
|
|
387
|
+
Usage(
|
|
388
|
+
prompt_tokens=chunk.usage.prompt_tokens or 0,
|
|
389
|
+
completion_tokens=chunk.usage.completion_tokens or 0,
|
|
390
|
+
total_tokens=chunk.usage.total_tokens or 0,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
except Exception as e:
|
|
395
|
+
error_msg = str(e)
|
|
396
|
+
# AG-UI: RUN_ERROR
|
|
397
|
+
yield event.run_error(message=error_msg)
|
|
398
|
+
raise FlowError("AGENT_EXECUTION_FAILED", 500, {"error": error_msg}) from e
|
|
399
|
+
|
|
400
|
+
if self.retry_empty_response and attempt == 0 and not tool_calls and not assistant_content.strip():
|
|
401
|
+
attempt = 1
|
|
402
|
+
continue
|
|
403
|
+
break
|
|
368
404
|
|
|
369
405
|
# 保存 assistant 消息(tool_calls 转为 OpenAI 标准格式)
|
|
370
406
|
if tool_calls:
|
|
@@ -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
|
+
]
|