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:
1 parent
144415247e
commit
684261f4b4
97 files changed
+3075
-5554
No files matched your search
@@ -18,6 +18,7 @@ from app.api.v1.endpoints import (
|
||||
kernel_runs,
|
||||
lorebooks,
|
||||
me,
|
||||
baigong,
|
||||
mirror_trees,
|
||||
mcp,
|
||||
personality,
|
||||
@@ -25,7 +26,6 @@ from app.api.v1.endpoints import (
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
skills,
|
||||
tools,
|
||||
world,
|
||||
xirang,
|
||||
@@ -39,6 +39,8 @@ api_router.include_router(character_import.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(kernel_runs.router)
|
||||
api_router.include_router(me.router)
|
||||
for _baigong_r in baigong.routers:
|
||||
api_router.include_router(_baigong_r)
|
||||
api_router.include_router(embryo_drafts.router)
|
||||
api_router.include_router(jingsheng_characters.router)
|
||||
api_router.include_router(mirror_trees.router)
|
||||
@@ -47,7 +49,6 @@ api_router.include_router(mirror_trees.node_router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(skills.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
|
||||
|
||||
from app.domains.baigong.api import routers
|
||||
|
||||
__all__ = ["routers"]
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user