M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证

- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
This commit is contained in:
chenxin committed 2026-08-27 19:17:21 +08:00
1 parent 144415247e
commit 684261f4b4
97 files changed
+3075 -5554

No files matched your search

+26 -241
View File
@@ -1,252 +1,37 @@
"""工具调用 API(api-spec §15)。
"""工具 API(§15 兼容面):清单走百工能力目录,调用走 /capabilities/{id}/invoke。
- GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
- POST /tools:注册自定义工具策略(百工枢器用治理声明,记审计)。
- GET /tools/calls/{tool_call_id}:单次工具调用事件详情。
- PATCH /tools/{tool_name}:编辑工具策略元数据(spec §15: risk_level 等)。
- DELETE /tools/{tool_name}:注销自定义工具(builtin 不可删)。
- POST /tools/{tool_name}/invoke:调用工具;高风险返回 requires_confirmation + confirmation_id,
交由 /safety/confirmations/{id}/approve 消费。
- POST /tools/{tool_name}/invoke/stream:流式调用(SSE),实时推送 tool.stdout/tool.stderr
增量与 tool.completed;司命 deny/ask-未确认/沙箱错误分别以 tool.failed / tool.requires_confirmation 结束流。
策略写操作(POST/PATCH/DELETE)后刷新 ToolRuntime 的司命决策器,使变更即时生效;
tool_runtime 未就绪时仅写库(重启后从 db 加载)。
旧 ToolRuntime/策略治理端点已随 M5 百工重写退役;本路由只保留
`/tools`(能力目录投影)与 `/tools/calls/{id}`(一炁事件详情)。
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
import json
from typing import Any
from fastapi import APIRouter, Depends, Response, status
from pydantic import BaseModel
from sse_starlette.sse import EventSourceResponse
from app.api.v1.deps import get_confirmation_store, get_optional_tool_runtime, get_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.db.session import AsyncSessionLocal
from app.integrations.sandbox.exceptions import SandboxError
from app.models.safety import PendingConfirmationRecord
from app.modules.safety.confirmation import (
ConfirmationStore,
PendingConfirmation,
new_confirmation_id,
)
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
from app.schemas.safety import (
CreateToolPolicyRequest,
ToolCallDetail,
ToolPolicyResponse,
UpdateToolPolicyRequest,
)
from app.schemas.skills import ToolCapability
from app.services import governance as governance_service
from app.services import skills as skills_service
from app.api.v1.deps import get_current_user
from app.domains.baigong import service as baigong
from app.schemas.auth import CurrentUser
from app.schemas.safety import ToolCallDetail
from app.services.governance import get_tool_call
router = APIRouter(prefix="/tools", tags=["tools"])
class InvokeRequest(BaseModel):
input: dict[str, Any]
run_id: str | None = None
character_id: str | None = None
trace_id: str | None = None
confirmed: bool = False
class ToolResultPayload(BaseModel):
ok: bool
exit_code: int | None = None
stdout: str = ""
stderr: str = ""
duration_ms: int = 0
timeout: bool = False
truncated: bool = False
trace_id: str = ""
class CompletedResponse(BaseModel):
status: str = "completed"
result: ToolResultPayload
class RequiresConfirmationResponse(BaseModel):
status: str = "requires_confirmation"
confirmation_id: str
risk_level: str
reason: str
async def _sync_confirmation_record(pending: PendingConfirmation) -> None:
"""把 InMemoryConfirmationStore 的待确认记录同步持久化到 DB。"""
async with AsyncSessionLocal() as session:
record = PendingConfirmationRecord(
id=pending.confirmation_id,
tool=pending.tool,
input_json=json.dumps(pending.input) if pending.input else "{}",
risk_level=pending.risk_level,
reason=pending.reason or "",
run_id=pending.run_id,
character_id=pending.character_id,
trace_id=pending.trace_id,
status="pending",
)
session.add(record)
await session.commit()
# —— §15 工具策略治理 ——
@router.get("", response_model=list[ToolPolicyResponse])
async def list_tools() -> list[ToolPolicyResponse]:
return await governance_service.list_tool_policies()
@router.get("/capabilities", response_model=list[ToolCapability])
async def list_capabilities(
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> list[ToolCapability]:
"""统一列举所有能力(builtin / mcp / skill,含来源标记,skills-design §6)。"""
return await skills_service.list_all_capabilities(runtime)
@router.post("", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
async def register_tool(
payload: CreateToolPolicyRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> ToolPolicyResponse:
response = await governance_service.create_tool_policy(payload)
if runtime is not None:
await runtime.reload_governor()
return response
@router.get("")
async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
"""能力目录投影(新链路以 /capabilities 为准;此处给旧消费方过渡读取)。"""
caps = await baigong.list_capabilities(user.owner_scope)
return [
{
"name": c["name"],
"description": c["description"],
"risk_level": max(c["risk_dims"].values(), default="L1"),
"permission_policy": "ask" if c["consent_mode"] == "ask" else "allow",
"enabled": c["enabled"],
"source": c["source"],
}
for c in caps
]
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
return await governance_service.get_tool_call(tool_call_id)
@router.patch("/{tool_name}", response_model=ToolPolicyResponse)
async def update_tool(
tool_name: str,
payload: UpdateToolPolicyRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> ToolPolicyResponse:
response = await governance_service.update_tool_policy(tool_name, payload)
if runtime is not None:
await runtime.reload_governor()
return response
@router.delete("/{tool_name}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tool(
tool_name: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> Response:
await governance_service.delete_tool_policy(tool_name)
if runtime is not None:
await runtime.reload_governor()
return Response(status_code=status.HTTP_204_NO_CONTENT)
# —— §15 工具调用 ——
@router.post("/{tool_name}/invoke", response_model=None)
async def invoke_tool(
tool_name: str,
body: InvokeRequest,
runtime: ToolRuntime = Depends(get_tool_runtime),
confirmations: ConfirmationStore = Depends(get_confirmation_store),
) -> CompletedResponse | RequiresConfirmationResponse:
try:
result = await runtime.execute(
tool_name,
body.input,
run_id=body.run_id,
character_id=body.character_id,
trace_id=body.trace_id,
confirmed=body.confirmed,
)
except RequiresConfirmation as exc:
pending = await confirmations.create(
PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool=tool_name,
input=body.input,
risk_level=exc.risk_level or "L2",
reason=exc.message,
run_id=body.run_id,
character_id=body.character_id,
trace_id=body.trace_id,
)
)
# 同步持久化 PendingConfirmationRecord(供 safety/confirmations 新端点查找)
await _sync_confirmation_record(pending)
return RequiresConfirmationResponse(
confirmation_id=pending.confirmation_id,
risk_level=pending.risk_level,
reason=pending.reason,
)
return CompletedResponse(
result=ToolResultPayload(
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=result.trace_id,
)
)
@router.post("/{tool_name}/invoke/stream")
async def invoke_tool_stream(
tool_name: str,
body: InvokeRequest,
runtime: ToolRuntime = Depends(get_tool_runtime),
) -> EventSourceResponse:
"""流式调用工具(SSE)。
事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``,前端解码)、
``tool.completed``(data 为结束汇总 JSON)。司命 deny → ``tool.failed``;
ask 且未确认 → ``tool.requires_confirmation``(前端应先走非流式 invoke 完成 confirmation,
再以 confirmed=True 调用本端点);沙箱错误 → ``tool.failed``。
"""
async def event_gen():
try:
async for ev in runtime.execute_stream(
tool_name,
body.input,
run_id=body.run_id,
character_id=body.character_id,
trace_id=body.trace_id,
confirmed=body.confirmed,
):
if ev.kind in ("stdout", "stderr"):
yield {"event": f"tool.{ev.kind}", "data": json.dumps({"data": ev.payload})}
elif ev.kind == "done":
yield {"event": "tool.completed", "data": json.dumps(ev.payload)}
except RequiresConfirmation as exc:
yield {
"event": "tool.requires_confirmation",
"data": json.dumps({"risk_level": exc.risk_level, "reason": exc.message}),
}
except PermissionDenied as exc:
yield {
"event": "tool.failed",
"data": json.dumps({"error_code": "permission_denied", "error_message": str(exc)}),
}
except SandboxError as exc:
yield {
"event": "tool.failed",
"data": json.dumps({"error_code": exc.code, "error_message": str(exc)}),
}
return EventSourceResponse(event_gen())
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
return await get_tool_call(tool_call_id)