ni.agentkit 0.3.2__tar.gz → 0.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.
Files changed (105) hide show
  1. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/PKG-INFO +13 -1
  2. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/README.md +12 -0
  3. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/__init__.py +4 -2
  4. ni_agentkit-0.4.0/agents/agent.py +443 -0
  5. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/agents/base_agent.py +34 -7
  6. ni_agentkit-0.4.0/agents/orchestrators.py +138 -0
  7. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/docs/Architecture.md +92 -14
  8. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/docs/QuickStart.md +444 -3
  9. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/docs/README.md +6 -6
  10. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/docs/Reference.md +151 -24
  11. ni_agentkit-0.4.0/docs/TestReport.md +100 -0
  12. ni_agentkit-0.4.0/examples/ollama/09a_structured_data_sql.py +59 -0
  13. ni_agentkit-0.4.0/examples/ollama/09b_structured_data_graph.py +78 -0
  14. ni_agentkit-0.4.0/examples/ollama/10_skill_lifecycle.py +47 -0
  15. ni_agentkit-0.4.0/examples/ollama/11_orchestration_enhancement.py +80 -0
  16. ni_agentkit-0.4.0/examples/ollama/12_run_context_serialization.py +60 -0
  17. ni_agentkit-0.4.0/examples/ollama/13_human_in_the_loop.py +93 -0
  18. ni_agentkit-0.4.0/examples/ollama/14_event_standardization.py +55 -0
  19. ni_agentkit-0.4.0/examples/ollama/15_multi_tenant_isolation.py +93 -0
  20. ni_agentkit-0.4.0/examples/ollama/16_lifecycle_hooks.py +96 -0
  21. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/README.md +6 -2
  22. ni_agentkit-0.4.0/examples/standard/09a_structured_data_sql.py +56 -0
  23. ni_agentkit-0.4.0/examples/standard/09b_structured_data_graph.py +75 -0
  24. ni_agentkit-0.4.0/examples/standard/10_skill_lifecycle.py +51 -0
  25. ni_agentkit-0.4.0/examples/standard/11_orchestration_enhancement.py +80 -0
  26. ni_agentkit-0.4.0/examples/standard/12_run_context_serialization.py +57 -0
  27. ni_agentkit-0.4.0/examples/standard/13_human_in_the_loop.py +90 -0
  28. ni_agentkit-0.4.0/examples/standard/14_event_standardization.py +50 -0
  29. ni_agentkit-0.4.0/examples/standard/15_multi_tenant_isolation.py +103 -0
  30. ni_agentkit-0.4.0/examples/standard/16_lifecycle_hooks.py +102 -0
  31. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/README.md +6 -2
  32. ni_agentkit-0.4.0/examples/test_ollama.py +45 -0
  33. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/PKG-INFO +13 -1
  34. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/SOURCES.txt +44 -0
  35. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/pyproject.toml +1 -1
  36. ni_agentkit-0.4.0/runner/context.py +107 -0
  37. ni_agentkit-0.4.0/runner/context_store.py +84 -0
  38. ni_agentkit-0.4.0/runner/events.py +116 -0
  39. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/runner/runner.py +90 -6
  40. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/skills/models.py +41 -0
  41. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/skills/registry.py +23 -3
  42. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/tools/base_tool.py +16 -0
  43. ni_agentkit-0.4.0/tools/nebula_tool.py +105 -0
  44. ni_agentkit-0.4.0/tools/sqlite_tool.py +69 -0
  45. ni_agentkit-0.4.0/tools/structured_data.py +92 -0
  46. ni_agentkit-0.3.2/agents/agent.py +0 -364
  47. ni_agentkit-0.3.2/agents/orchestrators.py +0 -62
  48. ni_agentkit-0.3.2/docs/TestReport.md +0 -80
  49. ni_agentkit-0.3.2/examples/test_ollama.py +0 -272
  50. ni_agentkit-0.3.2/runner/context.py +0 -47
  51. ni_agentkit-0.3.2/runner/events.py +0 -36
  52. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/LICENSE +0 -0
  53. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/_cli.py +0 -0
  54. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/agents/__init__.py +0 -0
  55. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/__init__.py +0 -0
  56. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/01_basic_chat.py +0 -0
  57. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/02_tool_calling.py +0 -0
  58. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/03_skill_usage.py +0 -0
  59. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/04_multi_agent.py +0 -0
  60. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/05_guardrail.py +0 -0
  61. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/06_orchestration.py +0 -0
  62. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/07_sync_async_stream.py +0 -0
  63. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/08_memory.py +0 -0
  64. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/ollama/__init__.py +0 -0
  65. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/quickstart.py +0 -0
  66. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/01_basic_chat.py +0 -0
  67. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/02_tool_calling.py +0 -0
  68. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/03_skill_usage.py +0 -0
  69. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/04_multi_agent.py +0 -0
  70. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/05_guardrail.py +0 -0
  71. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/06_orchestration.py +0 -0
  72. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/07_sync_async_stream.py +0 -0
  73. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/08_memory.py +0 -0
  74. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/examples/standard/__init__.py +0 -0
  75. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/__init__.py +0 -0
  76. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/__init__.py +0 -0
  77. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/anthropic_adapter.py +0 -0
  78. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/google_adapter.py +0 -0
  79. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/ollama_adapter.py +0 -0
  80. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/openai_adapter.py +0 -0
  81. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/adapters/openai_compatible.py +0 -0
  82. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/base.py +0 -0
  83. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/cache.py +0 -0
  84. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/middleware.py +0 -0
  85. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/registry.py +0 -0
  86. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/llm/types.py +0 -0
  87. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/memory/__init__.py +0 -0
  88. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/memory/base.py +0 -0
  89. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/memory/mem0_provider.py +0 -0
  90. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/dependency_links.txt +0 -0
  91. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/entry_points.txt +0 -0
  92. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/requires.txt +0 -0
  93. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/ni.agentkit.egg-info/top_level.txt +0 -0
  94. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/runner/__init__.py +0 -0
  95. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/safety/__init__.py +0 -0
  96. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/safety/guardrails.py +0 -0
  97. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/safety/permissions.py +0 -0
  98. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/setup.cfg +0 -0
  99. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/skills/__init__.py +0 -0
  100. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/skills/loader.py +0 -0
  101. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/tools/__init__.py +0 -0
  102. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/tools/function_tool.py +0 -0
  103. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/tools/skill_toolset.py +0 -0
  104. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/utils/__init__.py +0 -0
  105. {ni_agentkit-0.3.2 → ni_agentkit-0.4.0}/utils/schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ni.agentkit
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: A Python-native Agent framework with first-class Skill support and multi-LLM adapter
5
5
  Author-email: Krix Tam <krix.tam@qq.com>
