agstack 2.3.0__tar.gz → 3.0.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.
Files changed (106) hide show
  1. {agstack-2.3.0 → agstack-3.0.0}/PKG-INFO +6 -9
  2. {agstack-2.3.0 → agstack-3.0.0}/README.md +5 -8
  3. agstack-3.0.0/agstack/genai/__init__.py +10 -0
  4. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/__init__.py +5 -0
  5. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/agent.py +61 -1
  6. agstack-3.0.0/agstack/genai/flow/guards.py +421 -0
  7. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/detect_node.py +1 -1
  8. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/echo_node.py +3 -3
  9. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/llm_chat_node.py +1 -1
  10. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/llm_embed_node.py +1 -1
  11. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/llm_rerank_node.py +1 -1
  12. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/registry.py +1 -1
  13. agstack-3.0.0/agstack/genai/harness/__init__.py +120 -0
  14. agstack-3.0.0/agstack/genai/harness/context.py +411 -0
  15. agstack-3.0.0/agstack/genai/harness/events.py +222 -0
  16. agstack-3.0.0/agstack/genai/harness/metering.py +87 -0
  17. agstack-3.0.0/agstack/genai/harness/overflow.py +130 -0
  18. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/harness/ports.py +2 -2
  19. agstack-3.0.0/agstack/genai/harness/projection.py +141 -0
  20. {agstack-2.3.0/agstack → agstack-3.0.0/agstack/genai}/llm/client.py +3 -3
  21. {agstack-2.3.0/agstack → agstack-3.0.0/agstack/genai}/llm/hooks.py +2 -2
  22. {agstack-2.3.0 → agstack-3.0.0}/agstack.egg-info/PKG-INFO +6 -9
  23. agstack-3.0.0/agstack.egg-info/SOURCES.txt +102 -0
  24. {agstack-2.3.0 → agstack-3.0.0}/pyproject.toml +1 -1
  25. agstack-3.0.0/tests/test_agent_guards.py +229 -0
  26. agstack-3.0.0/tests/test_agent_overflow.py +203 -0
  27. {agstack-2.3.0 → agstack-3.0.0}/tests/test_agent_parallel_tools.py +9 -9
  28. {agstack-2.3.0 → agstack-3.0.0}/tests/test_agent_request_overrides.py +6 -6
  29. {agstack-2.3.0 → agstack-3.0.0}/tests/test_flow_cancellation.py +9 -9
  30. {agstack-2.3.0 → agstack-3.0.0}/tests/test_flow_error_semantics.py +16 -16
  31. {agstack-2.3.0 → agstack-3.0.0}/tests/test_flow_io.py +30 -30
  32. {agstack-2.3.0 → agstack-3.0.0}/tests/test_flow_iterator.py +11 -11
  33. {agstack-2.3.0 → agstack-3.0.0}/tests/test_flow_switch_subflow.py +8 -8
  34. agstack-3.0.0/tests/test_harness_context.py +224 -0
  35. agstack-3.0.0/tests/test_harness_events_projection_metering.py +238 -0
  36. {agstack-2.3.0 → agstack-3.0.0}/tests/test_harness_ports_truncation.py +4 -4
  37. {agstack-2.3.0 → agstack-3.0.0}/tests/test_harness_spill.py +3 -3
  38. {agstack-2.3.0 → agstack-3.0.0}/tests/test_llm_call_hooks.py +3 -3
  39. {agstack-2.3.0 → agstack-3.0.0}/tests/test_llm_usage_callback.py +2 -2
  40. {agstack-2.3.0 → agstack-3.0.0}/tests/test_tool_hooks.py +6 -6
  41. agstack-2.3.0/agstack/llm/harness/__init__.py +0 -51
  42. agstack-2.3.0/agstack.egg-info/SOURCES.txt +0 -91
  43. {agstack-2.3.0 → agstack-3.0.0}/LICENSE +0 -0
  44. {agstack-2.3.0 → agstack-3.0.0}/agstack/__init__.py +0 -0
  45. {agstack-2.3.0 → agstack-3.0.0}/agstack/cache/__init__.py +0 -0
  46. {agstack-2.3.0 → agstack-3.0.0}/agstack/cache/base.py +0 -0
  47. {agstack-2.3.0 → agstack-3.0.0}/agstack/cache/memory.py +0 -0
  48. {agstack-2.3.0 → agstack-3.0.0}/agstack/cache/redis.py +0 -0
  49. {agstack-2.3.0 → agstack-3.0.0}/agstack/config/__init__.py +0 -0
  50. {agstack-2.3.0 → agstack-3.0.0}/agstack/config/logger.py +0 -0
  51. {agstack-2.3.0 → agstack-3.0.0}/agstack/config/manager.py +0 -0
  52. {agstack-2.3.0 → agstack-3.0.0}/agstack/config/types.py +0 -0
  53. {agstack-2.3.0 → agstack-3.0.0}/agstack/contexts.py +0 -0
  54. {agstack-2.3.0 → agstack-3.0.0}/agstack/decorators.py +0 -0
  55. {agstack-2.3.0 → agstack-3.0.0}/agstack/events.py +0 -0
  56. {agstack-2.3.0 → agstack-3.0.0}/agstack/exceptions.py +0 -0
  57. {agstack-2.3.0 → agstack-3.0.0}/agstack/fastapi/__init__.py +0 -0
  58. {agstack-2.3.0 → agstack-3.0.0}/agstack/fastapi/exception.py +0 -0
  59. {agstack-2.3.0 → agstack-3.0.0}/agstack/fastapi/middleware.py +0 -0
  60. {agstack-2.3.0 → agstack-3.0.0}/agstack/fastapi/offline.py +0 -0
  61. {agstack-2.3.0 → agstack-3.0.0}/agstack/fastapi/sse.py +0 -0
  62. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/context.py +0 -0
  63. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/event.py +0 -0
  64. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/exceptions.py +0 -0
  65. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/factory.py +0 -0
  66. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/flow.py +0 -0
  67. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/loader.py +0 -0
  68. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/__init__.py +0 -0
  69. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/agent_node.py +0 -0
  70. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/base.py +0 -0
  71. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/iterator_node.py +0 -0
  72. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/python_node.py +0 -0
  73. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/subflow_node.py +0 -0
  74. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/switch_node.py +0 -0
  75. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/nodes/tool_node.py +0 -0
  76. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/records.py +0 -0
  77. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/sandbox.py +0 -0
  78. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/state.py +0 -0
  79. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/tool.py +0 -0
  80. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/flow/trace.py +0 -0
  81. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/harness/spill.py +0 -0
  82. {agstack-2.3.0/agstack/llm → agstack-3.0.0/agstack/genai}/harness/truncation.py +0 -0
  83. {agstack-2.3.0/agstack → agstack-3.0.0/agstack/genai}/llm/__init__.py +0 -0
  84. {agstack-2.3.0/agstack → agstack-3.0.0/agstack/genai}/llm/prompts.py +0 -0
  85. {agstack-2.3.0/agstack → agstack-3.0.0/agstack/genai}/llm/token.py +0 -0
  86. {agstack-2.3.0 → agstack-3.0.0}/agstack/infra/db/__init__.py +0 -0
  87. {agstack-2.3.0 → agstack-3.0.0}/agstack/infra/es/__init__.py +0 -0
  88. {agstack-2.3.0 → agstack-3.0.0}/agstack/infra/kg/__init__.py +0 -0
  89. {agstack-2.3.0 → agstack-3.0.0}/agstack/infra/mq/__init__.py +0 -0
  90. {agstack-2.3.0 → agstack-3.0.0}/agstack/messagebus/__init__.py +0 -0
  91. {agstack-2.3.0 → agstack-3.0.0}/agstack/messagebus/base.py +0 -0
  92. {agstack-2.3.0 → agstack-3.0.0}/agstack/messagebus/memory.py +0 -0
  93. {agstack-2.3.0 → agstack-3.0.0}/agstack/messagebus/redis.py +0 -0
  94. {agstack-2.3.0 → agstack-3.0.0}/agstack/schema.py +0 -0
  95. {agstack-2.3.0 → agstack-3.0.0}/agstack/security/__init__.py +0 -0
  96. {agstack-2.3.0 → agstack-3.0.0}/agstack/security/casbin.py +0 -0
  97. {agstack-2.3.0 → agstack-3.0.0}/agstack/security/crypt.py +0 -0
  98. {agstack-2.3.0 → agstack-3.0.0}/agstack/status.py +0 -0
  99. {agstack-2.3.0 → agstack-3.0.0}/agstack.egg-info/dependency_links.txt +0 -0
  100. {agstack-2.3.0 → agstack-3.0.0}/agstack.egg-info/requires.txt +0 -0
  101. {agstack-2.3.0 → agstack-3.0.0}/agstack.egg-info/top_level.txt +0 -0
  102. {agstack-2.3.0 → agstack-3.0.0}/setup.cfg +0 -0
  103. {agstack-2.3.0 → agstack-3.0.0}/tests/test_cache_memory.py +0 -0
  104. {agstack-2.3.0 → agstack-3.0.0}/tests/test_cache_redis.py +0 -0
  105. {agstack-2.3.0 → agstack-3.0.0}/tests/test_messagebus_memory.py +0 -0
  106. {agstack-2.3.0 → agstack-3.0.0}/tests/test_messagebus_redis.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agstack
