mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-19 11:10:10 +08:00
✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 (#2146)
* ✨ feat!(llm): 重构并升级大语言模型服务为全新 AI 智能体框架 - 【重构】将原 services/llm 重构并迁移至全新的 services/ai 架构,提供向下兼容垫片 - 【新增】引入 Agent、Team、Workflow 三大智能体与工作流编排范式 - 【新增】引入基于 RAG 的长期向量记忆与中期槽位记忆系统 - 【新增】引入基于 Docker 的安全代码执行沙箱环境 - 【新增】支持 MCP 协议,允许动态管理和调用 MCP 服务 - 【新增】引入输入输出安全合规护栏与自愈反思机制 - 【优化】重构并优化多厂商 API 适配器 (Gemini, OpenAI, DeepSeek, GLM 等) - 【优化】优化日志脱敏与 Token 预估机制 - 【移除】移除旧版 llm default 和 llm reset-key 命令,新增 llm mcp 管理命令 * 🔧 chore(deps): 更新项目依赖与配置 - 添加 mcp、jieba 和 aiodocker 依赖到配置文件及 requirements.txt - 在 pyright 配置中设置 reportMissingImports 为 none - 调整 .gitignore 中 resources 目录的忽略规则 * ♻️ refactor(tools): 重构工具终止机制并清理知识库日志输出 - 统一使用 `context.state["__end_run__"]` 替代 `EndRunResult` 控制任务结束 - 移除文件系统和向量知识库检索工具中 `ToolResult` 的 `.with_log` 调用 - 调整指令处理器(Directive)的返回值为 `tool_res.output` - 修复部分类型检查警告并优化联合类型判断语法 * ♻️ refactor(tools): 重构工具副作用指令与控制流熔断机制 - 引入 `DirectivePayload` 及 `ToolResult` 的子类以结构化表达工具副作用 - 移除通过 `context.state` 传递魔术变量的隐式控制流设计 - 重构 `DirectiveManager` 处理器接口,直接在处理器中修改 `AgentState` 并构建 `AgentRunResult` - 在 `StandardAgentExecutor` 中统一通过 `directive_manager` 调度工具返回的副作用指令 - 补全 `MessageBuilder` 中部分核心方法的文档注释 * 🐛 fix(sandbox): 修复 Docker 沙箱容器状态检测与会话清理逻辑 -【修复】修正 `is_alive` 中直接读取私有属性的问题,改用 `show()` 返回值 -【修复】解决 `execute_code` 中缓存的执行器与当前会话不一致的问题 -【优化】在清理工作区前增加容器存活检测,避免向已死容器发送请求 -【优化】创建容器时增加运行状态校验,若已停止则自动从缓存中移除并重建 -【优化】优化容器销毁和清理逻辑,静默处理容器不存在 (404) 的异常 * 📝 docs(core): 补充核心模块初始化方法的文档注释 * 🚨 auto fix by pre-commit hooks --------- Co-authored-by: webjoin111 <455457521@qq.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
webjoin111
pre-commit-ci[bot]
parent
bdc1374848
commit
80fc5b86a7
@@ -0,0 +1,15 @@
|
||||
from .blackboard import BlackboardToolkit
|
||||
from .hitl import HITLToolkit
|
||||
from .memory import MemoryManagementToolkit
|
||||
from .rest_api import RestApiToolkit
|
||||
from .sandbox import SandboxToolkit
|
||||
from .slots import MemorySlotToolkit
|
||||
|
||||
__all__ = [
|
||||
"BlackboardToolkit",
|
||||
"HITLToolkit",
|
||||
"MemoryManagementToolkit",
|
||||
"MemorySlotToolkit",
|
||||
"RestApiToolkit",
|
||||
"SandboxToolkit",
|
||||
]
|
||||
@@ -0,0 +1,72 @@
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, create_model
|
||||
|
||||
from zhenxun.services.ai.core.exceptions import ToolRetryError
|
||||
from zhenxun.services.ai.run.blackboard import BlackboardManager
|
||||
from zhenxun.services.ai.tools.core.tool import FunctionTool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
|
||||
from zhenxun.utils.pydantic_compat import model_fields
|
||||
|
||||
|
||||
class BlackboardToolkit(BaseToolkit):
|
||||
"""
|
||||
黑板工具箱,将底层的 BlackboardManager 桥接给大模型,提供动态的读写工具。
|
||||
"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
def __init__(self, manager: BlackboardManager, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
self.manager = manager
|
||||
self._injected_tools.extend(self._create_dynamic_tools())
|
||||
|
||||
def _create_dynamic_tools(self) -> list[FunctionTool]:
|
||||
async def read_blackboard() -> ToolResult:
|
||||
"""读取当前团队的全局共享黑板状态。"""
|
||||
content = await self.manager.read()
|
||||
return ToolResult(output=content)
|
||||
|
||||
read_tool = FunctionTool(
|
||||
func=read_blackboard,
|
||||
name="read_blackboard",
|
||||
description="读取当前团队的全局共享黑板最新状态。当你需要获取最新数据或了解现状时请调用此工具。",
|
||||
)
|
||||
|
||||
optional_fields = {}
|
||||
for field in model_fields(self.manager.schema):
|
||||
desc = (
|
||||
getattr(field.field_info, "description", None)
|
||||
if hasattr(field, "field_info")
|
||||
else None
|
||||
)
|
||||
optional_fields[field.name] = (
|
||||
field.annotation | None,
|
||||
Field(default=None, description=desc),
|
||||
)
|
||||
|
||||
UpdateSchema = create_model(
|
||||
f"Update_{self.manager.schema.__name__}", **optional_fields
|
||||
)
|
||||
|
||||
async def update_blackboard(**kwargs) -> ToolResult:
|
||||
valid_kwargs = {k: v for k, v in kwargs.items() if v is not None}
|
||||
if not valid_kwargs:
|
||||
return ToolResult(
|
||||
output="警告:没有提供任何有效的更新字段,黑板状态未发生改变。"
|
||||
)
|
||||
try:
|
||||
res = await self.manager.update(**valid_kwargs)
|
||||
return ToolResult(output=res)
|
||||
except ValueError as e:
|
||||
raise ToolRetryError(str(e))
|
||||
|
||||
update_tool = FunctionTool(
|
||||
func=update_blackboard,
|
||||
name="update_blackboard",
|
||||
description="局部更新全局黑板状态。请仅提供你需要修改的字段即可,不需要改变的字段请留空。",
|
||||
settings=ToolOptions(args_schema=UpdateSchema),
|
||||
)
|
||||
|
||||
return [read_tool, update_tool]
|
||||
@@ -0,0 +1,56 @@
|
||||
from zhenxun.services.ai.core.exceptions import AbortException, ToolFatalError
|
||||
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
|
||||
from zhenxun.services.ai.run import Inject, RunContext
|
||||
from zhenxun.services.ai.tools.core.decorators import tool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class HITLToolkit(BaseToolkit):
|
||||
"""
|
||||
Human-in-the-Loop (HITL) 工具箱。
|
||||
提供大模型主动挂起执行流并向用户提问求助的能力。
|
||||
"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
default_instructions = (
|
||||
"## 人机协同求助系统\n"
|
||||
"你拥有向用户发起提问的权限。当你遇到困难时(例如缺少关键信息、连续执行某个操作出错、找不到文件等),"
|
||||
"可以使用 `ask_user_for_help` 工具请求用户协助。"
|
||||
"用户回答后,你将收到答案并可以继续任务。"
|
||||
)
|
||||
|
||||
@tool(
|
||||
name="ask_user_for_help",
|
||||
description="向当前对话的用户提出问题以获取信息或指导。当你无法独立完成任务时,请调用此工具。",
|
||||
)
|
||||
async def ask_user_for_help(
|
||||
self,
|
||||
question: str,
|
||||
hitl: Inject.HITL,
|
||||
context: RunContext,
|
||||
) -> ToolResult:
|
||||
try:
|
||||
user_reply = await hitl.ask_text(
|
||||
f"🤖 [AI 提问]\n{question}\n\n(请在 60 秒内回复,或回复'取消')",
|
||||
timeout=60.0,
|
||||
)
|
||||
except ToolFatalError:
|
||||
return ToolResult(
|
||||
output="用户未在规定时间内回复 (超时未响应),"
|
||||
"你必须尝试自行解决或中止任务。",
|
||||
).as_error()
|
||||
except AbortException as e:
|
||||
raise e
|
||||
|
||||
logger.info(f"收到用户求助回复: {user_reply}")
|
||||
if context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name=context.call.tool_name, content="🗣️ 已收到用户的回复"
|
||||
)
|
||||
)
|
||||
|
||||
return ToolResult(output=f"用户提供的回复是: {user_reply}")
|
||||
@@ -0,0 +1,324 @@
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from pydantic import Field, create_model
|
||||
|
||||
from zhenxun.services.ai.context.memory.manager import memory_manager
|
||||
from zhenxun.services.ai.context.memory.models import MemoryConfig
|
||||
from zhenxun.services.ai.context.memory.types import SessionMetadata
|
||||
from zhenxun.services.ai.run.context import RunContext
|
||||
from zhenxun.services.ai.tools.core.decorators import tool
|
||||
from zhenxun.services.ai.tools.core.tool import BaseTool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class MemoryManagementToolkit(BaseToolkit):
|
||||
"""
|
||||
主动记忆管理工具箱 (Agentic Memory Toolkit)。
|
||||
赋予大模型自主存取、修改和删除用户长期设定的能力。
|
||||
"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
class Config:
|
||||
"""声明式配置:该工具箱下的所有工具默认静默"""
|
||||
|
||||
shared_options = ToolOptions(silent=True)
|
||||
|
||||
default_instructions = """\
|
||||
## 🧠 长期记忆管理系统 (Long-Term Memory)
|
||||
该系统是你的「无限档案馆」。⚠️ **注意:系统默认不会主动向你提供所有历史信息,你必须通过主动搜索来回忆。**
|
||||
|
||||
### 📝 何时使用长期记忆?
|
||||
- **记录离散事实与经验**:当需要记录某个独立事件、历史经验、问题解决方案或具体事实时(使用 `save_memory`)。
|
||||
- **寻找历史线索**:当遇到未知情况,或用户提及过去的事情、特定设定时,必须主动检索历史库(使用 `search_memory`)。
|
||||
|
||||
### ⚙️ 操作规范
|
||||
1. **隐式记录**:当接收到值得记忆的重要信息时,请静默记录。除非用户主动提问,否则无需向用户显式汇报"我已记住"。
|
||||
2. **按需更新**:如果发现某项历史记录已过时或状态发生扭转,请先检索出它的 ID,再进行修改(使用 `update_memory`)或废弃(使用 `delete_memory`)。
|
||||
3. **精准提炼**:保存记忆时请提炼核心价值,避免保存无意义的闲聊。\
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory_config: MemoryConfig | None = None,
|
||||
namespace: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
初始化主动记忆管理工具箱。
|
||||
|
||||
参数:
|
||||
memory_config: 记忆系统的全局配置对象,为空则使用全局默认。
|
||||
namespace: 当前隔离环境的命名空间。
|
||||
kwargs: 其他透传给 BaseToolkit 的参数。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.memory_config = memory_config
|
||||
self._namespace = namespace
|
||||
|
||||
def _get_runtime_meta_and_scope(
|
||||
self, context: RunContext, scope_name: str | None = None
|
||||
) -> tuple[Any, SessionMetadata]:
|
||||
"""动态获取当前运行时的数据库实例与会话元信息,实现无状态化"""
|
||||
ns = self._namespace or getattr(context.session, "namespace", "global")
|
||||
scope = memory_manager.get_long_term_memory(self.memory_config, namespace=ns)
|
||||
|
||||
scope_builder = None
|
||||
if (
|
||||
self.memory_config
|
||||
and self.memory_config.long_term
|
||||
and self.memory_config.long_term.scopes
|
||||
):
|
||||
scopes_dict = self.memory_config.long_term.scopes
|
||||
if not scope_name:
|
||||
scope_builder = next(iter(scopes_dict.values()))
|
||||
else:
|
||||
scope_builder = scopes_dict.get(scope_name)
|
||||
|
||||
if not scope_builder:
|
||||
scope_builder = getattr(self.memory_config, "base_isolation", None)
|
||||
if not scope_builder:
|
||||
from zhenxun.services.ai.context.memory.types import Isolation
|
||||
|
||||
scope_builder = Isolation.AGENT_USER()
|
||||
|
||||
selector = scope_builder.resolve(
|
||||
deps=context.deps,
|
||||
prefix="",
|
||||
default_namespace=ns,
|
||||
default_agent=context.run.agent_name,
|
||||
)
|
||||
parts = selector.get_scope_parts()
|
||||
all_scopes = {"/"}
|
||||
current_path = ""
|
||||
for part in parts:
|
||||
current_path += f"/{part}"
|
||||
all_scopes.add(current_path)
|
||||
accessible_scopes = list(all_scopes)
|
||||
accessible_scopes.sort(key=lambda x: len(x.split("/")))
|
||||
|
||||
scope_name_mapping = {}
|
||||
if (
|
||||
self.memory_config
|
||||
and self.memory_config.long_term
|
||||
and self.memory_config.long_term.scopes
|
||||
):
|
||||
for name, builder in self.memory_config.long_term.scopes.items():
|
||||
sel = builder.resolve(
|
||||
deps=context.deps,
|
||||
prefix="",
|
||||
default_namespace=ns,
|
||||
default_agent=context.run.agent_name,
|
||||
)
|
||||
scope_name_mapping[sel.scope_prefix] = name
|
||||
|
||||
session_meta = SessionMetadata(
|
||||
session_id=context.session_id or "default_session",
|
||||
selector=selector,
|
||||
scope_prefix=selector.scope_prefix,
|
||||
accessible_scopes=accessible_scopes,
|
||||
scope_name_mapping=scope_name_mapping,
|
||||
)
|
||||
return scope, session_meta
|
||||
|
||||
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
|
||||
tools = await super().get_tools(context)
|
||||
if not self.memory_config or not self.memory_config.long_term.enable:
|
||||
return tools
|
||||
|
||||
scopes_dict = self.memory_config.long_term.scopes
|
||||
if not scopes_dict:
|
||||
return tools
|
||||
|
||||
scope_keys = tuple(scopes_dict.keys())
|
||||
|
||||
if len(scope_keys) > 1:
|
||||
ScopeType = Literal[scope_keys]
|
||||
|
||||
SaveArgs = create_model(
|
||||
"SaveMemoryArgs",
|
||||
content=(str, Field(..., description="要保存的记忆内容")),
|
||||
importance=(float, Field(default=0.5, description="重要性(0-1)")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
SearchArgs = create_model(
|
||||
"SearchMemoryArgs",
|
||||
query=(str, Field(..., description="搜索关键词或问题")),
|
||||
filters=(
|
||||
str | None,
|
||||
Field(default=None, description="可选的元数据过滤JSON字符串"),
|
||||
),
|
||||
scope=(
|
||||
Optional[ScopeType], # noqa
|
||||
Field(
|
||||
default=None,
|
||||
description="搜索特定的分区。留空则跨所有有权分区混合检索!",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
UpdateArgs = create_model(
|
||||
"UpdateMemoryArgs",
|
||||
record_id=(str, Field(..., description="要更新的记忆ID")),
|
||||
new_content=(str, Field(..., description="新的记忆内容")),
|
||||
importance=(float, Field(default=0.5, description="重要性(0-1)")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="指定该记忆所在的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
DeleteArgs = create_model(
|
||||
"DeleteMemoryArgs",
|
||||
record_id=(str, Field(..., description="要删除的记忆ID")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="指定该记忆所在的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
for t_name, t in tools.items():
|
||||
if t_name.endswith("save_memory"):
|
||||
t.args_schema = SaveArgs
|
||||
elif t_name.endswith("search_memory"):
|
||||
t.args_schema = SearchArgs
|
||||
elif t_name.endswith("update_memory"):
|
||||
t.args_schema = UpdateArgs
|
||||
elif t_name.endswith("delete_memory"):
|
||||
t.args_schema = DeleteArgs
|
||||
|
||||
return tools
|
||||
|
||||
@tool(
|
||||
name="save_memory",
|
||||
description="保存关于用户的重要设定、偏好或事实到长期记忆中。",
|
||||
)
|
||||
async def save_memory(
|
||||
self, content: str, context: RunContext, importance: float = 0.5, **kwargs
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
|
||||
if not scope:
|
||||
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
|
||||
|
||||
await scope.remember(session=meta, content=content, importance=importance)
|
||||
|
||||
logger.debug(
|
||||
f"[Agentic Memory] AI 主动存入记忆: {content} (重要性: {importance})"
|
||||
)
|
||||
return ToolResult(output="记忆已成功排入系统后台合并存入。")
|
||||
|
||||
@tool(
|
||||
name="search_memory",
|
||||
description=(
|
||||
"在长期记忆中主动检索关于用户的设定和事实。"
|
||||
"支持自然语言模糊搜索。"
|
||||
"可选的 filters 参数用于元数据精确匹配。如果不需要过滤,"
|
||||
"请直接省略该参数,不要传入空字典或空字符串。"
|
||||
),
|
||||
)
|
||||
async def search_memory(
|
||||
self,
|
||||
query: str,
|
||||
context: RunContext,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
|
||||
if not scope:
|
||||
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
|
||||
|
||||
filters = kwargs.get("filters")
|
||||
parsed_filters = None
|
||||
if isinstance(filters, dict):
|
||||
parsed_filters = filters
|
||||
elif isinstance(filters, str) and filters.strip() and filters.strip() != "{}":
|
||||
import json
|
||||
|
||||
try:
|
||||
parsed_filters = json.loads(filters)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if scope_name is not None:
|
||||
meta.accessible_scopes = [meta.scope_prefix]
|
||||
|
||||
matches = await scope.recall(
|
||||
session=meta,
|
||||
query=query,
|
||||
limit=5,
|
||||
metadata_filter=parsed_filters,
|
||||
)
|
||||
if not matches:
|
||||
return ToolResult(
|
||||
output="未检索到相关记忆。这可能是你们关于此话题的首次探讨,请直接根据常识回答或向用户确认。"
|
||||
)
|
||||
|
||||
results = [
|
||||
(
|
||||
f"ID: {m.record.id} | 内容: {m.record.content} | "
|
||||
f"重要性: {m.record.metadata.get('importance', 0.5)}"
|
||||
)
|
||||
for m in matches
|
||||
]
|
||||
return ToolResult(output="检索到的记忆如下:\n" + "\n".join(results))
|
||||
|
||||
@tool(
|
||||
name="update_memory",
|
||||
description="更新指定ID的长期记忆内容。当你发现用户的某个旧设定发生改变时,请使用此工具覆盖旧记忆。",
|
||||
)
|
||||
async def update_memory(
|
||||
self,
|
||||
record_id: str,
|
||||
new_content: str,
|
||||
context: RunContext,
|
||||
importance: float = 0.5,
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
|
||||
if not scope:
|
||||
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
|
||||
|
||||
success = await scope.update(
|
||||
session=meta,
|
||||
record_id=record_id,
|
||||
new_content=new_content,
|
||||
importance=importance,
|
||||
)
|
||||
if success:
|
||||
logger.debug(
|
||||
f"[Agentic Memory] AI 主动更新记忆: {record_id} -> {new_content}"
|
||||
)
|
||||
return ToolResult(
|
||||
output=f"记忆 {record_id} 更新成功。新内容:{new_content}"
|
||||
)
|
||||
return ToolResult(
|
||||
output=f"更新失败:未找到ID为 {record_id} 的记忆。"
|
||||
).as_error()
|
||||
|
||||
@tool(
|
||||
name="delete_memory",
|
||||
description="根据记忆的唯一ID删除已经作废或过期的用户记忆。",
|
||||
)
|
||||
async def delete_memory(
|
||||
self, record_id: str, context: RunContext, **kwargs
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
scope, meta = self._get_runtime_meta_and_scope(context, scope_name)
|
||||
if not scope:
|
||||
return ToolResult(output="错误:未启用或未配置长期记忆后端。").as_error()
|
||||
|
||||
deleted_count = await scope.forget(session=meta, record_ids=[record_id])
|
||||
if deleted_count > 0:
|
||||
logger.info(f"[Agentic Memory] AI 主动删除记忆: {record_id}")
|
||||
return ToolResult(output=f"记忆 {record_id} 删除成功。")
|
||||
return ToolResult(
|
||||
output=f"删除失败:未找到ID为 {record_id} 的记忆。"
|
||||
).as_error()
|
||||
@@ -0,0 +1,129 @@
|
||||
from zhenxun.services.ai.tools.core.tool import ServerSideTool
|
||||
|
||||
|
||||
class WebSearchTool(ServerSideTool):
|
||||
"""原生网页搜索工具"""
|
||||
|
||||
type_id = "web_search"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = "google_search",
|
||||
description: str = "使用 Google 搜索查找实时信息。",
|
||||
dynamic_threshold: float | None = None,
|
||||
domain_filters: list[str] | None = None,
|
||||
):
|
||||
super().__init__(name=name, description=description)
|
||||
self.dynamic_threshold = dynamic_threshold
|
||||
self.domain_filters = domain_filters
|
||||
|
||||
|
||||
class CodeExecutionTool(ServerSideTool):
|
||||
"""原生代码沙箱执行工具"""
|
||||
|
||||
type_id = "code_execution"
|
||||
|
||||
def __init__(self, name: str = "code_execution", timeout: int | None = None):
|
||||
super().__init__(
|
||||
name=name, description="执行 Python 代码来解决数学或数据问题。"
|
||||
)
|
||||
self.timeout = timeout
|
||||
|
||||
|
||||
class ComputerUseTool(ServerSideTool):
|
||||
"""原生的桌面环境控制工具"""
|
||||
|
||||
type_id = "computer_use"
|
||||
|
||||
def __init__(self, display_width_px: int = 1024, display_height_px: int = 768):
|
||||
super().__init__(name="computer_use", description="控制电脑用户界面。")
|
||||
self.display_width_px = display_width_px
|
||||
self.display_height_px = display_height_px
|
||||
|
||||
|
||||
class FileSearchTool(ServerSideTool):
|
||||
"""原生文件检索工具"""
|
||||
|
||||
type_id = "file_search"
|
||||
|
||||
def __init__(self, name: str = "file_search"):
|
||||
super().__init__(name=name, description="搜索已索引的文件。")
|
||||
|
||||
|
||||
class GoogleMapsTool(ServerSideTool):
|
||||
"""原生谷歌地图检索工具"""
|
||||
|
||||
type_id = "google_map"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="google_map", description="搜索 Google 地图数据。")
|
||||
|
||||
|
||||
class UrlContextTool(ServerSideTool):
|
||||
"""原生 URL 上下文拉取工具"""
|
||||
|
||||
type_id = "url_context"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(name="url_context", description="从指定 URL 获取上下文信息。")
|
||||
|
||||
|
||||
class Native:
|
||||
"""
|
||||
云端原生工具命名空间工厂 (Namespace Factory)。
|
||||
|
||||
为开发者提供统一的云端内置工具调用入口,享受顶级 IDE 补全体验。
|
||||
此类工具仅会向大模型提供描述,物理执行发生在各大模型厂商的服务端。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def web_search(
|
||||
cls,
|
||||
name: str = "google_search",
|
||||
description: str = "使用 Google 搜索查找实时信息。",
|
||||
dynamic_threshold: float | None = None,
|
||||
domain_filters: list[str] | None = None,
|
||||
) -> WebSearchTool:
|
||||
"""
|
||||
原生网页搜索引擎工具 (如 Google Search, Bing)。
|
||||
"""
|
||||
return WebSearchTool(name, description, dynamic_threshold, domain_filters)
|
||||
|
||||
@classmethod
|
||||
def code_execution(
|
||||
cls, name: str = "code_execution", timeout: int | None = None
|
||||
) -> CodeExecutionTool:
|
||||
"""
|
||||
原生代码沙箱执行工具 (如 Gemini Code Execution, OpenAI Advanced Data Analysis)。
|
||||
"""
|
||||
return CodeExecutionTool(name, timeout)
|
||||
|
||||
@classmethod
|
||||
def computer_use(
|
||||
cls, display_width_px: int = 1024, display_height_px: int = 768
|
||||
) -> ComputerUseTool:
|
||||
"""
|
||||
原生桌面环境控制工具 (如 Claude Computer Use)。
|
||||
"""
|
||||
return ComputerUseTool(display_width_px, display_height_px)
|
||||
|
||||
@classmethod
|
||||
def file_search(cls, name: str = "file_search") -> FileSearchTool:
|
||||
"""
|
||||
原生文件检索工具。
|
||||
"""
|
||||
return FileSearchTool(name)
|
||||
|
||||
@classmethod
|
||||
def google_map(cls) -> GoogleMapsTool:
|
||||
"""
|
||||
原生谷歌地图检索工具 (Gemini 独占)。
|
||||
"""
|
||||
return GoogleMapsTool()
|
||||
|
||||
@classmethod
|
||||
def url_context(cls) -> UrlContextTool:
|
||||
"""
|
||||
原生 URL 上下文拉取工具。
|
||||
"""
|
||||
return UrlContextTool()
|
||||
@@ -0,0 +1,132 @@
|
||||
import json
|
||||
from typing import Any, Literal
|
||||
|
||||
import httpx
|
||||
|
||||
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent
|
||||
from zhenxun.services.ai.run import RunContext
|
||||
from zhenxun.services.ai.tools.core.decorators import tool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
|
||||
|
||||
class RestApiToolkit(BaseToolkit):
|
||||
"""
|
||||
通用 REST API 工具箱。
|
||||
让大模型无需编写任何代码即可调用外部 HTTP 接口获取或提交数据。
|
||||
"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
default_instructions = (
|
||||
"你拥有调用外部 REST API 接口的能力。\n"
|
||||
"1. 使用 `make_request` 工具发起 HTTP 请求。\n"
|
||||
"2. 如果请求失败,请分析返回的错误信息。可能需要调整请求参数 (params) 或请求体 (json_data)。\n" # noqa: E501
|
||||
"3. 务必根据返回的 JSON 数据,提取对用户有用的信息进行回答。"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = "",
|
||||
default_headers: dict[str, str] | None = None,
|
||||
default_params: dict[str, Any] | None = None,
|
||||
timeout: int = 30,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
初始化通用 REST API 工具箱。
|
||||
|
||||
参数:
|
||||
base_url: 接口基础 URL,默认发起请求时将与其拼接。
|
||||
default_headers: 发起请求时携带的默认 Headers 请求头。
|
||||
default_params: 发起请求时携带的默认 URL 查询参数。
|
||||
timeout: 请求超时时间,单位为秒。
|
||||
kwargs: 其他透传给 BaseToolkit 的参数。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.base_url = base_url.rstrip("/") if base_url else ""
|
||||
self.default_headers = default_headers or {}
|
||||
self.default_params = default_params or {}
|
||||
self.timeout = timeout
|
||||
|
||||
@tool(name="make_request")
|
||||
async def make_request(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] = "GET",
|
||||
params: dict[str, Any] | None = None,
|
||||
json_data: dict[str, Any] | None = None,
|
||||
context: RunContext | None = None,
|
||||
) -> ToolResult:
|
||||
"""
|
||||
发起 HTTP 请求获取外部数据。
|
||||
|
||||
Args:
|
||||
endpoint: API的路径(若初始化了base_url,将自动拼接)。如果是一个完整的URL(以http开头),则直接请求该URL。
|
||||
method: HTTP 请求方法。
|
||||
params: URL 查询参数字典。
|
||||
json_data: JSON 请求体字典。
|
||||
""" # noqa: E501
|
||||
if endpoint.startswith("http://") or endpoint.startswith("https://"):
|
||||
url = endpoint
|
||||
else:
|
||||
url = (
|
||||
f"{self.base_url}/{endpoint.lstrip('/')}" if self.base_url else endpoint
|
||||
)
|
||||
|
||||
merged_params = self.default_params.copy()
|
||||
if params:
|
||||
merged_params.update(params)
|
||||
|
||||
logger.info(f"🌐 [RestApiToolkit] 正在请求 {method} {url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
params=merged_params,
|
||||
json=json_data,
|
||||
headers=self.default_headers,
|
||||
)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
except json.JSONDecodeError:
|
||||
response_data = response.text
|
||||
|
||||
result_dict = {"status_code": response.status_code, "data": response_data}
|
||||
|
||||
is_error = response.status_code >= 400
|
||||
result = ToolResult(output=result_dict)
|
||||
|
||||
if context and context.run.event_bus:
|
||||
msg = (
|
||||
f"🌐 已调用 API: {url}"
|
||||
if not is_error
|
||||
else f"❌ API 调用失败 (Status: {response.status_code})"
|
||||
)
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name=context.call.tool_name if context else "make_request",
|
||||
content=msg,
|
||||
)
|
||||
)
|
||||
|
||||
if is_error:
|
||||
result = result.as_error()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RestApiToolkit 请求失败: {e}")
|
||||
if context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name=context.call.tool_name if context else "make_request",
|
||||
content="❌ API 网络请求发生框架级错误",
|
||||
)
|
||||
)
|
||||
return ToolResult(
|
||||
output=f"网络请求失败: {type(e).__name__} - {e}"
|
||||
).as_error()
|
||||
@@ -0,0 +1,425 @@
|
||||
import asyncio
|
||||
from typing import Any, Protocol
|
||||
|
||||
from zhenxun.services.ai.core.stream_events import ToolStreamChunkEvent, UserCustomEvent
|
||||
from zhenxun.services.ai.run import Inject, RunContext
|
||||
from zhenxun.services.ai.sandbox.models import (
|
||||
SandboxBlueprint,
|
||||
SandboxExecutionResult,
|
||||
)
|
||||
from zhenxun.services.ai.tools.core.decorators import silent, tool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolResult
|
||||
from zhenxun.services.log import logger
|
||||
from zhenxun.utils.pydantic_compat import model_copy
|
||||
|
||||
|
||||
class PythonPluginProtocol(Protocol):
|
||||
@property
|
||||
def supports_state(self) -> bool: ...
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
code: str,
|
||||
timeout: int = 30,
|
||||
injected_code: str | None = None,
|
||||
) -> SandboxExecutionResult: ...
|
||||
|
||||
|
||||
class SandboxToolkit(BaseToolkit):
|
||||
default_prefix = ""
|
||||
|
||||
default_instructions = (
|
||||
"## 🖥️ 沙箱工作区交互规范\n"
|
||||
"你拥有物理隔离的沙箱环境。请严格遵循以下调度规则:\n"
|
||||
"1. **短时/非交互任务**:直接使用 `execute_code`(如数据计算、算法运行)。"
|
||||
"⚠️ 该工具**严禁包含 `input()`** 等阻塞式交互。\n"
|
||||
"2. **长驻/交互式任务**:若需运行 Web Server 或含 `input()` 的交互程序,"
|
||||
"**必须**:\n"
|
||||
" - 先用 `write_sandbox_file` 将代码保存至当前工作区。\n"
|
||||
" - 再用 `execute_terminal_command(is_interactive=True)` 启动并挂起进程。\n"
|
||||
" - 通过 `send_sandbox_input` / `read_sandbox_screen` 与屏幕画面交互。\n"
|
||||
"3. **终端互斥锁**:虚拟终端只能单线程运行前台程序。"
|
||||
"若程序报错或死循环卡死,**必须**先调用 `interrupt_sandbox` "
|
||||
"打断它,方可进行后续修改。"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
blueprint: SandboxBlueprint | None = None,
|
||||
sandbox_session_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
初始化沙箱工作区工具箱。
|
||||
|
||||
参数:
|
||||
blueprint: 沙箱蓝图配置对象,包含沙箱运行时的各种参数定义(例如是否需要状态、端口映射等)。
|
||||
sandbox_session_id: 显式指定的沙箱会话 ID。如果不指定,则自动与当前执行上下文绑定。
|
||||
kwargs: 其他透传给 BaseToolkit 的参数。
|
||||
""" # noqa: E501
|
||||
super().__init__(**kwargs)
|
||||
self.blueprint = blueprint or SandboxBlueprint()
|
||||
|
||||
self.sandbox_session_id = sandbox_session_id
|
||||
|
||||
async def _get_session(self, context: RunContext, sandbox: Any) -> Any:
|
||||
"""获取或创建沙箱会话实例"""
|
||||
session_id = (
|
||||
self.sandbox_session_id or context.session_id or "default_sandbox_session"
|
||||
)
|
||||
return await sandbox.get_or_create_session(session_id, self.blueprint)
|
||||
|
||||
def _get_pty(self, context: RunContext) -> Any:
|
||||
"""从上下文获取虚拟终端"""
|
||||
session_id = self.sandbox_session_id or context.session_id or "default"
|
||||
return context.session.shared_state.get(f"pty_{session_id}")
|
||||
|
||||
@tool(
|
||||
name="execute_code",
|
||||
description=(
|
||||
"在沙箱环境中执行代码。\n"
|
||||
"支持多语言执行,请在 language 参数中指定具体的编程语言"
|
||||
"(如 python, bash 等)。\n"
|
||||
"默认超时时间为 45 秒。如果你的代码需要更长的时间或等待用户输入,"
|
||||
"它将被挂在后台运行。\n"
|
||||
"你将收到目前的屏幕输出,并可以决定后续操作。"
|
||||
),
|
||||
)
|
||||
async def execute_code(
|
||||
self,
|
||||
code: str,
|
||||
language: str,
|
||||
context: RunContext,
|
||||
sandbox: Inject.Sandbox,
|
||||
) -> ToolResult:
|
||||
session_id = (
|
||||
self.sandbox_session_id or context.session_id or "default_sandbox_session"
|
||||
)
|
||||
logger.info(
|
||||
f"大模型请求执行 {language} 代码 (Session: {session_id}, "
|
||||
f"长度: {len(code)} 字符)"
|
||||
)
|
||||
|
||||
from zhenxun.services.ai.sandbox.runtimes import CodeExecutorRegistry
|
||||
|
||||
ns = getattr(context.session, "namespace", "global") if context else "global"
|
||||
supported = CodeExecutorRegistry.get_supported_languages(ns)
|
||||
if CodeExecutorRegistry._normalize_lang(language) not in supported:
|
||||
from zhenxun.services.ai.core.exceptions import ToolRetryError
|
||||
|
||||
raise ToolRetryError(
|
||||
f"当前沙箱不支持该语言 '{language}'。支持的语言有: {supported}。"
|
||||
"请换用支持的语言重新编写代码!"
|
||||
)
|
||||
|
||||
bp = model_copy(self.blueprint, deep=True)
|
||||
|
||||
if context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name="Sandbox", content="正在分析代码依赖并分配沙箱环境..."
|
||||
)
|
||||
)
|
||||
executor = await self._get_session(context, sandbox)
|
||||
|
||||
state_key = f"code_exec_{session_id}_{language}"
|
||||
code_executor = context.session.shared_state.get(state_key)
|
||||
if code_executor and getattr(code_executor, "session", None) is not executor:
|
||||
code_executor = None
|
||||
|
||||
if not code_executor:
|
||||
code_executor = CodeExecutorRegistry.create_executor(
|
||||
language, bp.needs_state, executor, namespace=ns
|
||||
)
|
||||
context.session.shared_state[state_key] = code_executor
|
||||
|
||||
if context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name="Sandbox",
|
||||
content=f"沙箱已就绪,正在后台执行 {language} 代码...",
|
||||
)
|
||||
)
|
||||
|
||||
import re
|
||||
|
||||
clean_code = code.strip()
|
||||
match = re.search(
|
||||
r"^```[a-zA-Z0-9_-]*\r?\n(.*?)\r?\n```$",
|
||||
clean_code,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
clean_code = match.group(1)
|
||||
|
||||
line_buffer = ""
|
||||
|
||||
async def _stream_output(stream_type: str, data: bytes):
|
||||
nonlocal line_buffer
|
||||
line_buffer += data.decode("utf-8", errors="replace")
|
||||
|
||||
try:
|
||||
result = await code_executor.execute_code(
|
||||
code=clean_code,
|
||||
timeout=45,
|
||||
on_output=_stream_output,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"沙箱执行框架异常: {e}")
|
||||
from zhenxun.services.ai.core.exceptions import AbortException
|
||||
|
||||
raise AbortException(
|
||||
reason=f"System Error: 容器执行环境不可用,异常信息: {e}",
|
||||
display=f"❌ 沙箱框架异常: {e}",
|
||||
)
|
||||
|
||||
output_parts = []
|
||||
if result.exit_code != 0 or result.stderr:
|
||||
output_parts.append(f"Exit Code: {result.exit_code}")
|
||||
|
||||
if result.stdout:
|
||||
output_parts.append(f"Stdout:\n{result.stdout.strip()[:2000]}")
|
||||
|
||||
if result.stderr:
|
||||
output_parts.append(f"Stderr:\n{result.stderr.strip()[:2000]}")
|
||||
|
||||
if result.error:
|
||||
output_parts.append(f"System Error:\n{result.error}")
|
||||
|
||||
output_text = "\n".join(output_parts)
|
||||
|
||||
system_notice = ""
|
||||
if result.is_timeout:
|
||||
system_notice = (
|
||||
"\n\n⚠️ 警告: 代码执行超时!进程仍在后台挂起,"
|
||||
"若为死循环请立刻使用 `interrupt_sandbox`。"
|
||||
)
|
||||
if result.stderr and "StdinNotImplementedError" in result.stderr:
|
||||
system_notice += (
|
||||
"\n\n🚨 致命错误: 当前环境不支持 input()。"
|
||||
"请将代码写入文件并通过 "
|
||||
"execute_terminal_command(is_interactive=True) 运行!"
|
||||
)
|
||||
|
||||
image_bytes_list = []
|
||||
|
||||
if getattr(result, "images", None):
|
||||
import base64
|
||||
|
||||
for b64_str in result.images:
|
||||
try:
|
||||
image_bytes_list.append(base64.b64decode(b64_str))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if getattr(result, "artifacts", None):
|
||||
for filename, file_bytes in result.artifacts.items():
|
||||
if filename.endswith((".png", ".jpg", ".jpeg")):
|
||||
image_bytes_list.append(file_bytes)
|
||||
|
||||
from zhenxun.services.ai.core.messages import (
|
||||
ImagePart,
|
||||
LLMContentPart,
|
||||
TextPart,
|
||||
)
|
||||
|
||||
final_output: list[LLMContentPart] = [TextPart(text=output_text.strip())]
|
||||
|
||||
for img_bytes in image_bytes_list:
|
||||
final_output.append(ImagePart(raw=img_bytes))
|
||||
|
||||
final_output_text = output_text.strip()
|
||||
if system_notice:
|
||||
final_output_text += f"\n\n{system_notice}"
|
||||
final_output[0] = TextPart(text=final_output_text)
|
||||
|
||||
result = ToolResult(
|
||||
output=final_output if len(final_output) > 1 else final_output_text
|
||||
)
|
||||
if len(image_bytes_list) > 0 and context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(UserCustomEvent(display=final_output))
|
||||
return result
|
||||
|
||||
@tool(
|
||||
name="execute_terminal_command",
|
||||
description=(
|
||||
"在沙箱的终端中执行 Shell 命令"
|
||||
"(例如 `python3 script.py` 或 `npm start`)。\n"
|
||||
"如果只是执行普通的短时非交互脚本,"
|
||||
"保持 is_interactive=False 即可(执行速度极快且稳定)。\n"
|
||||
"如果程序包含 `input()` 或需要长期驻留(如 Server),"
|
||||
"请务必设置 is_interactive=True 开启虚拟屏幕模式!"
|
||||
),
|
||||
)
|
||||
async def execute_terminal_command(
|
||||
self,
|
||||
command: str,
|
||||
context: RunContext,
|
||||
sandbox: Inject.Sandbox,
|
||||
is_interactive: bool = False,
|
||||
) -> ToolResult:
|
||||
session_id = self.sandbox_session_id or context.session_id or "default"
|
||||
|
||||
if context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name="Sandbox", content=f"正在虚拟终端执行命令: {command} ..."
|
||||
)
|
||||
)
|
||||
executor = await self._get_session(context, sandbox)
|
||||
|
||||
if not is_interactive:
|
||||
res = await executor.run_process(command)
|
||||
|
||||
if getattr(res, "is_timeout", False):
|
||||
return ToolResult(
|
||||
output=(
|
||||
"⚠️ 警告: 命令执行超时!若程序需常驻或等待输入,"
|
||||
f"请务必设置 is_interactive=True。\n"
|
||||
f"Stdout: {res.stdout}\nStderr: {res.stderr}"
|
||||
)
|
||||
).as_error()
|
||||
|
||||
return ToolResult(
|
||||
output=(
|
||||
f"Exit Code: {res.exit_code}\n"
|
||||
f"Stdout: {res.stdout}\n"
|
||||
f"Stderr: {res.stderr}"
|
||||
)
|
||||
)
|
||||
|
||||
pty = self._get_pty(context)
|
||||
if pty:
|
||||
await pty.close()
|
||||
|
||||
interactive_session = await executor.create_pty_session()
|
||||
context.session.shared_state[f"pty_{session_id}"] = interactive_session
|
||||
|
||||
try:
|
||||
await interactive_session.start(command)
|
||||
await asyncio.sleep(1.5)
|
||||
screen = await interactive_session.read_output()
|
||||
return ToolResult(
|
||||
output=f"📺 虚拟终端已启动,屏幕快照:\n```text\n{screen}\n```"
|
||||
)
|
||||
except Exception as e:
|
||||
return ToolResult(output=f"虚拟屏幕启动异常: {e}").as_error()
|
||||
|
||||
@tool(
|
||||
name="send_sandbox_input",
|
||||
description=(
|
||||
"向当前沙箱中正在挂起运行的后台进程"
|
||||
"(如等待 input() 的 Python 脚本)发送输入文本。\n"
|
||||
"注意:你需要自己在文本末尾加上换行符 \\n 来模拟回车键。"
|
||||
),
|
||||
)
|
||||
async def send_sandbox_input(self, text: str, context: RunContext) -> ToolResult:
|
||||
interactive_session = self._get_pty(context)
|
||||
if not interactive_session:
|
||||
return ToolResult(
|
||||
output=(
|
||||
"错误:没有运行中的交互式虚拟屏幕。"
|
||||
"请先调用 execute_terminal_command(is_interactive=True)。"
|
||||
)
|
||||
).as_error()
|
||||
|
||||
text = text.replace("\\n", "\n").replace("\\r", "\r").replace("\\t", "\t")
|
||||
|
||||
await interactive_session.send_input(text)
|
||||
|
||||
await asyncio.sleep(1.5)
|
||||
output = await interactive_session.read_output(timeout=5)
|
||||
|
||||
if context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name=context.call.tool_name, content="⌨️ 已向后台进程发送输入"
|
||||
)
|
||||
)
|
||||
return ToolResult(
|
||||
output=f"已发送按键。📺 屏幕刷新后快照如下:\n```text\n{output}\n```"
|
||||
)
|
||||
|
||||
@tool(
|
||||
name="read_sandbox_screen",
|
||||
description=(
|
||||
"主动窥探并读取当前虚拟屏幕的画面。当你觉得后台程序可能已经渲染出新内容时,可以使用此工具。"
|
||||
),
|
||||
)
|
||||
async def read_sandbox_screen(self, context: RunContext) -> ToolResult:
|
||||
interactive_session = self._get_pty(context)
|
||||
if not interactive_session:
|
||||
return ToolResult(output="没有运行中的虚拟屏幕。").as_error()
|
||||
|
||||
output = await interactive_session.read_output()
|
||||
return ToolResult(output=f"📺 当前屏幕快照:\n```text\n{output}\n```")
|
||||
|
||||
@tool(
|
||||
name="interrupt_sandbox",
|
||||
description=(
|
||||
"向当前沙箱发送 Ctrl+C (SIGINT) 信号,强制中断正在死循环或挂起的后台进程。"
|
||||
),
|
||||
)
|
||||
async def interrupt_sandbox(self, context: RunContext) -> ToolResult:
|
||||
interactive_session = self._get_pty(context)
|
||||
if not interactive_session:
|
||||
return ToolResult(output="没有运行中的虚拟屏幕需要中断。").as_error()
|
||||
|
||||
await interactive_session.interrupt()
|
||||
await asyncio.sleep(1)
|
||||
output = await interactive_session.read_output()
|
||||
if context and context.run.event_bus:
|
||||
await context.run.event_bus.emit(
|
||||
ToolStreamChunkEvent(
|
||||
tool_name=context.call.tool_name, content="🛑 已强制中断后台进程"
|
||||
)
|
||||
)
|
||||
return ToolResult(
|
||||
output="✅ 成功发送 Ctrl+C 中断信号。"
|
||||
f"📺 当前屏幕快照:\n```text\n{output}\n```"
|
||||
)
|
||||
|
||||
@tool(
|
||||
name="write_sandbox_file",
|
||||
description="将文本内容写入沙箱文件系统中,支持保存大块数据或配置,避免超过对话上下文。",
|
||||
)
|
||||
@silent()
|
||||
async def write_sandbox_file(
|
||||
self, path: str, content: str, context: RunContext, sandbox: Inject.Sandbox
|
||||
) -> ToolResult:
|
||||
executor = await self._get_session(context, sandbox)
|
||||
|
||||
success = await executor.write(path, content.encode("utf-8"))
|
||||
if success:
|
||||
logger.info(f"📝 已向沙箱写入文件: {path}")
|
||||
return ToolResult(output=f"成功将内容写入文件: {path}")
|
||||
else:
|
||||
from zhenxun.services.ai.core.exceptions import AbortException
|
||||
|
||||
raise AbortException(
|
||||
reason="写入文件失败 (当前沙箱环境失联或不支持持久化IO)",
|
||||
display="❌ 写入文件失败:沙箱失联",
|
||||
)
|
||||
|
||||
@tool(
|
||||
name="read_sandbox_file",
|
||||
description="从沙箱文件系统中读取指定文件的文本内容。",
|
||||
)
|
||||
@silent()
|
||||
async def read_sandbox_file(
|
||||
self, path: str, context: RunContext, sandbox: Inject.Sandbox
|
||||
) -> ToolResult:
|
||||
executor = await self._get_session(context, sandbox)
|
||||
|
||||
try:
|
||||
content_bytes = await executor.read(path)
|
||||
content = content_bytes.decode("utf-8", errors="replace")
|
||||
if content.startswith("Error:") or content.startswith("Failed to"):
|
||||
return ToolResult(output=content).as_error()
|
||||
logger.info(f"已读取沙箱文件 {path} (共 {len(content)} 字符)")
|
||||
return ToolResult(output=content)
|
||||
except Exception as e:
|
||||
from zhenxun.services.ai.core.exceptions import AbortException
|
||||
|
||||
raise AbortException(reason=f"读取文件发生框架级异常: {e}")
|
||||
@@ -0,0 +1,368 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import Field, create_model
|
||||
|
||||
from zhenxun.services.ai.context.memory.manager import memory_manager
|
||||
from zhenxun.services.ai.context.memory.models import MemoryConfig
|
||||
from zhenxun.services.ai.context.memory.types import (
|
||||
MemorySlot,
|
||||
SessionMetadata,
|
||||
)
|
||||
from zhenxun.services.ai.run.context import RunContext
|
||||
from zhenxun.services.ai.tools.core.decorators import tool
|
||||
from zhenxun.services.ai.tools.core.tool import BaseTool
|
||||
from zhenxun.services.ai.tools.core.toolkit import BaseToolkit
|
||||
from zhenxun.services.ai.tools.models import ToolOptions, ToolResult
|
||||
|
||||
_SLOT_LOCKS: dict[str, asyncio.Lock] = {}
|
||||
_GLOBAL_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _slot_lock(session_id: str, label: str):
|
||||
key = f"{session_id}:{label}"
|
||||
async with _GLOBAL_LOCK:
|
||||
if key not in _SLOT_LOCKS:
|
||||
_SLOT_LOCKS[key] = asyncio.Lock()
|
||||
lock = _SLOT_LOCKS[key]
|
||||
async with lock:
|
||||
yield
|
||||
|
||||
|
||||
class MemorySlotToolkit(BaseToolkit):
|
||||
"""
|
||||
中期记忆槽工具箱。
|
||||
向大模型开放直接编辑上下文 XML 节点的能力。
|
||||
"""
|
||||
|
||||
default_prefix = ""
|
||||
|
||||
class Config:
|
||||
"""声明式配置:该工具箱下的所有工具默认静默"""
|
||||
|
||||
shared_options = ToolOptions(silent=True)
|
||||
|
||||
default_instructions = """\
|
||||
## 📋 状态与规则面板 (Memory Slots / 中期记忆)
|
||||
该系统是你的「桌面便利贴」或「共享黑板」,用于保存你当前需要随时查阅的核心状态与全局规范。
|
||||
|
||||
### 💡 核心机制
|
||||
- 被保存在记忆槽中的内容(如果已置顶),会在每次对话时**直接注入到你的上下文提示词中**,你无需任何搜索即可看见。
|
||||
- 槽位容量极其有限,仅用于维持当前最新的运行状态。
|
||||
|
||||
### 📝 何时使用记忆槽?
|
||||
- **维护全局规则**:例如设定"用户整体偏好"、"沟通口吻"、"全局指导原则"等需要时刻遵守的规范(使用 `update_slot`)。
|
||||
- **追踪当前进度**:例如记录"待办事项清单"、"当前任务进度"、"上下文摘要"(使用 `append_slot` 列表或 `update_slot` 覆盖)。
|
||||
|
||||
### ⚙️ 操作规范
|
||||
1. **探索可用面板**:接手新任务时,可使用 `list_slots` 宏观查看当前存在哪些状态面板。
|
||||
2. **保持精简**:槽位有严格的字符数限制。当内容过长时,请主动将其归档到长期记忆后,重新提炼并覆盖槽位,或直接调用 `delete_slot` 删除不再需要的槽位。\
|
||||
""" # noqa: E501
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory_config: MemoryConfig | None = None,
|
||||
namespace: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""
|
||||
初始化中期记忆槽工具箱。
|
||||
|
||||
参数:
|
||||
memory_config: 记忆系统的全局配置对象,为空则使用全局默认。
|
||||
namespace: 当前隔离环境的命名空间。
|
||||
kwargs: 其他透传给 BaseToolkit 的参数。
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.memory_config = memory_config
|
||||
self._namespace = namespace
|
||||
|
||||
def _get_runtime_meta_and_ctx(
|
||||
self, context: RunContext, scope_name: str | None = None
|
||||
) -> tuple[Any, SessionMetadata]:
|
||||
ns = self._namespace or getattr(context.session, "namespace", "global")
|
||||
slot_ctx = memory_manager.get_slot_context(self.memory_config, namespace=ns)
|
||||
|
||||
scope_builder = None
|
||||
if (
|
||||
self.memory_config
|
||||
and self.memory_config.slots
|
||||
and self.memory_config.slots.scopes
|
||||
):
|
||||
scopes_dict = self.memory_config.slots.scopes
|
||||
if not scope_name:
|
||||
scope_builder = next(iter(scopes_dict.values()))
|
||||
else:
|
||||
scope_builder = scopes_dict.get(scope_name)
|
||||
|
||||
if not scope_builder:
|
||||
scope_builder = getattr(self.memory_config, "base_isolation", None)
|
||||
if not scope_builder:
|
||||
from zhenxun.services.ai.context.memory.types import Isolation
|
||||
|
||||
scope_builder = Isolation.AGENT_USER()
|
||||
|
||||
selector = scope_builder.resolve(
|
||||
deps=context.deps,
|
||||
prefix="",
|
||||
default_namespace=ns,
|
||||
default_agent=context.run.agent_name,
|
||||
)
|
||||
parts = selector.get_scope_parts()
|
||||
all_scopes = {"/"}
|
||||
current_path = ""
|
||||
for part in parts:
|
||||
current_path += f"/{part}"
|
||||
all_scopes.add(current_path)
|
||||
accessible_scopes = list(all_scopes)
|
||||
accessible_scopes.sort(key=lambda x: len(x.split("/")))
|
||||
|
||||
scope_name_mapping = {}
|
||||
if (
|
||||
self.memory_config
|
||||
and self.memory_config.slots
|
||||
and self.memory_config.slots.scopes
|
||||
):
|
||||
for name, builder in self.memory_config.slots.scopes.items():
|
||||
sel = builder.resolve(
|
||||
deps=context.deps,
|
||||
prefix="",
|
||||
default_namespace=ns,
|
||||
default_agent=context.run.agent_name,
|
||||
)
|
||||
scope_name_mapping[sel.scope_prefix] = name
|
||||
|
||||
session_meta = SessionMetadata(
|
||||
session_id=context.session_id or "default_session",
|
||||
selector=selector,
|
||||
scope_prefix=selector.scope_prefix,
|
||||
accessible_scopes=accessible_scopes,
|
||||
scope_name_mapping=scope_name_mapping,
|
||||
)
|
||||
return slot_ctx, session_meta
|
||||
|
||||
async def get_tools(self, context: RunContext | None = None) -> dict[str, BaseTool]:
|
||||
tools = await super().get_tools(context)
|
||||
|
||||
if (
|
||||
not self.memory_config
|
||||
or not self.memory_config.slots
|
||||
or not self.memory_config.slots.enable
|
||||
):
|
||||
return tools
|
||||
|
||||
scopes_dict = self.memory_config.slots.scopes
|
||||
if not scopes_dict:
|
||||
return tools
|
||||
scope_keys = tuple(scopes_dict.keys())
|
||||
|
||||
if len(scope_keys) > 1:
|
||||
ScopeType = Literal[scope_keys]
|
||||
|
||||
UpdateArgs = create_model(
|
||||
"UpdateSlotArgs",
|
||||
label=(str, Field(..., description="槽位标签名称")),
|
||||
content=(str, Field(..., description="槽位内容(全量覆写)")),
|
||||
description=(str, Field(default="", description="槽位的简要说明")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
AppendArgs = create_model(
|
||||
"AppendSlotArgs",
|
||||
label=(str, Field(..., description="槽位标签名称")),
|
||||
text=(str, Field(..., description="要追加的文本内容")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
ReadArgs = create_model(
|
||||
"ReadSlotArgs",
|
||||
label=(str, Field(..., description="槽位标签名称")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
DeleteArgs = create_model(
|
||||
"DeleteSlotArgs",
|
||||
label=(str, Field(..., description="槽位标签名称")),
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
ListArgs = create_model(
|
||||
"ListSlotsArgs",
|
||||
scope=(
|
||||
ScopeType,
|
||||
Field(..., description="选择记忆存储的隔离分区"),
|
||||
),
|
||||
)
|
||||
|
||||
for t_name, t in tools.items():
|
||||
if t_name.endswith("update_slot"):
|
||||
t.args_schema = UpdateArgs
|
||||
elif t_name.endswith("append_slot"):
|
||||
t.args_schema = AppendArgs
|
||||
elif t_name.endswith("read_slot"):
|
||||
t.args_schema = ReadArgs
|
||||
elif t_name.endswith("delete_slot"):
|
||||
t.args_schema = DeleteArgs
|
||||
elif t_name.endswith("list_slots"):
|
||||
t.args_schema = ListArgs
|
||||
|
||||
return tools
|
||||
|
||||
@tool(
|
||||
description="列出当前所有可用的记忆槽(包括未置顶显示的槽位),方便你了解有哪些信息可供读取或更新。"
|
||||
)
|
||||
async def list_slots(self, context: RunContext, **kwargs) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
|
||||
if not slot_ctx:
|
||||
return ToolResult(output="错误:未配置记忆槽后端").as_error()
|
||||
slots = await slot_ctx.list_all_slots(meta)
|
||||
if not slots:
|
||||
return ToolResult(output="当前没有任何记忆槽。")
|
||||
res = ["已创建的记忆槽列表:"]
|
||||
|
||||
show_scope = False
|
||||
if (
|
||||
self.memory_config
|
||||
and self.memory_config.slots
|
||||
and self.memory_config.slots.scopes
|
||||
and len(self.memory_config.slots.scopes) > 1
|
||||
):
|
||||
show_scope = True
|
||||
|
||||
for s in slots:
|
||||
pin_str = "置顶" if s.pinned else "隐藏"
|
||||
if show_scope:
|
||||
semantic_name = meta.scope_name_mapping.get(s.scope, "未知")
|
||||
res.append(
|
||||
f"- [{s.label}] (分区: "
|
||||
f"{semantic_name}, {pin_str}) - {s.description}"
|
||||
)
|
||||
else:
|
||||
res.append(f"- [{s.label}] ({pin_str}) - {s.description}")
|
||||
return ToolResult(output="\n".join(res))
|
||||
|
||||
@tool(description="读取某个尚未展示在上下文中的记忆槽完整内容。")
|
||||
async def read_slot(self, label: str, context: RunContext, **kwargs) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
|
||||
if not slot_ctx:
|
||||
return ToolResult(output="错误:未配置记忆槽后端").as_error()
|
||||
slot = await slot_ctx.get_slot(meta, label)
|
||||
if not slot:
|
||||
return ToolResult(output=f"未找到标签为 '{label}' 的槽位。").as_error()
|
||||
return ToolResult(output=f"[{label}] 内容:\n{slot.content}")
|
||||
|
||||
@tool(description=("更新或新建记忆槽的内容(全量覆写)。"))
|
||||
async def update_slot(
|
||||
self,
|
||||
label: str,
|
||||
content: str,
|
||||
context: RunContext,
|
||||
description: str = "",
|
||||
**kwargs,
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
|
||||
if not slot_ctx:
|
||||
return ToolResult(output="错误:未配置记忆槽后端").as_error()
|
||||
|
||||
target_sid = meta.scope_prefix
|
||||
async with _slot_lock(target_sid, label):
|
||||
slot = await slot_ctx.get_slot(meta, label)
|
||||
if not slot:
|
||||
slot = MemorySlot(
|
||||
label=label,
|
||||
content=content,
|
||||
scope=meta.scope_prefix,
|
||||
description=description,
|
||||
)
|
||||
else:
|
||||
slot.content = content
|
||||
slot.scope = meta.scope_prefix
|
||||
if description:
|
||||
slot.description = description
|
||||
slot.updated_at = time.time()
|
||||
|
||||
if len(content) > slot.size_limit:
|
||||
return ToolResult(
|
||||
output=(
|
||||
f"错误:内容长度超过限制 ({len(content)} > {slot.size_limit})。"
|
||||
)
|
||||
).as_error()
|
||||
|
||||
await slot_ctx.set_slot(meta, slot)
|
||||
return ToolResult(output=f"已成功将 '{label}' 更新至记忆槽中。")
|
||||
|
||||
@tool(description="在指定记忆槽的末尾追加文本(例如追加待办事项清单)。")
|
||||
async def append_slot(
|
||||
self, label: str, text: str, context: RunContext, **kwargs
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
|
||||
if not slot_ctx:
|
||||
return ToolResult(output="错误:未配置记忆槽后端").as_error()
|
||||
|
||||
slot = await slot_ctx.get_slot(meta, label)
|
||||
if not slot:
|
||||
return ToolResult(
|
||||
output=(
|
||||
f"错误:标签为 '{label}' 的槽位不存在,请先使用 update_slot 创建。"
|
||||
)
|
||||
).as_error()
|
||||
|
||||
target_sid = meta.scope_prefix
|
||||
|
||||
async with _slot_lock(target_sid, label):
|
||||
slot = await slot_ctx.get_slot(meta, label)
|
||||
if not slot:
|
||||
return ToolResult(
|
||||
output="错误:并发写入异常,槽位已被删除。"
|
||||
).as_error()
|
||||
|
||||
sep = "\n" if slot.content and not slot.content.endswith("\n") else ""
|
||||
new_content = f"{slot.content}{sep}{text}"
|
||||
|
||||
if len(new_content) > slot.size_limit:
|
||||
return ToolResult(
|
||||
output=(
|
||||
"错误:追加后总长度超过限制 "
|
||||
f"({len(new_content)} > {slot.size_limit})。"
|
||||
)
|
||||
).as_error()
|
||||
|
||||
slot.content = new_content
|
||||
slot.updated_at = time.time()
|
||||
await slot_ctx.set_slot(meta, slot)
|
||||
|
||||
return ToolResult(output=f"已成功追加至 '{label}'。")
|
||||
|
||||
@tool(description="删除不再需要的记忆槽(全量删除)。")
|
||||
async def delete_slot(
|
||||
self, label: str, context: RunContext, **kwargs
|
||||
) -> ToolResult:
|
||||
scope_name = kwargs.get("scope")
|
||||
slot_ctx, meta = self._get_runtime_meta_and_ctx(context, scope_name)
|
||||
if not slot_ctx:
|
||||
return ToolResult(output="错误:未配置记忆槽后端").as_error()
|
||||
|
||||
target_sid = meta.scope_prefix
|
||||
async with _slot_lock(target_sid, label):
|
||||
await slot_ctx.delete_slot(meta, label, meta.scope_prefix)
|
||||
|
||||
return ToolResult(output=f"已成功删除槽位 '{label}'。")
|
||||
Reference in New Issue
Block a user