6
6
  License: MIT
@@ -56,11 +56,23 @@ Dynamic: license-file
56
56
  - 🛡️ **内置安全** — Guardrail 护栏 + 权限控制 + 三级沙箱
57
57
  - 🎭 **编排 Agent** — Sequential / Parallel / Loop 三种模式
58
58
  - 💾 **记忆系统** — 可选集成 Mem0,支持自定义记忆提供者
59
+ - ⚡ **全异步设计与 Hooks** — 底层全面采用 `asyncio`,流式响应(Streaming)、事件驱动(Event-Driven)、断点续跑机制(Checkpoint/Resume)。提供细粒度的 生命周期 Hooks,支持改写请求与结果。
60
+ - 🏢 **多租户数据隔离** — 框架级支持 `user_id` / `session_id` 贯穿,Memory 默认分桶,Session 结束自动释放资源。
61
+ - 🔗 **与大模型平台解耦** — 内置多模型支持,轻松对接 Ollama 等本地模型。
59
62
 
60
63
  ## 🚀 安装
61
64
 
62
65
  ```bash
66
+ # 基础安装
63
67
  pip install ni.agentkit
68
+
69
+ # 如果需要 OpenAI / DeepSeek / 通义千问等
70
+ pip install "ni.agentkit[openai]"
71
+ pip install "ni.agentkit[anthropic]"
72
+ pip install "ni.agentkit[google]"
73
+ pip install "ni.agentkit[memory]"
74
+ pip install "ni.agentkit[docker]"
75
+ pip install "ni.agentkit[all]"
64
76
  ```
65
77
 
66
78
  ## ⚡ 30 秒快速开始
