初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,14 @@
|
||||
"""百工工具运行时编排层。
|
||||
|
||||
司命评估 → 直接执行/沙箱执行 → 一炁事件回写 的编排核心。从 ``modules/tool_runtime`` 迁入
|
||||
``application``,因为编排涉及沙箱(integrations)、司命(modules)、事件存储(services)
|
||||
等多层协调,属于跨模块用例编排(见后端分层架构设计 §5.3)。
|
||||
|
||||
工具规格数据结构(``ToolSpec`` / ``ToolResult``)仍在 ``modules/tool_runtime/definition``。
|
||||
"""
|
||||
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.application.tool_runtime.view import RunScopedToolView
|
||||
|
||||
__all__ = ["RunScopedToolView", "ToolRuntime", "builtin_specs"]
|
||||
@@ -0,0 +1,174 @@
|
||||
"""内置工具集合。覆盖 L0–L3 风险等级,供编排闭环、司命分级演示与前端调用。
|
||||
|
||||
文件类工具(read_file / list_dir / write_file)使用沙箱 base 下的持久子目录
|
||||
``workspace``,并通过 ``isolated_workspace=True`` 由 runtime 改写为按角色隔离的
|
||||
``workspace/<character_id>``,使同一角色跨会话/跨 run 文件互通、不同角色互不可见
|
||||
(workdir 相对 base,受沙箱路径逃逸保护)。多用户上线后隔离主体将切换为真实用户标识。
|
||||
|
||||
执行路径:
|
||||
- 沙箱路径(datetime/read_file/list_dir/write_file/shell):``build_request`` 产出 ExecRequest,
|
||||
由 SandboxClient 执行。参数默认用环境变量传参(避免命令注入);write_file 的 content 因可能
|
||||
很大(单个 env 受 MAX_ARG_STRLEN≈128KB 限制)改经 stdin 写入——既无大小上限,又不经 shell 解释。
|
||||
shell 保留直接拼接——本就是任意命令,且经沙箱隔离。
|
||||
- 直接执行路径(fetch_url):``executor`` 直接发起 HTTP 调用(见 ``web_tools.py``),
|
||||
不走沙箱;http_client 与密钥由 runtime 注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.tool_runtime.web_tools import _fetch_url_executor
|
||||
from app.core.config import settings
|
||||
from app.integrations.sandbox.models import ExecRequest
|
||||
from app.modules.safety.governor import PermissionPolicy
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolSpec
|
||||
|
||||
# 沙箱 base 下的持久工作子目录(文件类工具共享,由 runtime 按角色隔离为 workspace/<character_id>)
|
||||
_WORKSPACE = "workspace"
|
||||
|
||||
|
||||
def _need_str(input: ToolInput, key: str) -> str:
|
||||
value = input.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError(f"工具需要非空字符串 '{key}' 输入")
|
||||
return value
|
||||
|
||||
|
||||
def _datetime_request(_input: ToolInput) -> ExecRequest:
|
||||
return ExecRequest(argv=["sh", "-c", "date '+%Y-%m-%d %H:%M:%S %Z'"])
|
||||
|
||||
|
||||
def _as_positive_int(value: object, *, default: int) -> int:
|
||||
"""把 offset/limit 容错解析为 >=1 的整数;非数字或缺省返回 default。"""
|
||||
try:
|
||||
n = int(value) # str/int/float 均可;bool(True)=1 由 >=1 兜底
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return n if n >= 1 else default
|
||||
|
||||
|
||||
def _read_file_request(input: ToolInput) -> ExecRequest:
|
||||
path = _need_str(input, "path")
|
||||
# 行分页(offset 起始行 1-based / limit 行数):大文件可分段读取,避免整体 cat 后被回显
|
||||
# 截断、模型拿不到完整内容而反复试错。offset/limit 经 int() 规整为纯数字后拼入 sed 地址
|
||||
# (数字无注入风险),行范围用单引号包裹(末行 $ 字面、不经 shell 展开),PATH_ARG 仍走 env。
|
||||
# 缺省 offset/limit 时读全部(行为不变)。
|
||||
# 使用绝对路径(/bin/sed, /bin/cat):避免 env_clear 后 PATH 失效。
|
||||
start = _as_positive_int(input.get("offset"), default=1)
|
||||
row_limit = _as_positive_int(input.get("limit"), default=0)
|
||||
if start > 1 or row_limit > 0:
|
||||
range_expr = f"{start},{start + row_limit - 1}" if row_limit > 0 else f"{start},$"
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", f"sed -n '{range_expr}p' \"$PATH_ARG\""],
|
||||
env={"PATH_ARG": path},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'cat -- "$PATH_ARG"'],
|
||||
env={"PATH_ARG": path},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _list_dir_request(input: ToolInput) -> ExecRequest:
|
||||
path = input.get("path", ".")
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'ls -la -- "$PATH_ARG"'],
|
||||
env={"PATH_ARG": str(path)},
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _write_file_request(input: ToolInput) -> ExecRequest:
|
||||
path = _need_str(input, "path")
|
||||
content = input.get("content", "")
|
||||
# content 经 stdin 写入(cat > file),不经环境变量:单个 env 受 MAX_ARG_STRLEN≈128KB
|
||||
# 限制,大文档经 env 会让 execve 返回 E2BIG、沙箱 ok=False,模型反复重试直到截断;
|
||||
# stdin 既无大小上限、又完全不经过 shell 解释(比 env/argv 更安全,内容里的 `$`、
|
||||
# 反引号、`%` 等都原样落盘)。PATH_ARG 仍用 env(短、需拼进重定向目标)。
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", 'cat > "$PATH_ARG"'],
|
||||
env={"PATH_ARG": path},
|
||||
stdin=str(content),
|
||||
workdir=_WORKSPACE,
|
||||
)
|
||||
|
||||
|
||||
def _shell_request(input: ToolInput) -> ExecRequest:
|
||||
command = _need_str(input, "command")
|
||||
raw_timeout = input.get("timeout_ms")
|
||||
timeout_ms = (
|
||||
int(raw_timeout)
|
||||
if isinstance(raw_timeout, int | float)
|
||||
else settings.sandbox_default_timeout_ms
|
||||
)
|
||||
stdin = input.get("stdin")
|
||||
return ExecRequest(
|
||||
argv=["sh", "-c", command],
|
||||
stdin=stdin if isinstance(stdin, str) else None,
|
||||
timeout_ms=timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
DATETIME = ToolSpec(
|
||||
name="datetime",
|
||||
description="获取当前时间(只读,L0)。",
|
||||
risk_level="L0",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_datetime_request,
|
||||
side_effect=False,
|
||||
)
|
||||
READ_FILE = ToolSpec(
|
||||
name="read_file",
|
||||
description="读取 workspace 内文件(只读,L1)。",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_read_file_request,
|
||||
side_effect=False,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
LIST_DIR = ToolSpec(
|
||||
name="list_dir",
|
||||
description="列出 workspace 内目录(只读,L1)。",
|
||||
risk_level="L1",
|
||||
permission_policy=PermissionPolicy.allow,
|
||||
build_request=_list_dir_request,
|
||||
side_effect=False,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
WRITE_FILE = ToolSpec(
|
||||
name="write_file",
|
||||
description="写入 workspace 内文件(有副作用,L2 需确认)。",
|
||||
risk_level="L2",
|
||||
permission_policy=PermissionPolicy.ask,
|
||||
build_request=_write_file_request,
|
||||
side_effect=True,
|
||||
isolated_workspace=True,
|
||||
)
|
||||
SHELL = ToolSpec(
|
||||
name="shell",
|
||||
description="在沙箱中执行任意 shell 命令(高风险,L3 需确认)。",
|
||||
risk_level="L3",
|
||||
permission_policy=PermissionPolicy.always_ask,
|
||||
build_request=_shell_request,
|
||||
side_effect=True,
|
||||
# 与文件类工具统一默认工作目录(workspace/<角色>):否则 shell 跑在沙箱根目录,cat/ls
|
||||
# 看不到 read_file/write_file 产生的文件,模型会反复试不同路径直到循环截断。shell 本是
|
||||
# 任意命令,cd 仍可改目录(沙箱路径逃逸保护另兜底),这里只统一默认起点。
|
||||
isolated_workspace=True,
|
||||
)
|
||||
FETCH_URL = ToolSpec(
|
||||
name="fetch_url",
|
||||
description="抓取指定 URL 的网页正文(转 markdown)。",
|
||||
risk_level="L2",
|
||||
permission_policy=PermissionPolicy.ask,
|
||||
side_effect=True,
|
||||
audit_required=True,
|
||||
executor=_fetch_url_executor,
|
||||
)
|
||||
|
||||
|
||||
def builtin_specs() -> dict[str, ToolSpec]:
|
||||
"""返回内置工具规格字典。"""
|
||||
return {
|
||||
spec.name: spec for spec in [DATETIME, READ_FILE, LIST_DIR, WRITE_FILE, SHELL, FETCH_URL]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""MCP 工具转 ToolSpec(executor 绑定 http_client 调 MCP server tools/call)。
|
||||
|
||||
MCP 工具命名 ``mcp_{server_name}_{tool_name}``(下划线分隔,冒号不含 OpenAI 命名规范),``risk_level`` 缺省 L2(外部工具),
|
||||
``permission_policy`` 由 risk 经 ``default_policy`` 推导。executor 闭包捕获
|
||||
http_client + server endpoint + auth_token。
|
||||
|
||||
协议:标准 MCP Streamable HTTP(JSON-RPC 2.0 over POST /message)
|
||||
"""
|
||||
|
||||
from app.integrations.mcp.client import call_tool
|
||||
from app.modules.safety.governor import default_policy
|
||||
from app.modules.tool_runtime.definition import ToolSpec
|
||||
|
||||
|
||||
def make_mcp_tool_specs(
|
||||
server,
|
||||
tools: list[dict],
|
||||
http_client,
|
||||
meta: dict | None = None,
|
||||
transport=None,
|
||||
) -> list[ToolSpec]:
|
||||
"""把 MCP server 工具清单(list[dict])转为 ToolSpec 列表。
|
||||
|
||||
Args:
|
||||
meta: 可选业务上下文
|
||||
transport: 可选传输层实例(stdio 用,传给 executor 替代 HTTP call_tool)
|
||||
"""
|
||||
specs: list[ToolSpec] = []
|
||||
for tool in tools:
|
||||
name = tool.get("name")
|
||||
if not name:
|
||||
continue
|
||||
risk_level = tool.get("risk_level", "L2")
|
||||
input_schema = tool.get("inputSchema") or tool.get("input_schema")
|
||||
specs.append(
|
||||
ToolSpec(
|
||||
name=f"mcp_{server.name}_{name}",
|
||||
description=tool.get("description", ""),
|
||||
risk_level=risk_level,
|
||||
permission_policy=default_policy(risk_level),
|
||||
executor=_make_mcp_executor(
|
||||
http_client,
|
||||
server.endpoint_url,
|
||||
name,
|
||||
server.auth_token,
|
||||
meta=meta,
|
||||
transport=transport,
|
||||
),
|
||||
input_schema=input_schema,
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _make_mcp_executor(
|
||||
http_client,
|
||||
endpoint_url,
|
||||
tool_name,
|
||||
auth_token,
|
||||
meta: dict | None = None,
|
||||
transport=None,
|
||||
):
|
||||
async def executor(input, context): # noqa: ARG001
|
||||
call_meta = dict(meta or {})
|
||||
if context:
|
||||
user_role = context.get("user_role") or "free"
|
||||
call_meta.setdefault("user_role", user_role)
|
||||
call_meta.setdefault("caller", "hunjing")
|
||||
if context.get("trace_id"):
|
||||
call_meta["trace_id"] = context["trace_id"]
|
||||
if transport is not None:
|
||||
return await transport.call_tool(tool_name, input)
|
||||
return await call_tool(
|
||||
http_client,
|
||||
endpoint_url,
|
||||
tool_name,
|
||||
input,
|
||||
auth_token,
|
||||
meta=call_meta or None,
|
||||
)
|
||||
|
||||
return executor
|
||||
@@ -0,0 +1,529 @@
|
||||
"""百工 Tool Runtime:司命评估 → 直接执行/沙箱执行 → 一炁事件回写 的编排核心。
|
||||
|
||||
调用链:
|
||||
ToolRuntime.execute / execute_stream
|
||||
→ SafetyGovernor.evaluate(deny→记 tool.failed 并抛 PermissionDenied;
|
||||
ask→抛 RequiresConfirmation 交上层确认后重试)
|
||||
→ 执行路径二选一:
|
||||
- ToolSpec.executor 非 None:构造执行上下文(含 http_client 等)
|
||||
直接调用 executor,跳过沙箱(HTTP 类工具如 fetch_url)。
|
||||
- executor 为 None:ToolSpec.build_request 构造沙箱请求(isolated_workspace 工具按
|
||||
run 隔离 workdir)→ SandboxClient.exec / exec_stream(携带司命授权上下文;
|
||||
事件经 EventStoreSink 落 EventStore)。
|
||||
→ ToolResult / 流式 ToolStreamEvent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.integrations.sandbox.audit_sink import AuditEventSink
|
||||
from app.integrations.sandbox.auth import AuthorizationContext
|
||||
from app.integrations.sandbox.bridge import EventStoreSink
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.events import (
|
||||
CompositeEventSink,
|
||||
EventSink,
|
||||
completed_event,
|
||||
failed_event,
|
||||
started_event,
|
||||
)
|
||||
from app.integrations.sandbox.exceptions import SandboxError
|
||||
from app.integrations.sandbox.models import (
|
||||
DirEntry,
|
||||
ExecResponse,
|
||||
FileReadRequest,
|
||||
FileReadResponse,
|
||||
)
|
||||
from app.modules.runtime.events import Event, EventStore
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.modules.safety.governor import SafetyDecision, SafetyGovernor
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolStreamEvent:
|
||||
"""工具流式事件:stdout/stderr 增量(base64 str,透传给前端解码)或 done(结束汇总 dict)。"""
|
||||
|
||||
kind: str # "stdout" | "stderr" | "done"
|
||||
payload: str | dict[str, Any]
|
||||
|
||||
|
||||
class ToolRuntime:
|
||||
"""百工工具运行时。"""
|
||||
|
||||
_ISOLATE_SAFE = re.compile(r"[^A-Za-z0-9_.-]")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox: SandboxClient,
|
||||
governor: SafetyGovernor,
|
||||
event_store: EventStore,
|
||||
specs: dict[str, ToolSpec] | None = None,
|
||||
*,
|
||||
http_client: httpx.AsyncClient | None = None,
|
||||
) -> None:
|
||||
self._sandbox = sandbox
|
||||
self._governor = governor
|
||||
self._store = event_store
|
||||
self._http_client = http_client
|
||||
# 不在启动期快照 builtin_specs()——那样删除/修改内置工具后需重启后端才生效。
|
||||
# builtin 工具每次经 _resolve_specs() 实时取(builtin_specs 是纯函数,返回新 dict)。
|
||||
# 调用方显式传入的 specs 视为"覆盖层",与 register 的工具一起叠加在 builtin 之上。
|
||||
self._custom_specs: dict[str, ToolSpec] = dict(specs) if specs else {}
|
||||
self._registered_specs: dict[str, ToolSpec] = {}
|
||||
|
||||
@property
|
||||
def http_client(self) -> httpx.AsyncClient | None:
|
||||
"""共享 HTTP 客户端(供 MCP executor 等复用,生命周期同 runtime)。"""
|
||||
return self._http_client
|
||||
|
||||
def _resolve_specs(self) -> dict[str, ToolSpec]:
|
||||
"""合并 builtin(实时)+ custom(调用方传入)+ registered(动态注册)。
|
||||
|
||||
后者覆盖前者同名,保证覆盖语义;builtin 每次现取,builtin_specs() 的任何变更
|
||||
(如新增/删除/修改内置工具)立即对当前 ToolRuntime 实例生效,无需重启。
|
||||
"""
|
||||
from app.application.tool_runtime.builtins import builtin_specs
|
||||
|
||||
merged: dict[str, ToolSpec] = {}
|
||||
merged.update(builtin_specs())
|
||||
merged.update(self._custom_specs)
|
||||
merged.update(self._registered_specs)
|
||||
return merged
|
||||
|
||||
def register(self, spec: ToolSpec) -> None:
|
||||
"""注册或覆盖工具规格(叠加在 builtin 与调用方传入的 specs 之上)。"""
|
||||
self._registered_specs[spec.name] = spec
|
||||
|
||||
def unregister(self, name: str) -> None:
|
||||
"""移除动态注册的工具(builtin / custom 不受影响)。"""
|
||||
self._registered_specs.pop(name, None)
|
||||
|
||||
async def reload_governor(self) -> None:
|
||||
"""从 tool_policies 表重新加载司命策略(写操作后调用,使变更即时生效)。
|
||||
|
||||
读取所有 enabled 策略构造 SafetyGovernor 覆盖当前实例。governor 的 policy
|
||||
字典作为覆盖层(见 SafetyGovernor.evaluate):命中的工具用表里的 risk_level /
|
||||
permission_policy,未命中的用 ToolSpec 自带 risk_level 推导默认策略。
|
||||
"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import governance as governance_repo
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
policies = await governance_repo.list_tool_policies(session)
|
||||
data = {
|
||||
"tools": {
|
||||
p.name: {
|
||||
"risk_level": p.risk_level,
|
||||
"permission_policy": p.permission_policy,
|
||||
}
|
||||
for p in policies
|
||||
if p.enabled
|
||||
}
|
||||
}
|
||||
self._governor = SafetyGovernor.from_policy_dict(data)
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
"""返回当前可用工具规格(用于 API 暴露工具元数据)。
|
||||
|
||||
builtin 实时合并:删除/修改 builtin_specs() 中的工具定义后无需重启即可生效。
|
||||
"""
|
||||
return list(self._resolve_specs().values())
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult:
|
||||
cancellation = (context or {}).get("cancellation")
|
||||
if cancellation is not None:
|
||||
cancellation.raise_if_cancelled()
|
||||
spec = self._resolve_specs().get(tool)
|
||||
if spec is None:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", f"未知工具: {tool}"
|
||||
)
|
||||
raise PermissionDenied(f"未知工具: {tool}")
|
||||
|
||||
decision = self._governor.evaluate(
|
||||
tool=tool, risk_level=spec.risk_level, confirmed=confirmed
|
||||
)
|
||||
if decision.decision is SafetyDecision.deny:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", decision.reason
|
||||
)
|
||||
raise PermissionDenied(decision.reason)
|
||||
if decision.decision is SafetyDecision.ask:
|
||||
# 不执行;上层确认后以 confirmed=True 重试。携带 risk_level 供前端展示。
|
||||
raise RequiresConfirmation(decision.reason, risk_level=decision.risk_level)
|
||||
|
||||
# ---- 直接执行路径(executor 工具,如 fetch_url)----
|
||||
if spec.executor is not None:
|
||||
exec_context = self._build_exec_context(context, trace_id=trace_id)
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=spec.name,
|
||||
argv=[],
|
||||
risk_level=spec.risk_level,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await spec.executor(input, exec_context)
|
||||
except Exception as exc: # noqa: BLE001 - executor 任意错误统一兜底
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=spec.name,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
error_code="executor_failed",
|
||||
error_message=str(exc),
|
||||
)
|
||||
)
|
||||
raise SandboxError(f"工具 {spec.name} 直接执行失败: {exc}") from exc
|
||||
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=spec.name,
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
exit_code=result.exit_code if result.exit_code is not None else 0,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
stdout_len=len(result.stdout),
|
||||
stderr_len=len(result.stderr),
|
||||
)
|
||||
)
|
||||
# 补齐 trace_id(executor 可能未填)
|
||||
if not result.trace_id:
|
||||
result = ToolResult(
|
||||
ok=result.ok,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
trace_id=trace_id or "",
|
||||
)
|
||||
return result
|
||||
|
||||
# ---- 沙箱执行路径 ----
|
||||
request = spec.build_request(input)
|
||||
if trace_id and not request.trace_id:
|
||||
request.trace_id = trace_id
|
||||
# 文件类工具按角色隔离工作目录(同一角色跨 run 文件互通,不同角色互不可见)
|
||||
if spec.isolated_workspace:
|
||||
request.workdir = self._isolate_workdir(
|
||||
request.workdir, subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=request.trace_id or trace_id or "",
|
||||
risk_level=spec.risk_level,
|
||||
tool=spec.name,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
# 本次调用绑定带 run/character 上下文的 sink,使沙箱事件落入一炁事件存储与审计表
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
|
||||
try:
|
||||
resp: ExecResponse = await self._sandbox.exec(request, auth_ctx, event_sink=sink)
|
||||
except asyncio.CancelledError:
|
||||
# 取消 HTTP await 不代表远端子进程已停止;尽力调用沙箱 cancel,随后保持取消语义。
|
||||
if request.trace_id:
|
||||
with suppress(Exception):
|
||||
await asyncio.shield(self._sandbox.cancel(request.trace_id, auth_ctx))
|
||||
raise
|
||||
except SandboxError:
|
||||
# 沙箱错误事件已由 sink 记录(tool.failed),此处不重复写入
|
||||
raise
|
||||
|
||||
return ToolResult(
|
||||
ok=resp.ok,
|
||||
exit_code=resp.exit_code,
|
||||
stdout=resp.stdout,
|
||||
stderr=resp.stderr,
|
||||
duration_ms=resp.duration_ms,
|
||||
timeout=resp.timeout,
|
||||
truncated=resp.truncated,
|
||||
trace_id=resp.trace_id,
|
||||
)
|
||||
|
||||
async def execute_stream(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
) -> AsyncIterator[ToolStreamEvent]:
|
||||
"""流式执行:司命评估后转发沙箱 stdout/stderr 增量,末尾一个 done。
|
||||
|
||||
started/completed/failed 落一炁事件存储;stdout/stderr 增量只透传不落库。
|
||||
司命 deny 记 tool.failed 并抛 PermissionDenied;ask 抛 RequiresConfirmation
|
||||
(流式端点要求调用方先经非流式 invoke 完成 confirmation,再以 confirmed=True 调用)。
|
||||
"""
|
||||
spec = self._resolve_specs().get(tool)
|
||||
if spec is None:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", f"未知工具: {tool}"
|
||||
)
|
||||
raise PermissionDenied(f"未知工具: {tool}")
|
||||
|
||||
decision = self._governor.evaluate(
|
||||
tool=tool, risk_level=spec.risk_level, confirmed=confirmed
|
||||
)
|
||||
if decision.decision is SafetyDecision.deny:
|
||||
await self._record_failed(
|
||||
tool, run_id, character_id, trace_id, "permission_denied", decision.reason
|
||||
)
|
||||
raise PermissionDenied(decision.reason)
|
||||
if decision.decision is SafetyDecision.ask:
|
||||
raise RequiresConfirmation(decision.reason, risk_level=decision.risk_level)
|
||||
|
||||
request = spec.build_request(input)
|
||||
if trace_id and not request.trace_id:
|
||||
request.trace_id = trace_id
|
||||
if spec.isolated_workspace:
|
||||
request.workdir = self._isolate_workdir(
|
||||
request.workdir, subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
|
||||
effective_trace = request.trace_id or trace_id or ""
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level=spec.risk_level,
|
||||
tool=spec.name,
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
sink = self._make_sink(run_id, character_id)
|
||||
|
||||
await sink.emit(
|
||||
started_event(
|
||||
tool=spec.name,
|
||||
argv=request.argv,
|
||||
risk_level=spec.risk_level,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
final: dict[str, Any] = {}
|
||||
try:
|
||||
async for ev in self._sandbox.exec_stream(request, auth_ctx):
|
||||
if ev.kind in ("stdout", "stderr"):
|
||||
yield ToolStreamEvent(ev.kind, ev.payload)
|
||||
elif ev.kind == "done" and isinstance(ev.payload, dict):
|
||||
final = ev.payload
|
||||
except SandboxError:
|
||||
await sink.emit(
|
||||
failed_event(
|
||||
tool=spec.name,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
error_code="sandbox_error",
|
||||
error_message="沙箱流式执行失败",
|
||||
)
|
||||
)
|
||||
raise
|
||||
|
||||
await sink.emit(
|
||||
completed_event(
|
||||
tool=spec.name,
|
||||
trace_id=effective_trace or None,
|
||||
run_id=run_id,
|
||||
exit_code=final.get("exit_code"),
|
||||
duration_ms=int(final.get("duration_ms", 0)),
|
||||
timeout=bool(final.get("timeout", False)),
|
||||
truncated=bool(final.get("truncated", False)),
|
||||
stdout_len=0, # 流式不收集完整文本
|
||||
stderr_len=0,
|
||||
)
|
||||
)
|
||||
yield ToolStreamEvent("done", final)
|
||||
|
||||
def _make_sink(self, run_id: str | None, character_id: str | None) -> EventSink:
|
||||
"""构造本次执行的事件 sink 组合:一炁流程事件 + 治理审计,互不感知。
|
||||
|
||||
EventStoreSink 落 events 表(流程,run 内 sequence),AuditEventSink 落 audit_events
|
||||
表(治理审计,含 decision / risk_level / argv 脱敏摘要)。两者经 CompositeEventSink 扇出。
|
||||
"""
|
||||
return CompositeEventSink(
|
||||
[
|
||||
EventStoreSink(self._store, run_id=run_id, character_id=character_id),
|
||||
AuditEventSink(AsyncSessionLocal, run_id=run_id, character_id=character_id),
|
||||
]
|
||||
)
|
||||
|
||||
def _isolate_workdir(
|
||||
self,
|
||||
base: str | None,
|
||||
*,
|
||||
subject_id: str | None,
|
||||
run_id: str | None,
|
||||
trace_id: str | None,
|
||||
) -> str:
|
||||
"""把工具 workspace 根目录隔离到「主体」级子目录,防跨主体信息泄漏。
|
||||
|
||||
隔离主体 subject_id:当前为 character_id(一个角色一个持久 workspace,跨会话/
|
||||
跨 run 文件互通,符合"一个角色拥有其全部文件"的语义);多用户上线后应改为真实
|
||||
用户标识(owner_scope / user_id),届时仅需调用方改传参,本函数逻辑不变。
|
||||
|
||||
run_id / trace_id 仅在 subject_id 缺失时回退(如脱离聊天流程的直接调用),
|
||||
保证既有隔离又不至于因缺主体而落到共享根目录。
|
||||
|
||||
subject_id 经 sanitize(仅保留 ``[A-Za-z0-9_.-]``)从源头杜绝路径注入;
|
||||
沙箱端另有 canonicalize + ``starts_with(base)`` 兜底。
|
||||
"""
|
||||
root = base or "workspace"
|
||||
key = subject_id or run_id or trace_id or f"adhoc_{uuid.uuid4().hex[:8]}"
|
||||
safe = self._ISOLATE_SAFE.sub("_", key).strip("._-") or "adhoc"
|
||||
return f"{root}/{safe}"
|
||||
|
||||
def _build_exec_context(
|
||||
self, context: dict[str, Any] | None, *, trace_id: str | None
|
||||
) -> dict[str, Any]:
|
||||
"""构造 executor 执行上下文:合并 runtime 持有的 http_client、settings 中的密钥等。
|
||||
|
||||
调用方传入的 context(per-request 覆盖)优先于 runtime 默认值。
|
||||
"""
|
||||
exec_context: dict[str, Any] = {"trace_id": trace_id}
|
||||
if self._http_client is not None:
|
||||
exec_context["http_client"] = self._http_client
|
||||
if context:
|
||||
exec_context.update(context)
|
||||
return exec_context
|
||||
|
||||
async def read_sandbox_file(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
isolated: bool = True,
|
||||
) -> FileReadResponse:
|
||||
"""读取沙箱内文件(仅供文件预览端点调用)。
|
||||
|
||||
Args:
|
||||
path: 文件路径。
|
||||
isolated=True 时视为工作区相对路径(write_file 传入的 path),
|
||||
自动补全 ``workspace/<character_id>/`` 隔离前缀;
|
||||
isolated=False 时作为沙箱 base 下的完整相对路径。
|
||||
run_id: 关联的 run ID(可选,用于授权上下文)。
|
||||
character_id: 人物 ID(可选)。提供时自动构造隔离工作区路径。
|
||||
trace_id: 追踪 ID(可选,自动生成)。
|
||||
isolated: 是否按 isolated_workspace 规则补全路径前缀。
|
||||
|
||||
Returns:
|
||||
FileReadResponse,含 base64url-no-pad 编码的文件内容。
|
||||
"""
|
||||
effective_path = path
|
||||
if isolated:
|
||||
workdir = self._isolate_workdir(
|
||||
"workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
effective_path = f"{workdir}/{path.lstrip('/')}"
|
||||
|
||||
effective_trace = trace_id or str(uuid.uuid4())
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level="L0",
|
||||
tool="file_read",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return await self._sandbox.file_read(
|
||||
FileReadRequest(path=effective_path),
|
||||
auth_ctx,
|
||||
)
|
||||
|
||||
async def list_sandbox_dir(
|
||||
self,
|
||||
path: str = ".",
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
isolated: bool = True,
|
||||
) -> list[DirEntry]:
|
||||
"""列出沙箱工作区目录(仅供文件浏览端点调用)。
|
||||
|
||||
Args:
|
||||
path: 目录路径,默认工作区根。
|
||||
isolated: True 时自动应用 isolated_workspace 隔离前缀。
|
||||
|
||||
返回排序后的目录条目列表。
|
||||
"""
|
||||
from app.integrations.sandbox.models import FileListRequest
|
||||
|
||||
effective_path = path
|
||||
if isolated:
|
||||
workdir = self._isolate_workdir(
|
||||
"workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id
|
||||
)
|
||||
effective_path = f"{workdir}/{path.lstrip('/')}"
|
||||
|
||||
effective_trace = trace_id or str(uuid.uuid4())
|
||||
auth_ctx = AuthorizationContext(
|
||||
trace_id=effective_trace,
|
||||
risk_level="L0",
|
||||
tool="file_list",
|
||||
character_id=character_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
result = await self._sandbox.file_list(
|
||||
FileListRequest(path=effective_path),
|
||||
auth_ctx,
|
||||
)
|
||||
return result.entries
|
||||
|
||||
async def _record_failed(
|
||||
self,
|
||||
tool: str,
|
||||
run_id: str | None,
|
||||
character_id: str | None,
|
||||
trace_id: str | None,
|
||||
error_code: str,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
await self._store.append(
|
||||
Event(
|
||||
type="tool.failed",
|
||||
source="tool",
|
||||
visibility="public",
|
||||
trace_id=trace_id,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
payload={
|
||||
"tool": tool,
|
||||
"phase": "safety",
|
||||
"error_code": error_code,
|
||||
"error_message": error_message,
|
||||
},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""单个 Agent Run 可见的工具视图。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
|
||||
|
||||
|
||||
class RunScopedToolView:
|
||||
"""保存 Run 级工具别名;实际执行始终委托共享 ToolRuntime。"""
|
||||
|
||||
def __init__(self, runtime: ToolRuntime) -> None:
|
||||
self._runtime = runtime
|
||||
self._aliases: dict[str, tuple[str, ToolSpec]] = {}
|
||||
|
||||
def bind_alias(self, alias: str, target: str, *, description_prefix: str = "") -> bool:
|
||||
"""将已有工具作为 Run 级别名暴露;目标不存在时返回 False。"""
|
||||
spec = next((item for item in self._runtime.list_specs() if item.name == target), None)
|
||||
if spec is None:
|
||||
return False
|
||||
description = f"{description_prefix}{spec.description}" if description_prefix else spec.description
|
||||
self._aliases[alias] = (target, replace(spec, name=alias, description=description))
|
||||
return True
|
||||
|
||||
def list_specs(self) -> list[ToolSpec]:
|
||||
return [*self._runtime.list_specs(), *(spec for _, spec in self._aliases.values())]
|
||||
|
||||
def specs_by_name(self) -> dict[str, ToolSpec]:
|
||||
return {spec.name: spec for spec in self.list_specs()}
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
tool: str,
|
||||
input: ToolInput,
|
||||
*,
|
||||
run_id: str | None = None,
|
||||
character_id: str | None = None,
|
||||
trace_id: str | None = None,
|
||||
confirmed: bool = False,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> ToolResult:
|
||||
target = self._aliases.get(tool, (tool,))[0]
|
||||
return await self._runtime.execute(
|
||||
target,
|
||||
input,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
trace_id=trace_id,
|
||||
confirmed=confirmed,
|
||||
context=context,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["RunScopedToolView"]
|
||||
@@ -0,0 +1,127 @@
|
||||
"""HTTP 类工具的直接执行器(非沙箱路径)。
|
||||
|
||||
``fetch_url``:抓取指定 URL 的 HTML 并用 html2text 转成 markdown。
|
||||
|
||||
executor 上下文由 ``ToolRuntime._build_exec_context`` 注入,至少包含:
|
||||
- ``http_client``:长生命周期的 ``httpx.AsyncClient``(禁止裸 ``async with httpx.AsyncClient()``)。
|
||||
- ``trace_id``:本次执行的 trace_id(可空)。
|
||||
|
||||
设计约束(见 backend/CLAUDE.md):
|
||||
- 禁止在调用处裸 ``async with httpx.AsyncClient()``,必须复用注入的 client。
|
||||
- HTTP 错误统一返回 ``ToolResult(ok=False, stderr=...)``,不抛异常(让模型自决策)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import html2text
|
||||
import httpx
|
||||
|
||||
from app.core.config import settings
|
||||
from app.modules.tool_runtime.definition import ToolInput, ToolResult
|
||||
|
||||
# 默认参数
|
||||
_DEFAULT_MAX_CHARS = 8000
|
||||
# 合理的浏览器 UA,避免被部分站点拒绝
|
||||
_USER_AGENT = "Mozilla/5.0 (compatible; HunjingBot/1.0; +https://github.com/hunjing/hunjing)"
|
||||
|
||||
# 模块级 html2text 配置(避免每次调用都构造配置对象)
|
||||
_H2T = html2text.HTML2Text()
|
||||
_H2T.ignore_links = False
|
||||
_H2T.ignore_images = True
|
||||
_H2T.body_width = 0 # 不折行,交给上层截断
|
||||
|
||||
|
||||
def _get_http_client(context: dict[str, Any] | None) -> httpx.AsyncClient | None:
|
||||
"""从 context 取注入的 httpx.AsyncClient;缺失返回 None。"""
|
||||
if context is None:
|
||||
return None
|
||||
client = context.get("http_client")
|
||||
return client if isinstance(client, httpx.AsyncClient) else None
|
||||
|
||||
|
||||
def _ok_result(stdout: str, trace_id: str = "") -> ToolResult:
|
||||
return ToolResult(
|
||||
ok=True,
|
||||
exit_code=0,
|
||||
stdout=stdout,
|
||||
stderr="",
|
||||
duration_ms=0,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
def _err_result(stderr: str, trace_id: str = "") -> ToolResult:
|
||||
return ToolResult(
|
||||
ok=False,
|
||||
exit_code=1,
|
||||
stdout="",
|
||||
stderr=stderr,
|
||||
duration_ms=0,
|
||||
timeout=False,
|
||||
truncated=False,
|
||||
trace_id=trace_id,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_url_executor(input: ToolInput, context: dict[str, Any] | None) -> ToolResult:
|
||||
"""抓取指定 URL 的网页正文并转 markdown。
|
||||
|
||||
input:
|
||||
url (str, required): 要抓取的 URL。
|
||||
max_chars (int, optional): 正文最大字符数,默认 8000。
|
||||
context:
|
||||
http_client (httpx.AsyncClient): 注入的 HTTP 客户端。
|
||||
trace_id (str): 本次执行 trace_id。
|
||||
"""
|
||||
trace_id = (context or {}).get("trace_id", "") or ""
|
||||
url = input.get("url")
|
||||
if not isinstance(url, str) or not url.strip():
|
||||
return _err_result("fetch_url 需要 'url' 字符串输入。", trace_id)
|
||||
|
||||
raw_max = input.get("max_chars", _DEFAULT_MAX_CHARS)
|
||||
try:
|
||||
max_chars = int(raw_max) if raw_max is not None else _DEFAULT_MAX_CHARS
|
||||
except (TypeError, ValueError):
|
||||
max_chars = _DEFAULT_MAX_CHARS
|
||||
if max_chars < 1:
|
||||
max_chars = _DEFAULT_MAX_CHARS
|
||||
|
||||
client = _get_http_client(context)
|
||||
if client is None:
|
||||
return _err_result("fetch_url 缺少注入的 http_client。", trace_id)
|
||||
|
||||
try:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={"User-Agent": _USER_AGENT},
|
||||
timeout=settings.tool_web_http_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
return _err_result(f"fetch_url 请求失败:{exc}", trace_id)
|
||||
|
||||
if resp.status_code >= 400:
|
||||
return _err_result(f"fetch_url 返回 HTTP {resp.status_code}", trace_id)
|
||||
|
||||
content_type = resp.headers.get("content-type", "").lower()
|
||||
html_text = resp.text
|
||||
# 无论 content-type 如何都尝试转 markdown(部分站点 content-type 不规范)
|
||||
try:
|
||||
markdown = _H2T.handle(html_text)
|
||||
except Exception: # noqa: BLE001 - html2text 解析失败时回退原文
|
||||
markdown = html_text
|
||||
|
||||
# 规范化多余空白并截断
|
||||
cleaned = "\n".join(line.rstrip() for line in markdown.splitlines())
|
||||
cleaned = cleaned.strip()
|
||||
if len(cleaned) > max_chars:
|
||||
cleaned = f"{cleaned[:max_chars]}\n\n[...已截断,共 {len(cleaned)} 字符]"
|
||||
|
||||
if not cleaned:
|
||||
return _err_result(f"fetch_url 未抓取到正文内容(content-type={content_type})。", trace_id)
|
||||
|
||||
return _ok_result(cleaned, trace_id)
|
||||
Reference in new issue
Block a user