3
- Version: 2.3.0
3
+ Version: 3.0.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>
@@ -86,7 +86,7 @@ pip install agstack
86
86
  ### Basic Usage
87
87
 
88
88
  ```python
89
- from agstack.llm.flow import (
89
+ from agstack.genai.flow import (
90
90
  Tool,
91
91
  FlowContext,
92
92
  registry,
@@ -130,13 +130,10 @@ agstack/
130
130
  ├── schema.py # Base Pydantic models
131
131
  ├── registry.py # Global component registry
132
132
  ├── exceptions.py # Exception hierarchy
133
- ├── llm/ # LLM and AI features
134
- │ ├── client.py # LLM client
135
- │ └── flow/ # Flow execution framework
136
- │ ├── agent.py # Agent definition
137
- │ ├── tool.py # Tool definition
138
- │ ├── flow.py # Flow orchestration
139
- │ └── ...
133
+ ├── genai/ # Generative AI, three one-way layers
134
+ │ ├── llm/ # Model access: client, call hooks, prompts, token counting
135
+ │ ├── flow/ # Flow execution framework (agent / tool / flow / nodes / guards)
136
+ │ └── harness/ # Runtime: ports, events, projection, metering, overflow, context, spill
140
137
  ├── fastapi/ # FastAPI integration
141
138
  ├── infra/ # Infrastructure components
142
139
  │ ├── db/ # Database
@@ -28,7 +28,7 @@ pip install agstack
28
28
  ### Basic Usage
29
29
 
30
30
  ```python