@@ -14,11 +14,23 @@
14
14
  - 🛡️ **内置安全** — Guardrail 护栏 + 权限控制 + 三级沙箱
15
15
  - 🎭 **编排 Agent** — Sequential / Parallel / Loop 三种模式
16
16
  - 💾 **记忆系统** — 可选集成 Mem0,支持自定义记忆提供者
17
+ - ⚡ **全异步设计与 Hooks** — 底层全面采用 `asyncio`,流式响应(Streaming)、事件驱动(Event-Driven)、断点续跑机制(Checkpoint/Resume)。提供细粒度的 生命周期 Hooks,支持改写请求与结果。
18
+ - 🏢 **多租户数据隔离** — 框架级支持 `user_id` / `session_id` 贯穿,Memory 默认分桶,Session 结束自动释放资源。
19
+ - 🔗 **与大模型平台解耦** — 内置多模型支持,轻松对接 Ollama 等本地模型。
17
20
 
18
21
  ## 🚀 安装
19
22
 
20
23
  ```bash
24
+ # 基础安装
21
25
  pip install ni.agentkit
26
+
27
+ # 如果需要 OpenAI / DeepSeek / 通义千问等
28
+ pip install "ni.agentkit[openai]"
29
+ pip install "ni.agentkit[anthropic]"
30
+ pip install "ni.agentkit[google]"
31
+ pip install "ni.agentkit[memory]"
32
+ pip install "ni.agentkit[docker]"
33
+ pip install "ni.agentkit[all]"
22
34
  ```
23
35
 
24
36
  ## ⚡ 30 秒快速开始
@@ -28,8 +28,10 @@ from .skills.models import Skill, SkillFrontmatter, SkillResources
28
28
  from .skills.registry import SkillRegistry
29
29
  from .tools.base_tool import BaseTool, BaseToolset
30
30
  from .tools.function_tool import FunctionTool, function_tool
31
+ from .tools.structured_data import ResultFormatter, StructuredDataTool
32
+ from .tools.sqlite_tool import SQLiteTool, SQLiteResultFormatter
31
33
 
32
- __version__ = "0.3.2"
34
+ __version__ = "0.4.0"
33
35
 
34
36
 
35
37
  def get_docs_dir() -> str:
@@ -48,7 +50,7 @@ __all__ = [
48
50
  "Agent", "BaseAgent", "SequentialAgent", "ParallelAgent", "LoopAgent",
49
51
  "Runner", "RunResult", "Event",
50
52
  "BaseLLM", "LLMConfig", "LLMRegistry", "LLMResponse", "Message", "ToolCall", "ToolDefinition",
51
- "BaseTool", "BaseToolset", "FunctionTool", "function_tool",
53
+ "BaseTool", "BaseToolset", "FunctionTool", "function_tool", "StructuredDataTool", "ResultFormatter", "SQLiteTool",
52
54
  "Skill", "SkillFrontmatter", "SkillResources", "SkillRegistry", "load_skill_from_dir",
53
55
  "GuardrailResult", "InputGuardrail", "OutputGuardrail", "PermissionPolicy",
54
56
  "input_guardrail", "output_guardrail",
@@ -0,0 +1,443 @@
1
+ """
2
+ agentkit/agents/agent.py — 核心 LLM Agent
3
+
4
+ 开发者 99% 情况下使用的类。融合:
5
+ - OpenAI 的声明式配置
6
+ - Google 的丰富回调
7
+ - Skill 一等公民
8
+ - Handoff + as_tool 双协作模式
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import logging
14
+ from typing import Any, AsyncGenerator, Callable, Optional, Union
15
+
16
+ from pydantic import ConfigDict, Field, PrivateAttr
17
+
18
+ from ..llm.base import BaseLLM
19
+ from ..llm.registry import LLMRegistry
20
+ from ..llm.types import LLMConfig, Message, MessageRole, ToolCall as LLMToolCall
21
+ from ..runner.events import Event, EventType
22
+ from ..skills.models import Skill
23
+ from ..tools.base_tool import BaseTool, BaseToolset, HumanInputRequested
24
+ from ..tools.function_tool import FunctionTool
25
+ from ..tools.skill_toolset import SkillToolset
26
+ from .base_agent import BaseAgent
27
+
28
+ from ..runner.context import RunContext
29
+
30
+ logger = logging.getLogger("agentkit.agent")
31
+
32
+
33
+ class Agent(BaseAgent):
34
+ """核心 LLM Agent"""
35
+
36
+ # === LLM 配置 ===
37
+ model: Union[str, LLMConfig, BaseLLM, None] = ""
38
+ instructions: Union[str, Callable] = ""
39
+
40
+ # === 工具 & 技能 ===
41
+ tools: list[Any] = Field(default_factory=list)
42
+ skills: list[Skill] = Field(default_factory=list)
43
+
44
+ # === Agent 间协作 ===
45
+ handoffs: list[Any] = Field(default_factory=list)
46
+
47
+ # === 输入输出 ===
48
+ output_type: Optional[type] = None
49
+
50
+ # === 安全 ===
51
+ input_guardrails: list[Any] = Field(default_factory=list)
52
+ output_guardrails: list[Any] = Field(default_factory=list)
53
+ permission_policy: Optional[Any] = None
54
+
55
+ # === 记忆 ===
56
+ memory: Optional[Any] = None
57
+
58
+ # === 行为 ===
59
+ tool_use_behavior: str = "run_llm_again"
60
+ max_tool_rounds: int = 20
61
+ enable_cache: bool = True # LLM 响应缓存(默认开启,绑定 Agent 实例生命周期)
62
+ cache_ttl: int = 300 # 缓存有效期(秒)
63
+ memory_async_write: bool = True # 记忆写入是否异步(True=不阻塞, False=等写完再返回)
64
+
65
+ # === 精细回调 ===
66
+ before_model_callback: Optional[Callable] = None
67
+ after_model_callback: Optional[Callable] = None
68
+ before_tool_callback: Optional[Callable] = None
69
+ after_tool_callback: Optional[Callable] = None
70
+ before_handoff_callback: Optional[Callable] = None
71
+ after_handoff_callback: Optional[Callable] = None
72
+ on_error_callback: Optional[Callable] = None
73
+ fail_fast_on_hook_error: bool = False
74
+
75
+ # ------------------------------------------------------------------
76
+ # 核心方法
77
+ # ------------------------------------------------------------------
78
+
79
+ async def get_instructions(self, ctx: "RunContext") -> str:
80
+ """获取系统提示词(支持动态函数)"""
81
+ if callable(self.instructions):
82
+ result = self.instructions(ctx, self)
83
+ if inspect.isawaitable(result):
84
+ return await result
85
+ return result
86
+ return self.instructions or ""
87
+
88
+ async def get_all_tools(self, ctx: "RunContext") -> list[BaseTool]:
89
+ """汇总所有可用工具"""
90
+ all_tools: list[BaseTool] = []
91
+
92
+ # 1. 处理 tools
93
+ for tool_union in self.tools:
94
+ if isinstance(tool_union, BaseTool):
95
+ all_tools.append(tool_union)
96
+ elif isinstance(tool_union, BaseToolset):
97
+ all_tools.extend(await tool_union.get_tools(ctx))
98
+ elif callable(tool_union):
99
+ all_tools.append(FunctionTool.from_function(tool_union))
100
+
101
+ # 2. 处理 skills → SkillToolset
102
+ if self.skills:
103
+ additional = [t for t in all_tools] # 让 Skill 能看到已注册的工具
104
+ skill_toolset = SkillToolset(skills=self.skills, additional_tools=additional)
105
+ all_tools.extend(await skill_toolset.get_tools(ctx))
106
+
107
+ # 3. 处理 handoffs → transfer_to_xxx 工具
108
+ for target in self.handoffs:
109
+ if isinstance(target, BaseAgent):
110
+ all_tools.append(self._create_handoff_tool(target))
111
+
112
+ return all_tools
113
+
114
+ def as_tool(self, name: str, description: str) -> FunctionTool:
115
+ """把自己变成一个工具,供其他 Agent 调用"""
116
+ agent_ref = self
117
+
118
+ async def _invoke(**kwargs: Any) -> Any:
119
+ input_text = kwargs.get("input", "")
120
+ from ..runner.runner import Runner
121
+ result = await Runner.run(agent_ref, input=str(input_text))
122
+ return result.final_output
123
+
124
+ return FunctionTool(
125
+ name=name,
126
+ description=description,
127
+ handler=_invoke,
128
+ json_schema={
129
+ "type": "object",
130
+ "properties": {"input": {"type": "string", "description": "任务输入"}},
131
+ "required": ["input"],
132
+ },
133
+ )
134
+
135
+ # ------------------------------------------------------------------
136
+ # 核心执行循环
137
+ # ------------------------------------------------------------------
138
+
139
+ async def _run_impl(self, ctx: "RunContext") -> AsyncGenerator[Event, None]:
140
+ round_count = 0
141
+
142
+ # 在执行前加载所有未加载的 Skill
143
+ for skill in self.skills:
144
+ try:
145
+ await skill.on_load(ctx)
146
+ except Exception as e:
147
+ yield Event(
148
+ agent=self.name,
149
+ type="error",
150
+ data={"context": "skill_on_load", "skill": skill.name, "error": str(e)}
151
+ )
152
+
153
+ try:
154
+ while round_count < self.max_tool_rounds:
155
+ round_count += 1
156
+
157
+ # 1. 构建指令
158
+ instructions = await self.get_instructions(ctx)
159
+
160
+ # 注入记忆
161
+ if self.memory:
162
+ try:
163
+ relevant = await self.memory.search(ctx.input, user_id=ctx.user_id, agent_id=self.name, limit=5)
164
+ if relevant:
165
+ mem_text = "\n".join([f"- {m.content}" for m in relevant])
166
+ instructions += f"\n\n## 相关记忆\n{mem_text}"
167
+ except Exception as e:
168
+ logger.warning("检索记忆失败: %s", e)
169
+
170
+ # 注入 Skill 列表
171
+ if self.skills:
172
+ skill_toolset = SkillToolset(skills=self.skills)
173
+ instructions += "\n\n" + skill_toolset.get_system_prompt_injection()
174
+
175
+ # 2. 获取工具
176
+ tools = await self.get_all_tools(ctx)
177
+ tool_defs = [t.to_tool_definition() for t in tools]
178
+
179
+ # 3. 构建消息
180
+ messages = [Message.system(instructions)]
181
+ messages.append(Message.user(ctx.input))
182
+
183
+ # 追加历史消息
184
+ for msg_dict in ctx.get_messages():
185
+ role = MessageRole(msg_dict.get("role", "user"))
186
+ tool_calls_raw = msg_dict.get("tool_calls", [])
187
+ tool_calls_parsed = [
188
+ LLMToolCall(id=tc["id"], name=tc["name"], arguments=tc["arguments"])
189
+ for tc in tool_calls_raw
190
+ ] if tool_calls_raw else []
191
+ messages.append(Message(
192
+ role=role,
193
+ content=msg_dict.get("content"),
194
+ tool_call_id=msg_dict.get("tool_call_id"),
195
+ tool_calls=tool_calls_parsed,
196
+ ))
197
+
198
+ # 4. before_model 回调
199
+ if self.before_model_callback:
200
+ override, duration, err = await self._run_hook(self.before_model_callback, "before_model_callback", ctx, instructions, tools)
201
+ if err:
202
+ yield Event(agent=self.name, type="error", data={"hook": "before_model", "error": str(err), "duration": duration})
203
+ if self.fail_fast_on_hook_error:
204
+ return
205
+ elif override is not None:
206
+ yield Event(agent=self.name, type="model_override", data={"override": override, "duration": duration})
207
+ return
208
+
209
+ # 5. 调用 LLM(支持缓存)
210
+ llm = self._resolve_model()
211
+ cached = False
212
+
213
+ # 检查缓存
214
+ if self.enable_cache:
215
+ cache = self._get_cache()
216
+ cached_response = cache.get(messages, tool_defs if tool_defs else None)
217
+ if cached_response is not None:
218
+ response = cached_response
219
+ cached = True
220
+
221
+ if not cached:
222
+ try:
223
+ response = await llm.generate(messages=messages, tools=tool_defs if tool_defs else None)
224
+ except Exception as e:
225
+ error_msg = str(e) or f"{type(e).__name__}: LLM 调用失败"
226
+ if self.on_error_callback:
227
+ _, duration, err = await self._run_hook(self.on_error_callback, "on_error_callback", ctx, e)
228
+ if err:
229
+ yield Event(agent=self.name, type="error", data={"hook": "on_error", "error": str(err), "duration": duration})
230
+ if self.fail_fast_on_hook_error:
231
+ return
232
+ yield Event(agent=self.name, type="error", data=error_msg)
233
+ return
234
+
235
+ # 写入缓存
236
+ if self.enable_cache:
237
+ cache.put(messages, tool_defs if tool_defs else None, response)
238
+
239
+ # 6. after_model 回调
240
+ if self.after_model_callback:
241
+ hook_res, duration, err = await self._run_hook(self.after_model_callback, "after_model_callback", ctx, response)
242
+ if err:
243
+ yield Event(agent=self.name, type="error", data={"hook": "after_model", "error": str(err), "duration": duration})
244
+ if self.fail_fast_on_hook_error:
245
+ return
246
+ elif hook_res is not None:
247
+ response = hook_res
248
+
249
+ yield Event(agent=self.name, type="llm_response", data=response)
250
+
251
+ # 7. 分析响应
252
+ if response.has_tool_calls:
253
+ # ⭐ 关键:先把 assistant 的 tool_calls 加入历史,
254
+ # 这样下一轮 LLM 能看到完整的调用链(assistant→tool→assistant...)
255
+ ctx.messages.append({
256
+ "role": "assistant",
257
+ "content": response.content,
258
+ "tool_calls": [
259
+ {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
260
+ for tc in response.tool_calls
261
+ ],
262
+ })
263
+
264
+ for tool_call in response.tool_calls:
265
+ tool = self._find_tool(tools, tool_call.name)
266
+ if not tool:
267
+ yield Event(agent=self.name, type="error", data=f"工具 '{tool_call.name}' 未找到")
268
+ continue
269
+
270
+ # 检查是否是 handoff
271
+ if tool_call.name.startswith("transfer_to_"):
272
+ target_agent = tool_call.name.replace("transfer_to_", "")
273
+
274
+ # before_handoff 回调
275
+ if self.before_handoff_callback:
276
+ override, duration, err = await self._run_hook(self.before_handoff_callback, "before_handoff_callback", ctx, target_agent, tool_call)
277
+ if err:
278
+ yield Event(agent=self.name, type="error", data={"hook": "before_handoff", "error": str(err), "duration": duration})
279
+ if self.fail_fast_on_hook_error:
280
+ return
281
+ elif override is not None:
282
+ target_agent = override
283
+
284
+ # 保持 tool-call 消息链完整,避免后续 Agent 看到未闭合的 tool_call
285
+ # 导致模型输出空内容 (content=None)。
286
+ ctx.add_tool_result(tool_call.id, f"Handoff to {target_agent}")
287
+
288
+ # after_handoff 回调
289
+ if self.after_handoff_callback:
290
+ _, duration, err = await self._run_hook(self.after_handoff_callback, "after_handoff_callback", ctx, target_agent)
291
+ if err:
292
+ yield Event(agent=self.name, type="error", data={"hook": "after_handoff", "error": str(err), "duration": duration})
293
+ if self.fail_fast_on_hook_error:
294
+ return
295
+
296
+ yield Event(agent=self.name, type="handoff", data={"target": target_agent})
297
+ return
298
+
299
+ # before_tool 回调
300
+ if self.before_tool_callback:
301
+ override, duration, err = await self._run_hook(self.before_tool_callback, "before_tool_callback", ctx, tool, tool_call)
302
+ if err:
303
+ yield Event(agent=self.name, type="error", data={"hook": "before_tool", "error": str(err), "duration": duration})
304
+ if self.fail_fast_on_hook_error:
305
+ return
306
+ elif override is not None:
307
+ continue
308
+
309
+ # 权限检查
310
+ if self.permission_policy:
311
+ allowed = await self.permission_policy.check(tool.name, tool_call.arguments)
312
+ if not allowed:
313
+ yield Event(agent=self.name, type="permission_denied", data={"tool": tool_call.name})
314
+ ctx.add_tool_result(tool_call.id, "Permission denied")
315
+ continue
316
+
317
+ # 执行工具
318
+ try:
319
+ result = await tool.execute(ctx, tool_call.arguments)
320
+ except HumanInputRequested as e:
321
+ # 触发挂起事件,并记录挂起的工具信息
322
+ ctx.state["__suspended_tool_call_id__"] = tool_call.id
323
+ ctx.state["__suspended_tool_name__"] = tool_call.name
324
+ yield Event(
325
+ agent=self.name,
326
+ type=EventType.SUSPEND_REQUESTED,
327
+ data={"prompt": e.prompt, "tool": tool_call.name, "tool_call_id": tool_call.id, **e.kwargs}
328
+ )
329
+ return
330
+ except Exception as e:
331
+ result = f"工具执行错误: {e}"
332
+
333
+ # after_tool 回调
334
+ if self.after_tool_callback:
335
+ hook_res, duration, err = await self._run_hook(self.after_tool_callback, "after_tool_callback", ctx, tool, result)
336
+ if err:
337
+ yield Event(agent=self.name, type="error", data={"hook": "after_tool", "error": str(err), "duration": duration})
338
+ if self.fail_fast_on_hook_error:
339
+ return
340
+ elif hook_res is not None:
341
+ result = hook_res
342
+
343
+ yield Event(agent=self.name, type="tool_result", data={"tool": tool_call.name, "result": result})
344
+ ctx.add_tool_result(tool_call.id, result)
345
+
346
+ if self.tool_use_behavior == "stop":
347
+ return
348
+ continue # run_llm_again
349
+
350
+ else:
351
+ # 最终输出
352
+ output = response.content
353
+
354
+ # 存储记忆
355
+ if self.memory and output:
356
+ conversation = f"User: {ctx.input}\nAssistant: {output}"
357
+ if self.memory_async_write:
358
+ # fire-and-forget:不阻塞返回,后台异步写入(更快)
359
+ import asyncio
360
+
361
+ async def _save_memory():
362
+ try:
363
+ await self.memory.add(conversation, user_id=ctx.user_id, agent_id=self.name)
364
+ except Exception as e:
365
+ logger.warning("存储记忆失败: %s", e)
366
+
367
+ asyncio.create_task(_save_memory())
368
+ else:
369
+ # 同步等待写入完成(适合需要即时读取记忆的场景)
370
+ try:
371
+ await self.memory.add(conversation, user_id=ctx.user_id, agent_id=self.name)
372
+ except Exception as e:
373
+ logger.warning("存储记忆失败: %s", e)
374
+
375
+ yield Event(agent=self.name, type="final_output", data=output)
376
+ return
377
+
378
+ yield Event(agent=self.name, type="error", data=f"超过最大工具调用轮次 {self.max_tool_rounds}")
379
+ finally:
380
+ for skill in self.skills:
381
+ try:
382
+ import time
383
+ start_time = time.time()
384
+ await skill.on_unload(ctx)
385
+ duration = time.time() - start_time
386
+ logger.info(f"[{self.name}] resource_released: skill={skill.name} duration={duration:.4f}s session={ctx.session_id}")
387
+ except Exception as e:
388
+ logger.error(f"[{self.name}] skill_on_unload error: skill={skill.name} error={e}")
389
+
390
+ # ------------------------------------------------------------------
391
+ # 辅助方法
392
+ # ------------------------------------------------------------------
393
+
394
+ def clear_cache(self) -> None:
395
+ """清空 LLM 响应缓存"""
396
+ if self._cache_instance is not None:
397
+ self._cache_instance.clear()
398
+
399
+ _cache_instance: Any = PrivateAttr(default=None)
400
+
401
+ model_config = ConfigDict(arbitrary_types_allowed=True)
402
+
403
+ def _get_cache(self):
404
+ """获取或创建缓存实例(懒初始化)"""
405
+ if self._cache_instance is None:
406
+ from ..llm.cache import LLMCache
407
+ self._cache_instance = LLMCache(max_size=128, ttl=self.cache_ttl)
408
+ return self._cache_instance
409
+
410
+ def _resolve_model(self) -> BaseLLM:
411
+ if isinstance(self.model, BaseLLM):
412
+ return self.model
413
+ if self.model:
414
+ return LLMRegistry.create(self.model)
415
+ # 向上继承
416
+ ancestor = self.parent_agent
417
+ while ancestor:
418
+ if isinstance(ancestor, Agent) and ancestor.model:
419
+ return ancestor._resolve_model()
420
+ ancestor = ancestor.parent_agent
421
+ return LLMRegistry.create_default()
422
+
423
+ @staticmethod
424
+ def _find_tool(tools: list[BaseTool], name: str) -> BaseTool | None:
425
+ for tool in tools:
426
+ if tool.name == name:
427
+ return tool
428
+ return None
429
+
430
+ @staticmethod
431
+ def _create_handoff_tool(target: BaseAgent) -> FunctionTool:
432
+ async def _handler(**_kwargs: Any) -> str:
433
+ return f"Handoff to {target.name}"
434
+
435
+ return FunctionTool(
436
+ name=f"transfer_to_{target.name}",
437
+ description=f"将对话交给 {target.description or target.name}",
438
+ handler=_handler,
439
+ json_schema={
440
+ "type": "object",
441
+ "properties": {"reason": {"type": "string", "description": "转交原因"}},
442
+ },
443
+ )
@@ -25,10 +25,29 @@ class BaseAgent(BaseModel):
25
25
  sub_agents: list["BaseAgent"] = Field(default_factory=list)
26
26
  before_agent_callback: Optional[Callable] = None
27
27
  after_agent_callback: Optional[Callable] = None
28
+ fail_fast_on_hook_error: bool = False
28
29
 
29
30
  model_config = ConfigDict(arbitrary_types_allowed=True)
30
31
 
31
- def model_post_init(self, __context: Any) -> None:
32
+ async def _run_hook(self, hook: Optional[Callable], _hook_name: str, *args, **kwargs) -> tuple[Any, float, Optional[Exception]]:
33
+ """执行回调钩子并返回 (结果, 耗时(秒), 异常)"""
34
+ if not hook:
35
+ return None, 0.0, None
36
+
37
+ import time
38
+ import inspect
39
+ start_time = time.time()
40
+ try:
41
+ result = hook(*args, **kwargs)
42
+ if inspect.isawaitable(result):
43
+ result = await result
44
+ duration = time.time() - start_time
45
+ return result, duration, None
46
+ except Exception as e:
47
+ duration = time.time() - start_time
48
+ return None, duration, e
49
+
50
+ def model_post_init(self, _context: Any) -> None:
32
51
  for sub in self.sub_agents:
33
52
  if sub.parent_agent is not None:
34
53
  raise ValueError(f"Agent '{sub.name}' 已有父 Agent")
@@ -38,9 +57,13 @@ class BaseAgent(BaseModel):
38
57
  """运行入口 — 子类不可覆盖"""
39
58
  # 1. before callback
40
59
  if self.before_agent_callback:
41
- result = await self.before_agent_callback(ctx)
42
- if result is not None:
43
- yield Event(agent=self.name, type="callback", data=result)
60
+ result, duration, err = await self._run_hook(self.before_agent_callback, "before_agent_callback", ctx)
61
+ if err:
62
+ yield Event(agent=self.name, type="error", data={"hook": "before_agent", "error": str(err), "duration": duration})
63
+ if self.fail_fast_on_hook_error:
64
+ return
65
+ elif result is not None:
66
+ yield Event(agent=self.name, type="callback", data={"result": result, "duration": duration})
44
67
  return
45
68
 
46
69
  # 2. 核心逻辑(子类实现)
@@ -49,9 +72,13 @@ class BaseAgent(BaseModel):
49
72
 
50
73
  # 3. after callback
51
74
  if self.after_agent_callback:
52
- result = await self.after_agent_callback(ctx)
53
- if result is not None:
54
- yield Event(agent=self.name, type="callback", data=result)
75
+ result, duration, err = await self._run_hook(self.after_agent_callback, "after_agent_callback", ctx)
76
+ if err:
77
+ yield Event(agent=self.name, type="error", data={"hook": "after_agent", "error": str(err), "duration": duration})
78
+ if self.fail_fast_on_hook_error:
79
+ return
80
+ elif result is not None:
81
+ yield Event(agent=self.name, type="callback", data={"result": result, "duration": duration})
55
82
 
56
83
  @abstractmethod
57
84
  async def _run_impl(self, ctx: "RunContext") -> AsyncGenerator[Event, None]: