初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
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)