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

+7 -20
View File
@@ -1,6 +1,6 @@
"""API 层依赖注入。
从 ``app.state`` 取 lifespan 装配的共享运行时(ToolRuntime / ConfirmationStore / EventStore)。
从 ``app.state`` 取 lifespan 装配的共享运行时(乾坤沙箱客户端 / EventStore)。
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
"""
@@ -11,11 +11,10 @@ import hmac
from fastapi import Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import settings
from app.db.session import get_session
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import EventStore
from app.modules.safety.confirmation import ConfirmationStore
from app.schemas.auth import CurrentUser as CurrentUserSchema
from app.services.auth import decode_token, get_user_by_id, get_user_permissions
@@ -27,23 +26,11 @@ def _unavailable(message: str) -> HTTPException:
)
def get_tool_runtime(request: Request) -> ToolRuntime:
runtime: ToolRuntime | None = getattr(request.app.state, "tool_runtime", None)
if runtime is None:
raise _unavailable("工具运行时未就绪(沙箱未配置或未启动)")
return runtime
def get_optional_tool_runtime(request: Request) -> ToolRuntime | None:
"""宽松版:tool_runtime 未就绪时返回 None(不抛 503),供聚合视图降级呈现。"""
return getattr(request.app.state, "tool_runtime", None)
def get_confirmation_store(request: Request) -> ConfirmationStore:
store = getattr(request.app.state, "confirmation_store", None)
if store is None:
raise _unavailable("确认存储未就绪")
return store
def get_sandbox_client(request: Request) -> SandboxClient:
sandbox: SandboxClient | None = getattr(request.app.state, "baigong_sandbox", None)
if sandbox is None:
raise _unavailable("乾坤沙箱未就绪(SANDBOX 未配置或未启动)")
return sandbox
def get_event_store(request: Request) -> EventStore:
+2 -14
View File
@@ -4,7 +4,7 @@
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。
聚合视图自持查询(百工域常驻),无注入运行时。
"""
import json
@@ -13,14 +13,10 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from app.api.v1.deps import (
get_confirmation_store,
get_current_user,
get_optional_tool_runtime,
require_admin,
)
from app.application.tool_runtime.runtime import ToolRuntime
from app.models.runtime import Event
from app.modules.safety.confirmation import ConfirmationStore
from app.schemas.admin import (
AdminCreateUserRequest,
AdminUpdateUserRequest,
@@ -80,13 +76,9 @@ router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(requir
@router.get("/pivots/health", response_model=PivotHealthReport)
async def pivots_health(
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
current_user: CurrentUser = Depends(get_current_user),
) -> PivotHealthReport:
return await get_pivots_health(
confirmation_store=confirmation_store,
tool_runtime=tool_runtime,
owner_scope=current_user.owner_scope,
)
@@ -96,12 +88,8 @@ async def pivots_health(
@router.get("/safety/pending", response_model=Page[PendingApproval])
async def safety_pending(
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> Page[PendingApproval]:
items = await get_pending_approvals(
confirmation_store=confirmation_store, tool_runtime=tool_runtime
)
items = await get_pending_approvals()
return Page(items=items, page=1, page_size=len(items), total=len(items))
+5
View File
@@ -0,0 +1,5 @@
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
from app.domains.baigong.api import routers
__all__ = ["routers"]
+17 -50
View File
@@ -7,15 +7,12 @@ stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
from fastapi import APIRouter, Depends, Query
from fastapi import status as http_status
from app.api.v1.deps import get_optional_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.integrations.mcp.process_manager import get_process_manager
from app.schemas.mcp import (
CreateMcpServerRequest,
McpServerResponse,
McpServerStatusResponse,
UpdateMcpServerRequest,
)
UpdateMcpServerRequest)
from app.services import mcp as mcp_service
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
@@ -24,12 +21,9 @@ router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
@router.post(
"",
response_model=McpServerResponse,
status_code=http_status.HTTP_201_CREATED,
)
status_code=http_status.HTTP_201_CREATED)
async def register_mcp_server(
payload: CreateMcpServerRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
payload: CreateMcpServerRequest) -> McpServerResponse:
"""注册 MCP server。
streamable-http:同步拉取工具清单并注册到运行时。
@@ -45,28 +39,23 @@ async def register_mcp_server(
env_json=payload.env_json,
cwd=payload.cwd,
status=payload.status,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.get("", response_model=list[McpServerResponse])
async def list_mcp_servers(
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
) -> list[McpServerResponse]:
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled")) -> list[McpServerResponse]:
"""列举 MCP server(含工具清单摘要)。"""
return await mcp_service.list_mcp_servers(status=status)
@router.patch(
"/{server_id}",
response_model=McpServerResponse,
)
response_model=McpServerResponse)
async def update_mcp_server(
server_id: str,
payload: UpdateMcpServerRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
payload: UpdateMcpServerRequest) -> McpServerResponse:
"""更新 MCP server 字段(部分更新)。"""
return await mcp_service.update_mcp_server(
server_id,
@@ -77,79 +66,61 @@ async def update_mcp_server(
args_json=payload.args_json,
env_json=payload.env_json,
cwd=payload.cwd,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
async def delete_mcp_server(
server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> None:
server_id: str) -> None:
"""删除 MCP server(先 stop 子进程再删)。"""
await mcp_service.delete_mcp_server(
server_id,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/start",
response_model=McpServerStatusResponse,
)
response_model=McpServerStatusResponse)
async def start_mcp_server(
server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
server_id: str) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具并注册。"""
return await mcp_service.start_mcp_server(
server_id,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/stop",
response_model=McpServerStatusResponse,
)
response_model=McpServerStatusResponse)
async def stop_mcp_server(
server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
server_id: str) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程,注销工具。"""
return await mcp_service.stop_mcp_server(
server_id,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.post(
"/{server_id}/restart",
response_model=McpServerStatusResponse,
)
response_model=McpServerStatusResponse)
async def restart_mcp_server(
server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerStatusResponse:
server_id: str) -> McpServerStatusResponse:
"""重启 stdio MCP server。"""
return await mcp_service.restart_mcp_server(
server_id,
runtime=runtime,
process_manager=get_process_manager(),
)
@router.get(
"/{server_id}/status",
response_model=McpServerStatusResponse,
)
response_model=McpServerStatusResponse)
async def get_mcp_server_status(
server_id: str,
) -> McpServerStatusResponse:
server_id: str) -> McpServerStatusResponse:
"""查询 MCP server 进程状态。"""
return await mcp_service.get_mcp_server_status(
server_id,
@@ -159,15 +130,11 @@ async def get_mcp_server_status(
@router.post(
"/{server_id}/refresh",
response_model=McpServerResponse,
)
response_model=McpServerResponse)
async def refresh_mcp_server_tools(
server_id: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> McpServerResponse:
server_id: str) -> McpServerResponse:
"""重新拉取 MCP server 工具清单并注册到运行时。"""
return await mcp_service.refresh_mcp_server_tools(
server_id,
runtime=runtime,
process_manager=get_process_manager(),
)
+7 -126
View File
@@ -1,149 +1,30 @@
"""安全 API(api-spec §16)。
"""安全 API(§16):审计时间线查询。
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 落库裁决
高风险工具确认(pending → approved/denied),客户端随后以 confirmed=True 重新调用。
工具策略治理与确认流已由百工域承接(/capabilities /mandates /petitions);
本路由保留 audit-events(行动账时间线)。
"""
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select, update
from app.api.v1.deps import get_optional_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.db.session import AsyncSessionLocal
from app.models.safety import PendingConfirmationRecord
from app.api.v1.deps import get_current_user
from app.schemas.admin import AuditEntry
from app.schemas.auth import CurrentUser
from app.schemas.common import Page
from app.schemas.safety import (
CreateToolPolicyRequest,
SafetyPolicyItem,
ToolPolicyResponse,
UpdateToolPolicyRequest,
)
from app.services import audit as audit_service
from app.services import governance as governance_service
router = APIRouter(prefix="/safety", tags=["safety"])
# —— §16 审计事件查询 ——
@router.get("/audit-events", response_model=Page[AuditEntry])
async def list_audit_events(
character_id: str | None = Query(default=None, description="按人物过滤。"),
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
_user: CurrentUser = Depends(get_current_user),
) -> Page[AuditEntry]:
return await audit_service.query_audit_events(
character_id=character_id, pivot=pivot, page=page, page_size=page_size
)
# —— §16 工具策略治理 ——
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
if runtime is not None:
await runtime.reload_governor()
@router.get("/policies", response_model=list[SafetyPolicyItem])
async def list_safety_policies() -> list[SafetyPolicyItem]:
return await governance_service.list_safety_policies()
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
async def create_safety_policy(
payload: CreateToolPolicyRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> ToolPolicyResponse:
response = await governance_service.create_tool_policy(payload)
await _refresh_governor(runtime)
return response
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
async def update_safety_policy(
tool: str,
payload: UpdateToolPolicyRequest,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> ToolPolicyResponse:
response = await governance_service.update_tool_policy(tool, payload)
await _refresh_governor(runtime)
return response
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_safety_policy(
tool: str,
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
) -> Response:
await governance_service.delete_tool_policy(tool)
await _refresh_governor(runtime)
return Response(status_code=status.HTTP_204_NO_CONTENT)
# —— §16 高风险工具确认 ——
def _not_found(confirmation_id: str) -> HTTPException:
return HTTPException(
status_code=404,
detail={
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
},
)
def _cancelled(confirmation_id: str) -> dict[str, str]:
return {"status": "cancelled", "confirmation_id": confirmation_id}
@router.post("/confirmations/{confirmation_id}/approve")
async def approve(confirmation_id: str) -> dict[str, bool | str]:
"""批准工具执行:pending 记录落库为 approved,客户端以 confirmed=True 重新调用。"""
record = await _resolve_confirmation_record(confirmation_id, approved=True)
return {"status": "resolved", "approved": True, "run_id": record.run_id}
@router.post("/confirmations/{confirmation_id}/deny")
async def deny(confirmation_id: str) -> dict[str, str]:
"""拒绝工具执行:pending 记录落库为 denied。"""
await _resolve_confirmation_record(confirmation_id, approved=False)
return {"status": "cancelled", "confirmation_id": confirmation_id}
async def _resolve_confirmation_record(
confirmation_id: str, *, approved: bool
) -> PendingConfirmationRecord:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
)
record = result.scalar_one_or_none()
if record is None or record.run_id is None:
raise _not_found(confirmation_id)
async with AsyncSessionLocal() as session:
stmt = (
update(PendingConfirmationRecord)
.where(
PendingConfirmationRecord.id == confirmation_id,
PendingConfirmationRecord.status == "pending",
)
.values(
status="approved" if approved else "denied",
resolved_at=datetime.now(UTC),
)
)
updated = await session.execute(stmt)
await session.commit()
if updated.rowcount == 0:
raise _not_found(confirmation_id)
return record
+11 -8
View File
@@ -22,8 +22,9 @@ import mimetypes
from fastapi import APIRouter, Depends, HTTPException, Query
from starlette.responses import Response
from app.api.v1.deps import get_current_user, get_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.api.v1.deps import get_current_user, get_sandbox_client
from app.domains.baigong import files as baigong_files
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
from app.schemas.auth import CurrentUser
@@ -54,7 +55,7 @@ async def read_sandbox_file(
"write_file 写入的文件在 workspace/<character_id>/ 下,"
"传递 write_file 的 path 时保持 True 即可。",
),
runtime: ToolRuntime = Depends(get_tool_runtime),
sandbox: SandboxClient = Depends(get_sandbox_client),
_current_user: CurrentUser = Depends(get_current_user),
) -> Response:
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
@@ -76,8 +77,9 @@ async def read_sandbox_file(
# 2) 从沙箱读取
try:
result = await runtime.read_sandbox_file(
path=path,
result = await baigong_files.read_sandbox_file(
sandbox,
path,
run_id=run_id,
character_id=character_id,
isolated=isolated,
@@ -117,7 +119,7 @@ async def list_sandbox_files(
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
),
isolated: bool = Query(True, description="是否自动补全隔离前缀"),
runtime: ToolRuntime = Depends(get_tool_runtime),
sandbox: SandboxClient = Depends(get_sandbox_client),
_current_user: CurrentUser = Depends(get_current_user),
) -> list[dict[str, object]]:
"""列出沙箱工作区目录内容。
@@ -126,8 +128,9 @@ async def list_sandbox_files(
适用于前端文件浏览器导航。
"""
try:
entries = await runtime.list_sandbox_dir(
path=path,
entries = await baigong_files.list_sandbox_dir(
sandbox,
path,
run_id=run_id,
character_id=character_id,
isolated=isolated,
-54
View File
@@ -1,54 +0,0 @@
"""Skill 能力包 API(/tools/skills,skills-design §6)。"""
from fastapi import APIRouter, Query, status
from app.schemas.skills import (
CreateSkillRequest,
SkillResponse,
UpdateSkillRequest,
)
from app.services import skills as skills_service
router = APIRouter(prefix="/tools/skills", tags=["skills"])
@router.post("", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
async def create_skill(payload: CreateSkillRequest) -> SkillResponse:
"""注册 Skill 能力包(name 唯一)。"""
return await skills_service.create_skill(
name=payload.name,
description=payload.description,
trigger=payload.trigger,
prompt_fragment=payload.prompt_fragment,
tool_bindings=payload.tool_bindings,
risk_level=payload.risk_level,
status=payload.status,
)
@router.get("", response_model=list[SkillResponse])
async def list_skills(
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
) -> list[SkillResponse]:
"""列举 Skill(可按 status 过滤)。"""
return await skills_service.list_skills(status=status)
@router.patch("/{skill_id}", response_model=SkillResponse)
async def update_skill(skill_id: str, payload: UpdateSkillRequest) -> SkillResponse:
"""更新 / 启用 / 禁用 Skill。"""
return await skills_service.update_skill(
skill_id,
description=payload.description,
trigger=payload.trigger,
prompt_fragment=payload.prompt_fragment,
tool_bindings=payload.tool_bindings,
risk_level=payload.risk_level,
status=payload.status,
)
@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_skill(skill_id: str) -> None:
"""删除 Skill。"""
await skills_service.delete_skill(skill_id)
+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)