31
- from agstack.llm.flow import (
31
+ from agstack.genai.flow import (
32
32
  Tool,
33
33
  FlowContext,
34
34
  registry,
@@ -72,13 +72,10 @@ agstack/
72
72
  ├── schema.py # Base Pydantic models
73
73
  ├── registry.py # Global component registry
74
74
  ├── exceptions.py # Exception hierarchy
75
- ├── llm/ # LLM and AI features
76
- │ ├── client.py # LLM client
77
- │ └── flow/ # Flow execution framework
78
- │ ├── agent.py # Agent definition
79
- │ ├── tool.py # Tool definition
80
- │ ├── flow.py # Flow orchestration
81
- │ └── ...
75
+ ├── genai/ # Generative AI, three one-way layers
76
+ │ ├── llm/ # Model access: client, call hooks, prompts, token counting
77
+ │ ├── flow/ # Flow execution framework (agent / tool / flow / nodes / guards)
78
+ │ └── harness/ # Runtime: ports, events, projection, metering, overflow, context, spill
82
79
  ├── fastapi/ # FastAPI integration
83
80
  ├── infra/ # Infrastructure components
84
81
  │ ├── db/ # Database
@@ -0,0 +1,10 @@
1
+ # Copyright (c) 2020-2026 XtraVisions, All rights reserved.
2
+
3
+ """agstack.genai——生成式 AI 三层(3.0)
4
+
5
+ - :mod:`.llm`:模型接入(client / hooks / prompts / token),不依赖另外两层;
6
+ - :mod:`.flow`:编排(Agent / Tool / Flow / nodes / guards),依赖 llm;
7
+ - :mod:`.harness`:运行时纪律(ports / events / projection / metering / context / overflow / spill),依赖 llm 与 flow。
8
+
9
+ 依赖单向、不成环;应用对本包的依赖应集中在一处适配层。
10
+ """
@@ -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",
@@ -7,7 +7,8 @@ import json
7
7
  from typing import TYPE_CHECKING, Any, AsyncIterator
8
8
  from uuid import uuid4
9
9
 
10
- from ..client import get_llm_client
10
+ from ..harness.overflow import OverflowPolicy, classify_overflow, usage_tokens
11
+ from ..llm.client import get_llm_client
11
12
  from . import event
12
13
  from .context import Usage
13
14
  from .event import EventType
@@ -22,6 +23,9 @@ if TYPE_CHECKING:
22
23
  class Agent:
23
24
  """Agent 定义"""
24
25
 
26
+ #: 上下文溢出恢复策略(harness.overflow;None 不恢复)。子类可按类属性给缺省,构造参数 ``overflow`` 覆盖
27
+ overflow: OverflowPolicy | None = None
28
+
25
29
  def __init__(
26
30
  self,
27
31
  name: str,
@@ -37,6 +41,7 @@ class Agent:
37
41
  retry_empty_response: bool = False,
38
42
  label: str | None = None,
39
43
  echo: bool = False,
44
+ overflow: OverflowPolicy | None = None,
40
45
  ):
41
46
  """初始化 Agent
42
47
 
@@ -52,6 +57,7 @@ class Agent:
52
57
  以 ``request_overrides(..., retry=True)`` 的覆盖参数重试一次
53
58
  :param label: 面向用户的展示名称(控制 STEP 进度事件可见性)
54
59
  :param echo: 是否转发 TEXT_MESSAGE 给用户
60
+ :param overflow: 上下文溢出恢复策略(三态判定命中后调 ``compact`` 压缩并同轮重发一次,见 harness.overflow)
55
61
  """
56
62
  self.name = name
57
63
  self.instructions = instructions or f"You are {name}, a helpful AI assistant."
@@ -65,6 +71,8 @@ class Agent:
65
71
  self.retry_empty_response = retry_empty_response
66
72
  self.label = label
67
73
  self.echo = echo
74
+ if overflow is not None:
75
+ self.overflow = overflow
68
76
 
69
77
  def get_system_message(self) -> dict[str, Any]:
70
78
  """获取系统消息"""
@@ -102,6 +110,25 @@ class Agent:
102
110
  return tool
103
111
  return None
104
112
 
113
+ # ── 溢出恢复(机制;策略在 self.overflow) ──
114
+
115
+ def _max_recoveries(self) -> int:
116
+ return self.overflow.max_recoveries if self.overflow is not None else 0
117
+
118
+ def _overflow_kind(self, context: "FlowContext", *, error: BaseException | None = None, **usage: Any) -> str | None:
119
+ if self.overflow is None:
120
+ return None
121
+ if error is not None:
122
+ return classify_overflow(error=error)
123
+ return classify_overflow(context_length=self.overflow.window(context), **usage)
124
+
125
+ async def _compact_overflow(self, context: "FlowContext", kind: str) -> bool:
126
+ assert self.overflow is not None
127
+ try:
128
+ return bool(await self.overflow.compact(context, kind))
129
+ except Exception: # noqa: BLE001 — 压缩失败不替代原错误,按原路报错
130
+ return False
131
+
105
132
  def _group_tool_calls(self, tool_calls: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
106
133
  """按声明分组:连续的 concurrency_safe 调用聚为一组并发执行,其余单独成组串行
107
134
 
@@ -290,6 +317,7 @@ class Agent:
290
317
 
291
318
  # Agent 循环
292
319
  assistant_content = ""
320
+ recoveries = 0 # 溢出恢复次数(每次运行上限 overflow.max_recoveries)
293
321
  for turn in range(1, self.max_turns + 1):
294
322
  # 协作式取消检查点:不再开始新的 LLM 轮次
295
323
  if context.is_cancelled:
@@ -306,6 +334,8 @@ class Agent:
306
334
  assistant_content = ""
307
335
  tool_calls: list[dict[str, Any]] = []
308
336
  tool_calls_buffer: dict[int, dict[str, Any]] = {}
337
+ finish_reason: str | None = None
338
+ turn_usage: Any = None
309
339
 
310
340
  try:
311
341
  kwargs: dict[str, Any] = {
@@ -362,6 +392,7 @@ class Agent:
362
392
 
363
393
  # 完成
364
394
  if choice.finish_reason:
395
+ finish_reason = choice.finish_reason
365
396
  # AG-UI: 工具调用事件
366
397
  for tool_call_data in tool_calls_buffer.values():
367
398
  tool_calls.append(tool_call_data)
@@ -383,6 +414,7 @@ class Agent:
383
414
 
384
415
  # 更新 usage
385
416
  if hasattr(chunk, "usage") and chunk.usage:
417
+ turn_usage = chunk.usage
386
418
  context.add_usage(
387
419
  Usage(
388
420
  prompt_tokens=chunk.usage.prompt_tokens or 0,
@@ -392,11 +424,39 @@ class Agent:
392
424
  )
393
425
 
394
426
  except Exception as e:
427
+ # 上下文溢出(后端报错态):尚未放出任何内容时压缩并同轮重发一次(harness.overflow)
428
+ kind = self._overflow_kind(context, error=e) if not assistant_content and not tool_calls else None
429
+ if kind and recoveries < self._max_recoveries():
430
+ recoveries += 1
431
+ yield event.custom(
432
+ name="agent_overflow", value={"agentName": self.name, "kind": kind, "turn": turn}
433
+ )
434
+ if await self._compact_overflow(context, kind):
435
+ messages = [self.get_system_message()] + context.history + context.get_messages(self.name)
436
+ continue
395
437
  error_msg = str(e)
396
438
  # AG-UI: RUN_ERROR
397
439
  yield event.run_error(message=error_msg)
398
440
  raise FlowError("AGENT_EXECUTION_FAILED", 500, {"error": error_msg}) from e
399
441
 
442
+ # 上下文溢出(静默 / length 态):请求成功但输入已撑爆窗口且本次没有产出,同样压缩重发一次
443
+ if not tool_calls and not assistant_content.strip():
444
+ prompt_tokens, completion_tokens = usage_tokens(turn_usage)
445
+ kind = self._overflow_kind(
446
+ context,
447
+ finish_reason=finish_reason,
448
+ prompt_tokens=prompt_tokens,
449
+ completion_tokens=completion_tokens,
450
+ )
451
+ if kind and recoveries < self._max_recoveries():
452
+ recoveries += 1
453
+ yield event.custom(
454
+ name="agent_overflow", value={"agentName": self.name, "kind": kind, "turn": turn}
455
+ )
456
+ if await self._compact_overflow(context, kind):
457
+ messages = [self.get_system_message()] + context.history + context.get_messages(self.name)
458
+ continue
459
+
400
460
  if self.retry_empty_response and attempt == 0 and not tool_calls and not assistant_content.strip():
401
461
  attempt = 1
402
462
  continue
@@ -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
+ ]
@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
6
6
 
7
7
  from openai.types.chat import ChatCompletionMessageParam
8
8
 
9
- from ...client import get_llm_client
9
+ from ...llm.client import get_llm_client
10
10
  from ..context import Usage
11
11
  from .base import NodeHandler
12
12
 
@@ -5,12 +5,12 @@
5
5
  from typing import TYPE_CHECKING, Any, AsyncIterator
6
6
  from uuid import uuid4
7
7
 
8
- from agstack.llm.flow import event
9
- from agstack.llm.flow.nodes.base import NodeHandler
8
+ from agstack.genai.flow import event
9
+ from agstack.genai.flow.nodes.base import NodeHandler
10
10
 
11
11
 
12
12
  if TYPE_CHECKING:
13
- from agstack.llm.flow.context import FlowContext
13
+ from agstack.genai.flow.context import FlowContext
14
14
 
15
15
 
16
16
  class EchoNodeHandler(NodeHandler):