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

+3 -2
View File
@@ -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)
+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)
@@ -1,14 +0,0 @@
"""百工工具运行时编排层。
司命评估 → 直接执行/沙箱执行 → 一炁事件回写 的编排核心。从 ``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"]
@@ -1,174 +0,0 @@
"""内置工具集合。覆盖 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]
}
@@ -1,82 +0,0 @@
"""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
@@ -1,529 +0,0 @@
"""百工 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,
},
)
)
@@ -1,57 +0,0 @@
"""单个 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"]
@@ -1,127 +0,0 @@
"""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)
+10 -1
View File
@@ -11,7 +11,16 @@ from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.ids import new_trace_id
from app.core.logging import log_with_context
from app.core.security import redact_value
from app.modules.safety.exceptions import SafetyError
class SafetyError(Exception):
"""司命决策异常(错误码映射契约字典 §15)。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def __str__(self) -> str:
return self.message
trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)
+5
View File
@@ -47,6 +47,11 @@ def new_personality_proposal_id() -> str:
return new_id("personality_proposal_")
def new_confirmation_id() -> str:
"""确认项 ID(前缀 confirm_,契约词典 §6)。"""
return new_id("confirm")
def new_audit_id() -> str:
return new_id("audit_")
+76 -21
View File
@@ -8,12 +8,12 @@ from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.domains.baigong.models import Capability
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.repositories.json_utils import dumps
@@ -251,28 +251,83 @@ _AGENTS = [
]
async def _seed_tool_policies(session: AsyncSession) -> None:
"""把 builtin 工具元数据按主键插入 tool_policies(source=builtin)。
_BUILTIN_CAPABILITIES = [
# name, 描述, 执行类型, 执行器, 四维风险, 数据范围, 目的地, consent
(
"builtin.echo",
"回显输入(无副作用演示能力)",
"builtin",
"echo",
{"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
"",
"",
"allow",
),
(
"builtin.datetime",
"读取当前时间",
"builtin",
"datetime.now",
{"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
"",
"",
"allow",
),
(
"qiankun.shell",
"在乾坤沙箱中执行命令(隔离执行,HMAC 授契)",
"qiankun",
"",
{"capability": "L2", "data": "L1", "consequence": "L3", "context": "L1"},
"沙箱工作目录",
"本机沙箱进程",
"ask",
),
(
"web.fetch",
"抓取指定 URL 内容(受限出站)",
"http",
"",
{"capability": "L1", "data": "L1", "consequence": "L2", "context": "L1"},
"目标 URL 响应体",
"目标站点",
"allow",
),
]
存在则跳过——保留用户经 API 对 builtin 工具策略的 PATCH 覆盖;新增的 builtin 工具
(builtins.py 里新加的)会被插入。不记审计(系统初始化)。
"""
from app.application.tool_runtime.builtins import builtin_specs
for name, spec in builtin_specs().items():
if await session.get(ToolPolicy, name) is None:
session.add(
ToolPolicy(
name=name,
description=spec.description,
risk_level=spec.risk_level,
permission_policy=str(spec.permission_policy),
side_effect=spec.side_effect,
audit_required=spec.audit_required,
source="builtin",
enabled=True,
)
async def _seed_capabilities(session: AsyncSession) -> None:
"""内置能力按 (owner, name) 幂等登记(source=builtin,四维风险标注随代码演进)。"""
import json as _json
from sqlalchemy import select
existing = set(
(
await session.execute(
select(Capability.name).where(Capability.source == "builtin")
)
)
.scalars()
.all()
)
for name, desc, executor_type, ref, dims, scope, dests, consent in _BUILTIN_CAPABILITIES:
if name in existing:
continue
session.add(
Capability(
name=name,
owner_scope="local-user",
description=desc,
executor_type=executor_type,
executor_ref=ref,
risk_dims_json=_json.dumps(dims),
data_scope=scope,
destinations=dests,
consent_mode=consent,
source="builtin",
)
)
async def ensure_governance_seed(session: AsyncSession) -> None:
@@ -368,5 +423,5 @@ async def ensure_governance_seed(session: AsyncSession) -> None:
)
)
await _seed_tool_policies(session)
await _seed_capabilities(session)
await session.commit()
+12
View File
@@ -0,0 +1,12 @@
"""百工域:能力注册、授契/行契、请命与行动账(rewrite/1.0 M5)。
- models:capabilities / mandates / tool_calls(请命复用 pending_confirmations,迁移 006 增列);
- siming:司命裁决器——禁域为代码常量,任何 Profile/插件/配置不可关闭;
- executor:执行分派(builtin / qiankun 乾坤 HMAC / mcp / http);
- sanitize:结果净化与观察标证(观察非事实,不自动入忆);
- api:/capabilities /mandates /petitions /tool-calls。
"""
from app.domains.baigong import executor, models, sanitize, siming # noqa: F401
__all__ = ["executor", "models", "sanitize", "siming"]
+179
View File
@@ -0,0 +1,179 @@
"""百工与司命 API(契约词典 §5.9 pivot=capability_registry)。
- 能力目录与调用:GET/POST /capabilities、POST /capabilities/{id}/invoke;
- 授契与行契:GET/POST /mandates、DELETE /mandates/{id}(撤销立即生效);
- 请命:GET /petitions、POST /petitions/{id}/approve|deny(超时=拒绝执行 HJ-1302);
- 行动账:GET /tool-calls(净化摘要+观察标证)。
"""
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.baigong import service
from app.schemas.auth import CurrentUser
cap_router = APIRouter(prefix="/capabilities", tags=["baigong"])
mandate_router = APIRouter(prefix="/mandates", tags=["baigong"])
petition_router = APIRouter(prefix="/petitions", tags=["baigong"])
ledger_router = APIRouter(prefix="/tool-calls", tags=["baigong"])
class InvokeRequest(BaseModel):
params: dict = Field(default_factory=dict, description="能力参数。")
run_id: str | None = None
character_id: str | None = None
trace_id: str | None = None
confirmed: bool = Field(default=False, description="请命批准后的当次执行回执。")
class CreateCapabilityRequest(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str = ""
executor_type: str = Field(description="builtin / qiankun / mcp / http")
executor_ref: str = ""
params_schema: dict = Field(default_factory=dict)
risk_dims: dict = Field(default_factory=dict)
data_scope: str = ""
destinations: str = ""
consent_mode: str = "allow"
source: str = "user_custom"
class GrantMandateRequest(BaseModel):
capability_id: str
kind: str = Field(default="grant", description="grant 授契 / charter 行契")
character_id: str | None = None
scope: dict = Field(default_factory=dict)
budget_total: int = Field(default=1, ge=0)
expires_at: datetime | None = None
# ── 能力目录 ──
@cap_router.get("")
async def list_capabilities(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_capabilities(user.owner_scope)
@cap_router.post("", status_code=status.HTTP_201_CREATED)
async def create_capability(
payload: CreateCapabilityRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
import json
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability
if payload.executor_type not in ("builtin", "qiankun", "mcp", "http"):
from app.core.errors import AppError
raise AppError("invalid_request", "executor_type 须为 builtin/qiankun/mcp/http")
async with AsyncSessionLocal() as session:
cap = Capability(
name=payload.name,
owner_scope=user.owner_scope,
description=payload.description,
executor_type=payload.executor_type,
executor_ref=payload.executor_ref,
params_schema_json=json.dumps(payload.params_schema, ensure_ascii=False),
risk_dims_json=json.dumps(payload.risk_dims, ensure_ascii=False),
data_scope=payload.data_scope,
destinations=payload.destinations,
consent_mode=payload.consent_mode,
source=payload.source,
)
session.add(cap)
try:
await session.commit()
except Exception as exc: # noqa: BLE001
from app.core.errors import AppError
await session.rollback()
raise AppError("invalid_request", f"能力登记失败(名称重复?):{exc}") from None
return {"id": cap.id, "name": cap.name, "status": "registered"}
@cap_router.post("/{capability_id}/invoke")
async def invoke_capability(
capability_id: str,
payload: InvokeRequest,
request: Request,
user: CurrentUser = Depends(get_current_user),
) -> dict:
return await service.invoke(
request,
capability_id,
payload.params,
user=user,
run_id=payload.run_id,
character_id=payload.character_id,
trace_id=payload.trace_id,
confirmed=payload.confirmed,
)
# ── 授契与行契 ──
@mandate_router.get("")
async def list_mandates(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_mandates(user.owner_scope)
@mandate_router.post("", status_code=status.HTTP_201_CREATED)
async def grant_mandate(
payload: GrantMandateRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.grant_mandate(
user.owner_scope,
capability_id=payload.capability_id,
kind=payload.kind,
character_id=payload.character_id,
scope=payload.scope,
budget_total=payload.budget_total,
expires_at=payload.expires_at,
)
@mandate_router.delete("/{mandate_id}", status_code=status.HTTP_204_NO_CONTENT)
async def revoke_mandate(
mandate_id: str, user: CurrentUser = Depends(get_current_user)
) -> None:
await service.revoke_mandate(user.owner_scope, mandate_id)
# ── 请命 ──
@petition_router.get("")
async def list_petitions(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_petitions()
@petition_router.post("/{petition_id}/approve")
async def approve_petition(
petition_id: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.resolve_petition(petition_id, approved=True)
@petition_router.post("/{petition_id}/deny")
async def deny_petition(petition_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.resolve_petition(petition_id, approved=False)
# ── 行动账 ──
@ledger_router.get("")
async def list_tool_calls(
user: CurrentUser = Depends(get_current_user), limit: int = 100
) -> list[dict]:
return await service.list_tool_calls(user.owner_scope, limit=limit)
routers = [cap_router, mandate_router, petition_router, ledger_router]
+115
View File
@@ -0,0 +1,115 @@
"""百工执行器:按能力执行类型分派(builtin / qiankun 乾坤 / mcp / http)。
乾坤经 ``integrations/sandbox`` 的 SandboxClient——HMAC 短期凭证授契由沙箱客户端
签发(SANDBOX_AUTH_SECRET 两端一致),执行审计证据经行动账落库。
"""
from __future__ import annotations
import json
import shlex
from dataclasses import dataclass
from typing import Any
import httpx
from sqlalchemy import select
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability
from app.integrations.mcp.client import call_tool
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.models import ExecRequest as SandboxExecRequest
_HTTP_TIMEOUT_SECONDS = 30.0
@dataclass
class ExecOutcome:
ok: bool
output: str
error_code: str | None = None
duration_ms: int = 0
class Executor:
"""能力执行分派器。构造时注入乾坤客户端(未配置则乾坤类能力 503)。"""
def __init__(self, sandbox: SandboxClient | None, http: httpx.AsyncClient | None = None) -> None:
self._sandbox = sandbox
self._http = http
async def execute(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
dispatch = {
"builtin": self._exec_builtin,
"qiankun": self._exec_qiankun,
"mcp": self._exec_mcp,
"http": self._exec_http,
}
handler = dispatch.get(capability.executor_type)
if handler is None:
raise AppError("invalid_request", f"未知执行类型:{capability.executor_type}")
return await handler(capability, params)
# ── 内置:无副作用纯计算(时间/回显等) ──
async def _exec_builtin(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
from app.core.time import to_iso, utc_now
if capability.executor_ref == "echo":
return ExecOutcome(ok=True, output=f"[echo] {params.get('text', '')}")
if capability.executor_ref == "datetime.now":
return ExecOutcome(ok=True, output=to_iso(utc_now()))
raise AppError("not_found", f"内置能力不存在:{capability.executor_ref}")
# ── 乾坤:不可信执行隔离(HMAC 授契由 SandboxClient 签发) ──
async def _exec_qiankun(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
if self._sandbox is None:
raise AppError(
"service_unavailable", "乾坤沙箱未就绪(SANDBOX 未配置),无法执行。", status_code=503
)
command = str(params.get("command", ""))
if not command:
raise AppError("invalid_request", "qiankun 能力需要 command 参数")
result = await self._sandbox.exec(SandboxExecRequest(argv=shlex.split(command)))
output = result.stdout or result.stderr or ""
return ExecOutcome(
ok=result.ok,
output=output,
error_code=None if result.ok else "qiankun_exit",
duration_ms=result.duration_ms,
)
# ── MCP:外部能力服务(博闻等);executor_ref = mcp_servers.name ──
async def _exec_mcp(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
from app.models.mcp_servers import McpServer
async with AsyncSessionLocal() as session:
server = (
await session.execute(
select(McpServer).where(McpServer.name == capability.executor_ref)
)
).scalars().first()
if server is None or not server.endpoint_url:
raise AppError(
"not_found", f"MCP 服务未登记或无端点:{capability.executor_ref}"
)
endpoint, token = server.endpoint_url, server.auth_token
session.expunge(server)
client = self._http or httpx.AsyncClient(trust_env=False, timeout=_HTTP_TIMEOUT_SECONDS)
result = await call_tool(client, endpoint, capability.name.removeprefix(f"mcp_{capability.executor_ref}_"), params, token)
payload = getattr(result, "content", None) or result
return ExecOutcome(ok=True, output=json.dumps(payload, ensure_ascii=False, default=str))
# ── HTTP:受授契约束的外部 HTTP 动作 ──
async def _exec_http(self, capability: Capability, params: dict[str, Any]) -> ExecOutcome:
url = str(params.get("url", ""))
if not url:
raise AppError("invalid_request", "http 能力需要 url 参数")
async with httpx.AsyncClient(trust_env=False, timeout=_HTTP_TIMEOUT_SECONDS) as client:
resp = await client.request(str(params.get("method", "GET")).upper(), url)
return ExecOutcome(
ok=resp.status_code < 400,
output=f"HTTP {resp.status_code}: {resp.text[:500]}",
error_code=None if resp.status_code < 400 else f"http_{resp.status_code}",
)
+86
View File
@@ -0,0 +1,86 @@
"""乾坤沙箱工作区文件读取(百工执行产物预览)。
隔离前缀语义:workspace/<subject>/(subject=character_id,一角色一持久工作区);
subject 经白名单替换杜绝路径注入,沙箱端另有 canonicalize 校验兜底。
"""
from __future__ import annotations
import re
import uuid
from app.integrations.sandbox.auth import AuthorizationContext
from app.integrations.sandbox.client import SandboxClient
from app.integrations.sandbox.models import (
DirEntry,
FileListRequest,
FileReadRequest,
FileReadResponse,
)
_ISOLATE_SAFE = re.compile(r"[^A-Za-z0-9_.-]")
def isolate_workdir(
base: str | None,
*,
subject_id: str | None,
run_id: str | None,
trace_id: str | None,
) -> str:
"""把工作区根目录隔离到主体级子目录,防跨主体信息泄漏。"""
root = base or "workspace"
key = subject_id or run_id or trace_id or f"adhoc_{uuid.uuid4().hex[:8]}"
safe = _ISOLATE_SAFE.sub("_", key).strip("._-") or "adhoc"
return f"{root}/{safe}"
async def read_sandbox_file(
sandbox: SandboxClient,
path: str,
*,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
isolated: bool = True,
) -> FileReadResponse:
effective = path
if isolated:
workdir = isolate_workdir("workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id)
effective = f"{workdir}/{path.lstrip('/')}"
return await sandbox.file_read(
FileReadRequest(path=effective),
AuthorizationContext(
trace_id=trace_id or str(uuid.uuid4()),
risk_level="L0",
tool="file_read",
character_id=character_id,
run_id=run_id,
),
)
async def list_sandbox_dir(
sandbox: SandboxClient,
path: str = ".",
*,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
isolated: bool = True,
) -> list[DirEntry]:
effective = path
if isolated:
workdir = isolate_workdir("workspace", subject_id=character_id, run_id=run_id, trace_id=trace_id)
effective = f"{workdir}/{path.lstrip('/')}"
result = await sandbox.file_list(
FileListRequest(path=effective),
AuthorizationContext(
trace_id=trace_id or str(uuid.uuid4()),
risk_level="L0",
tool="file_list",
character_id=character_id,
run_id=run_id,
),
)
return result.entries
+110
View File
@@ -0,0 +1,110 @@
"""百工域表模型:能力注册表 / 授契与行契 / 调用记录。
设计依据 docs/domain/mandate-and-capability-model.md 与 docs/architecture/data-model.md §6:
- ``capabilities`` 统一能力契约(工具/MCP/Skill/感官/形器),保留执行类型;
- ``mandates`` 授契(单次范围)与行契(重复执行:范围/预算/时间窗),撤销立即生效;
- ``tool_calls`` 调用记录与证据链(净化后的输入输出摘要 + 观察标证);
- 请命复用 ``pending_confirmations`` 表(models/safety.py,迁移 006 增列改造)。
"""
from datetime import datetime
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
# 执行类型(data-model §6):HTTP / MCP / 乾坤 / 内置
EXECUTOR_TYPES = ("builtin", "qiankun", "mcp", "http")
# 四维风险标注(mandate-and-capability-model.md §3):能力/数据/后果/情境
RISK_DIMENSIONS = ("capability", "data", "consequence", "context")
class Capability(Base):
"""百工能力注册表:稳定标识 + 参数 Schema + 执行类型 + 四维风险标注。"""
__tablename__ = "capabilities"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("cap"))
# 稳定标识(契约词典 §5:capability_registry);同 owner 内唯一
name: Mapped[str] = mapped_column(String(120), nullable=False)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 执行类型:builtin / qiankun / mcp / http
executor_type: Mapped[str] = mapped_column(String(20), nullable=False)
# 执行器绑定(builtin 工具名 / MCP server id / 乾坤命令模板等)
executor_ref: Mapped[str] = mapped_column(String(200), nullable=False, default="")
# 参数 Schema(JSON Schema 子集;供校验与 UI 表单)
params_schema_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 四维风险标注:{"capability": "L1", "data": "L2", "consequence": "L0", "context": "L1"}
risk_dims_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# 读取/传出/修改的数据范围与外部目的地(能力契约展示)
data_scope: Mapped[str] = mapped_column(Text, nullable=False, default="")
destinations: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 来源与版本:builtin / official_signed / user_custom
source: Mapped[str] = mapped_column(String(20), nullable=False, default="builtin")
version: Mapped[str] = mapped_column(String(40), nullable=False, default="1")
# 司命裁决模式:allow(授契即行)/ ask(每次请命);禁域由裁决器硬编码,不落库不可配
consent_mode: Mapped[str] = mapped_column(String(10), nullable=False, default="allow")
enabled: Mapped[bool] = mapped_column(default=True, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
__table_args__ = (
Index("uq_capabilities_owner_name", "owner_scope", "name", unique=True),
)
class Mandate(Base):
"""授契(grant:单次范围)与行契(charter:限定条件内重复执行)。"""
__tablename__ = "mandates"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("mdt"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 授予人物(空 = 用户全局,所有人物可用)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), nullable=True, index=True
)
capability_id: Mapped[str] = mapped_column(
String(80), ForeignKey("capabilities.id"), nullable=False, index=True
)
# grant(授契:范围用完即止)/ charter(行契:条件内重复执行)
kind: Mapped[str] = mapped_column(String(10), nullable=False, default="grant")
# 范围(允许的参数/数据/目标约束,JSON;行契含预算与停止条件)
scope_json: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
# 行契:时间窗与预算(次数上限;0 = 不限次但受停止条件约束)
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
budget_total: Mapped[int] = mapped_column(nullable=False, default=1)
budget_used: Mapped[int] = mapped_column(nullable=False, default=0)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class ToolCall(Base):
"""调用记录与证据链:净化后的输入输出摘要 + 观察标证(不落原文)。"""
__tablename__ = "tool_calls"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("tc"))
run_id: Mapped[str | None] = mapped_column(String(80), ForeignKey("runs.id"), index=True)
character_id: Mapped[str | None] = mapped_column(
String(80), ForeignKey("characters.id"), index=True
)
capability_id: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
trace_id: Mapped[str | None] = mapped_column(String(80), index=True)
# 司命裁决:allow / ask_approved / denied_forbidden(HJ-1301) / denied_unmandated(HJ-1303)
decision: Mapped[str] = mapped_column(String(40), nullable=False)
# 命中的授契/行契(拒绝时为空)
mandate_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(20), nullable=False, default="ok")
error_code: Mapped[str | None] = mapped_column(String(40))
# 净化后摘要(体量受限,不含原文全文)
input_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
output_digest: Mapped[str] = mapped_column(Text, nullable=False, default="")
# 观察标证:来源、执行条件、观察语义(非事实)——供灵台作为观察接纳
evidence_json: Mapped[str] = mapped_column(JSON, nullable=False, default=dict)
duration_ms: Mapped[int] = mapped_column(nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now, index=True)
+52
View File
@@ -0,0 +1,52 @@
"""结果净化与观察标证(mandate-and-capability-model.md §6)。
能力返回不可信输入:截断体量、去除敏感材料、标注来源与执行条件;
结果以「观察」进入灵台,接纳不等于成为事实/答复/记忆。
"""
from __future__ import annotations
import json
import re
from typing import Any
MAX_DIGEST_CHARS = 2000
_REDACT = re.compile(r"sk-[a-zA-Z0-9]{8,}|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|Bearer\s+[a-zA-Z0-9._-]{12,}")
def redact(text: str) -> str:
"""敏感材料遮蔽(密钥/凭证形态)。"""
return _REDACT.sub("[已遮蔽]", text)
def digest(value: Any, limit: int = MAX_DIGEST_CHARS) -> str:
"""净化摘要:脱敏 + 截断(超长以省略号收尾,不落原文全文)。"""
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
text = redact(text)
if len(text) > limit:
return text[:limit] + "…[截断]"
return text
def observation(
*,
capability_name: str,
executor_type: str,
output_digest: str,
trace_id: str | None,
decision: str,
duration_ms: int,
) -> dict:
"""观察标证:灵台接纳工具结果的唯一形态(kind=observation,非事实非记忆)。"""
return {
"kind": "observation",
"capability": capability_name,
"executor": executor_type,
"trace_id": trace_id,
"decision": decision,
"duration_ms": duration_ms,
"output": output_digest,
# 认识论声明:观察被接纳不等于成为事实/答复/记忆(分别受认识论/镜树归真/记忆规则约束)
"epistemic": "observation_not_fact",
}
+497
View File
@@ -0,0 +1,497 @@
"""百工域应用服务:能力目录 / 授契与行契 / 请命 / 调用全链 / 行动账。
调用全链(mandate-and-capability-model.md §1 流程图):
禁域(代码常量,最先且不可绕过)→ 司命裁决(授契/行契/四维)→ 请命(超时不执行)
→ 隔离执行(乾坤 HMAC / MCP / 内置)→ 净化标证 → 行动账与调用记录落库。
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import Any
from fastapi import Request
from sqlalchemy import select
from app.core.errors import AppError
from app.core.ids import new_confirmation_id
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains.baigong import siming
from app.domains.baigong.executor import ExecOutcome, Executor
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.domains.baigong.sanitize import digest, observation, redact
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.schemas.auth import CurrentUser
async def _get_capability(capability_id: str, owner_scope: str) -> Capability:
async with AsyncSessionLocal() as session:
cap = await session.get(Capability, capability_id)
if cap is None or cap.owner_scope != owner_scope:
raise AppError("not_found", "能力不存在。", status_code=404)
session.expunge(cap)
return cap
async def _audit(
*,
action: str,
resource_id: str,
decision: str,
risk_level: str,
reason: str,
input_summary: str,
output_summary: str,
run_id: str | None,
character_id: str | None,
trace_id: str | None,
) -> None:
async with AsyncSessionLocal() as session:
session.add(
AuditEvent(
pivot="capability_registry",
action=action,
actor="baigong",
risk_level=risk_level,
decision=decision,
reason=reason,
input_summary=input_summary,
output_summary=output_summary,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
resource_type="capability",
resource_id=resource_id,
)
)
await session.commit()
async def _record_call(
*,
capability: Capability,
decision: str,
mandate_id: str | None,
status: str,
error_code: str | None,
params: dict[str, Any],
outcome: ExecOutcome | None,
run_id: str | None,
character_id: str | None,
trace_id: str | None,
duration_ms: int,
) -> ToolCall:
obs = observation(
capability_name=capability.name,
executor_type=capability.executor_type,
output_digest=outcome.output if outcome else "",
trace_id=trace_id,
decision=decision,
duration_ms=duration_ms,
)
async with AsyncSessionLocal() as session:
call = ToolCall(
run_id=run_id,
character_id=character_id,
capability_id=capability.id,
trace_id=trace_id,
decision=decision,
mandate_id=mandate_id,
status=status,
error_code=error_code,
input_digest=digest(params, limit=500),
output_digest=digest(outcome.output if outcome else ""),
evidence_json=obs,
duration_ms=duration_ms,
)
session.add(call)
await session.commit()
session.expunge(call)
return call
async def invoke(
request: Request,
capability_id: str,
params: dict[str, Any],
*,
user: CurrentUser,
run_id: str | None = None,
character_id: str | None = None,
trace_id: str | None = None,
confirmed: bool = False,
) -> dict[str, Any]:
"""能力调用全链。返回 allow 执行结果 / requires_petition 请命 / 拒绝抛错。"""
capability = await _get_capability(capability_id, user.owner_scope)
# ① 禁域最先:任何配置、标注与确认都不能越过
siming.check_forbidden(capability, params)
# ② 司命裁决:授契/行契/四维
ruling = await siming.rule(
capability,
params,
owner_scope=user.owner_scope,
character_id=character_id,
confirmed=confirmed,
)
if ruling.decision == "denied":
await _audit(
action="capability.denied",
resource_id=capability.name,
decision="denied",
risk_level=ruling.risk_level,
reason=ruling.reason,
input_summary=digest(params, limit=500),
output_summary="",
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
await _record_call(
capability=capability,
decision="denied_unmandated" if "未授契" in ruling.reason else "denied",
mandate_id=None,
status="rejected",
error_code="HJ-1303",
params=params,
outcome=None,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=0,
)
raise AppError("HJ-1303", ruling.reason, status_code=403)
if ruling.decision == "ask":
petition_id = new_confirmation_id()
deadline = siming.petition_deadline()
brief = {
# 请命说明六要素:谁在做/做什么/用什么/去哪里/后果/替代
"who": f"character:{character_id or 'user'}",
"what": capability.description or capability.name,
"capability": capability.name,
"executor": capability.executor_type,
"destinations": capability.destinations,
"consequences": f"风险等级 {ruling.risk_level}",
"alternatives": "拒绝后可调整参数或改用低风险能力",
"params": digest(params, limit=500),
}
async with AsyncSessionLocal() as session:
session.add(
PendingConfirmationRecord(
id=petition_id,
tool=capability.name,
input_json=json.dumps(params, ensure_ascii=False, default=str),
risk_level=ruling.risk_level,
reason="请命:高风险动作需当次确认",
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
expires_at=deadline,
brief_json=json.dumps(brief, ensure_ascii=False),
)
)
await session.commit()
await _record_call(
capability=capability,
decision="ask",
mandate_id=ruling.mandate.id if ruling.mandate else None,
status="petition_pending",
error_code=None,
params=params,
outcome=None,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=0,
)
return {
"status": "requires_petition",
"petition_id": petition_id,
"risk_level": ruling.risk_level,
"expires_at": deadline.isoformat(),
"brief": brief,
}
# ③ 执行 → 净化标证 → 落库
executor: Executor = request.app.state.baigong_executor
started = utc_now()
try:
outcome = await executor.execute(capability, params)
except AppError:
raise
duration_ms = (utc_now() - started).microseconds // 1000 + (
outcome.duration_ms if "outcome" in locals() else 0
)
decision = "ask_approved" if confirmed else "allow"
call = await _record_call(
capability=capability,
decision=decision,
mandate_id=ruling.mandate.id if ruling.mandate else None,
status="ok" if outcome.ok else "failed",
error_code=outcome.error_code,
params=params,
outcome=outcome,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
duration_ms=duration_ms,
)
# 行契预算核销
if ruling.mandate is not None and ruling.mandate.budget_total > 0:
async with AsyncSessionLocal() as session:
mandate = await session.get(Mandate, ruling.mandate.id)
if mandate is not None:
mandate.budget_used += 1
await session.commit()
await _audit(
action="capability.executed",
resource_id=capability.name,
decision="allow",
risk_level=ruling.risk_level,
reason=f"mandate={ruling.mandate.id if ruling.mandate else 'none'}",
input_summary=call.input_digest,
output_summary=call.output_digest,
run_id=run_id,
character_id=character_id,
trace_id=trace_id,
)
return {
"status": "ok" if outcome.ok else "failed",
"call_id": call.id,
"observation": call.evidence_json,
}
async def resolve_petition(petition_id: str, approved: bool) -> dict[str, Any]:
"""请命裁决:approve 允许当次执行(客户端以 confirmed=True 重调);超时拒绝。"""
async with AsyncSessionLocal() as session:
record = await session.get(PendingConfirmationRecord, petition_id)
if record is None or record.status != "pending":
raise AppError("not_found", "请命不存在或已处理。", status_code=404)
expires = record.expires_at
if expires is not None:
if expires.tzinfo is None: # SQLite 回读 naive(开发档)
expires = expires.replace(tzinfo=UTC)
if expires is not None and expires <= datetime.now(UTC):
record.status = "timed_out"
record.resolved_at = utc_now()
await session.commit()
raise AppError("HJ-1302", "请命超时未确认,未执行。", status_code=410)
record.status = "approved" if approved else "denied"
record.resolved_at = utc_now()
await session.commit()
return {
"status": "resolved" if approved else "cancelled",
"approved": approved,
"capability": redact(record.tool),
"run_id": record.run_id,
}
# ── 查询面 ──
async def list_capabilities(owner_scope: str) -> list[dict[str, Any]]:
async with AsyncSessionLocal() as session:
caps = (
(
await session.execute(
select(Capability).where(Capability.owner_scope == owner_scope)
)
)
.scalars()
.all()
)
mandates = (
(
await session.execute(
select(Mandate).where(
Mandate.owner_scope == owner_scope, Mandate.status == "active"
)
)
)
.scalars()
.all()
)
mandated_ids = {m.capability_id for m in mandates}
return [
{
"id": c.id,
"name": c.name,
"description": c.description,
"executor_type": c.executor_type,
"executor_ref": c.executor_ref,
"risk_dims": json.loads(c.risk_dims_json or "{}"),
"data_scope": c.data_scope,
"destinations": c.destinations,
"source": c.source,
"version": c.version,
"consent_mode": c.consent_mode,
"enabled": c.enabled,
"mandated": c.id in mandated_ids,
}
for c in caps
]
async def grant_mandate(
owner_scope: str,
*,
capability_id: str,
kind: str,
character_id: str | None,
scope: dict,
budget_total: int,
expires_at: datetime | None,
) -> dict[str, Any]:
await _get_capability(capability_id, owner_scope)
if kind not in ("grant", "charter"):
raise AppError("invalid_request", "kind 须为 grant / charter")
async with AsyncSessionLocal() as session:
mandate = Mandate(
owner_scope=owner_scope,
capability_id=capability_id,
kind=kind,
scope_json=scope,
character_id=character_id,
budget_total=budget_total,
expires_at=expires_at,
)
session.add(mandate)
await session.commit()
session.expunge(mandate)
return {
"id": mandate.id,
"capability_id": capability_id,
"kind": kind,
"budget_total": budget_total,
"budget_used": 0,
"status": mandate.status,
}
async def revoke_mandate(owner_scope: str, mandate_id: str) -> None:
"""撤销授契:立即生效(后续裁决不再命中)。"""
async with AsyncSessionLocal() as session:
mandate = await session.get(Mandate, mandate_id)
if mandate is None or mandate.owner_scope != owner_scope:
raise AppError("not_found", "授契不存在。", status_code=404)
mandate.status = "revoked"
mandate.revoked_at = utc_now()
await session.commit()
async def list_mandates(owner_scope: str) -> list[dict[str, Any]]:
async with AsyncSessionLocal() as session:
rows = (
(
await session.execute(
select(Mandate)
.where(Mandate.owner_scope == owner_scope)
.order_by(Mandate.granted_at.desc())
)
)
.scalars()
.all()
)
return [
{
"id": m.id,
"capability_id": m.capability_id,
"character_id": m.character_id,
"kind": m.kind,
"scope": m.scope_json,
"budget_total": m.budget_total,
"budget_used": m.budget_used,
"status": m.status,
"granted_at": m.granted_at.isoformat() if m.granted_at else None,
"expires_at": m.expires_at.isoformat() if m.expires_at else None,
}
for m in rows
]
async def list_petitions(owner_scope: str | None = None) -> list[dict[str, Any]]:
"""待决请命清单(行动账待办)。"""
async with AsyncSessionLocal() as session:
rows = (
(
await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.desc())
)
)
.scalars()
.all()
)
out = []
for r in rows:
try:
brief = json.loads(r.brief_json or "{}")
except ValueError:
brief = {}
out.append(
{
"id": r.id,
"capability": r.tool,
"risk_level": r.risk_level,
"reason": r.reason,
"brief": brief,
"run_id": r.run_id,
"character_id": r.character_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"expires_at": r.expires_at.isoformat() if r.expires_at else None,
}
)
return out
async def list_tool_calls(owner_scope: str, limit: int = 100) -> list[dict[str, Any]]:
"""行动账:调用记录(净化摘要 + 观察标证)。"""
async with AsyncSessionLocal() as session:
cap_ids = set(
(
await session.execute(
select(Capability.id).where(Capability.owner_scope == owner_scope)
)
)
.scalars()
.all()
)
rows = (
(
await session.execute(
select(ToolCall).order_by(ToolCall.created_at.desc()).limit(limit)
)
)
.scalars()
.all()
)
return [
{
"id": t.id,
"capability_id": t.capability_id,
"run_id": t.run_id,
"character_id": t.character_id,
"trace_id": t.trace_id,
"decision": t.decision,
"mandate_id": t.mandate_id,
"status": t.status,
"error_code": t.error_code,
"input_digest": t.input_digest,
"output_digest": t.output_digest,
"evidence": t.evidence_json,
"duration_ms": t.duration_ms,
"created_at": t.created_at.isoformat() if t.created_at else None,
}
for t in rows
if t.capability_id in cap_ids
]
+160
View File
@@ -0,0 +1,160 @@
"""司命裁决器:行动前的四维裁决与禁域否决(mandate-and-capability-model.md §3/§4)。
裁决顺序(后者不可被前者越过):
1. **禁域**:代码常量,无任何配置/Profile/插件写入路径——Prompt、人物、模型、扩展、
普通管理员与自托管配置均不能关闭(专项测试钉死);
2. **能力与授契**:能力未注册/停用/未授契 → 拒绝并留痕(HJ-1303);
3. **行契条件**:预算耗尽/过期/超范围 → 降级为请命或拒绝;
4. **请命**:consent_mode=ask 或裁决为高风险 → 登记请命,用户当次同意前不执行,
超时视为拒绝(HJ-1302)。
四维(能力/数据/后果/情境)取能力注册表的风险标注与调用上下文综合,亲密或
"过去允许过"不构成授权。
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from sqlalchemy import select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability, Mandate, RISK_DIMENSIONS
# ── 禁域:不可覆盖的核心边界 ──────────────────────────────
# 形态:对能力输入的硬否决规则。模块级常量,无运行时写入口;
# 任何 Profile/插件/DB 配置在裁决顺序上位于其后,无法绕过。
_FORBIDDEN_SHELL = re.compile(
r"rm\s+-[rfRF]+\s+/(?:\s|$)" # rm -rf / 递归删除根
r"|mkfs(\.\w+)?\s"
r"|dd\s+.*of=/dev/(?:sd|disk|nvme)"
r"|:\(\)\{.*\};\s*:", # fork 炸弹
)
_FORBIDDEN_URL = re.compile(r"^file://", re.IGNORECASE)
# 密钥与身份材料不得经任何能力外发
_SENSITIVE_KEY = re.compile(r"sk-[a-zA-Z0-9]{16,}|-----BEGIN [A-Z ]*PRIVATE KEY-----")
class ForbiddenViolation(AppError):
"""禁域违规:HJ-1301,不可申诉绕过。"""
def __init__(self, rule: str) -> None:
super().__init__("HJ-1301", f"禁域裁决拒绝:{rule}", status_code=403)
self.rule = rule
def check_forbidden(capability: Capability, params: dict) -> None:
"""禁域检查:任何能力、任何参数形态都先过这里。抛 ForbiddenViolation。"""
text = " ".join(str(v) for v in params.values())
if capability.executor_type == "qiankun" and _FORBIDDEN_SHELL.search(text):
raise ForbiddenViolation("禁止递归删除根文件系统/格式化/写裸设备/炸弹进程")
if _FORBIDDEN_URL.search(text):
raise ForbiddenViolation("禁止经能力访问本机文件协议")
if _SENSITIVE_KEY.search(text):
raise ForbiddenViolation("禁止将密钥材料作为能力参数外发")
# ── 裁决结果 ──────────────────────────────
@dataclass
class Ruling:
"""一次司命裁决的结论。"""
decision: str # allow / ask / denied
mandate: Mandate | None = None
risk_level: str = "L1"
reason: str = ""
def _risk_level(dims: dict) -> str:
"""四维取最高档:L0 < L1 < L2 < L3 < L4。"""
order = ["L0", "L1", "L2", "L3", "L4"]
level = "L0"
for dim in RISK_DIMENSIONS:
v = str(dims.get(dim, "L1"))
if v in order and order.index(v) > order.index(level):
level = v
return level
async def _active_mandate(
capability: Capability,
*,
owner_scope: str,
character_id: str | None,
now: datetime,
) -> Mandate | None:
"""查命中且仍有效的授契/行契:用户全局或本人物;未过期、未撤销、预算未尽。"""
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Mandate).where(
Mandate.capability_id == capability.id,
Mandate.owner_scope == owner_scope,
Mandate.status == "active",
(Mandate.character_id.is_(None))
| (Mandate.character_id == (character_id or "")),
(Mandate.expires_at.is_(None)) | (Mandate.expires_at > now),
(Mandate.budget_total == 0) | (Mandate.budget_used < Mandate.budget_total),
)
)
mandate = result.scalars().first()
if mandate is not None:
session.expunge(mandate)
return mandate
async def rule(
capability: Capability,
params: dict,
*,
owner_scope: str,
character_id: str | None,
confirmed: bool = False,
) -> Ruling:
"""四维裁决入口。禁域在调用方(service.invoke)最先执行,这里做授契与请命裁决。"""
now = datetime.now(UTC)
if not capability.enabled:
return Ruling(decision="denied", reason=f"能力已停用:{capability.name}")
mandate = await _active_mandate(
capability, owner_scope=owner_scope, character_id=character_id, now=now
)
risk = _risk_level(_load_dims(capability))
if mandate is None:
return Ruling(
decision="denied",
risk_level=risk,
reason=f"未授契能力被拒:{capability.name}(HJ-1303)",
)
# 行契维度:高数据/后果风险即便授契也要请命(一次确认不得自动扩大为无限行契)
needs_ask = capability.consent_mode == "ask" or risk in ("L3", "L4")
if needs_ask and not confirmed:
return Ruling(decision="ask", mandate=mandate, risk_level=risk, reason="高风险动作需当次请命")
return Ruling(decision="allow", mandate=mandate, risk_level=risk)
def _load_dims(capability: Capability) -> dict:
import json
try:
return dict(json.loads(capability.risk_dims_json or "{}"))
except ValueError:
return {}
# ── 请命超时 ──────────────────────────────
PETITION_TTL = timedelta(minutes=10)
def petition_deadline() -> datetime:
"""请命截止时刻:超时即视为拒绝(不执行,HJ-1302)。"""
return utc_now() + PETITION_TTL
+1 -1
View File
@@ -10,7 +10,7 @@ from typing import Any
import httpx
from app.core.config import settings
from app.modules.tool_runtime.definition import ToolResult
from app.integrations.mcp.models import ToolResult
def _auth_headers(auth_token: str | None) -> dict[str, str]:
+23
View File
@@ -0,0 +1,23 @@
"""MCP 集成层值对象。"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class ToolResult:
"""MCP 工具执行产出(与沙箱 ExecResponse 同构,供传输层与客户端共用)。"""
ok: bool
exit_code: int | None = None
stdout: str = ""
stderr: str = ""
duration_ms: int = 0
timeout: bool = False
truncated: bool = False
trace_id: str = ""
# 原始结构化内容(供百工净化层取用;stdout 之外的富内容)
content: object = None
is_error: bool = False
meta: dict | None = None
@@ -8,7 +8,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
from app.modules.tool_runtime.definition import ToolResult
from app.integrations.mcp.models import ToolResult
class TransportProtocol(ABC):
@@ -13,7 +13,7 @@ import httpx
from app.core.config import settings
from app.integrations.mcp.client import _auth_headers, _parse_call_result
from app.integrations.mcp.transports.base import TransportProtocol
from app.modules.tool_runtime.definition import ToolResult
from app.integrations.mcp.models import ToolResult
class StreamableHttpTransport(TransportProtocol):
@@ -17,7 +17,7 @@ from typing import Any
from app.core.config import settings
from app.integrations.mcp.client import _parse_call_result
from app.integrations.mcp.transports.base import TransportProtocol
from app.modules.tool_runtime.definition import ToolResult
from app.integrations.mcp.models import ToolResult
logger = logging.getLogger(__name__)
+23 -43
View File
@@ -15,7 +15,6 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.exc import OperationalError
from app.api.router import api_router
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import settings
from app.core.errors import register_exception_handlers, trace_id_var
from app.core.ids import new_trace_id
@@ -28,12 +27,11 @@ from app.core.logging import (
)
from app.db.init_data import seed_default_data
from app.db.session import AsyncSessionLocal, close_engine
from app.domains.baigong.executor import Executor
from app.integrations.sandbox.client import SandboxClient
from app.modules.safety.confirmation import InMemoryConfirmationStore
from app.schemas.common import ErrorResponse
from app.services.dispatcher import Dispatcher
from app.services.event_store import DbEventStore
from app.services.safety import load_governor_from_db
# 模块级初始化日志系统:import 本模块即完成 loguru 配置并接管标准 logging,
# 覆盖 ``python -m app.main`` 与 ``uvicorn app.main:app`` 两种启动入口。
@@ -107,47 +105,31 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
# 各枢核心就绪后在 start() 前 register;lifespan 管理 start/stop。
_app.state.dispatcher = Dispatcher(_app.state.event_store)
await _app.state.dispatcher.start()
_app.state.confirmation_store = InMemoryConfirmationStore()
if settings.use_db_confirmation_store:
from app.modules.safety.confirmation import DbConfirmationStore
_app.state.confirmation_store = DbConfirmationStore(AsyncSessionLocal)
log_with_context("confirmation_store_set_to_db")
# 百工执行器:乾坤沙箱客户端(HMAC 授契)+ 长生命周期 HTTP;未配置时乾坤类能力 503
sandbox_http: httpx.AsyncClient | None = None
tools_http: httpx.AsyncClient | None = None
if settings.enable_tool_runtime:
try:
if settings.sandbox_auth_enabled and not settings.sandbox_auth_secret:
raise ValueError("SANDBOX_AUTH_ENABLED=true 但 SANDBOX_AUTH_SECRET 未配置")
sandbox_http = httpx.AsyncClient(trust_env=False)
# executor 工具(fetch_url)的长生命周期 HTTP 客户端,
# 与 sandbox client 同生命周期,由 lifespan 统一 aclose。
tools_http = httpx.AsyncClient(trust_env=False)
sandbox = SandboxClient(sandbox_http, settings)
governor = await load_governor_from_db()
_app.state.tool_runtime = ToolRuntime(
sandbox,
governor,
_app.state.event_store,
http_client=tools_http,
)
log_with_context("百工工具运行时就绪")
# 启动加载 MCP server 工具(按 transport 类型注册,失败不阻断启动)
try:
from app.services.mcp import load_registered_mcp_servers
try:
if settings.sandbox_auth_enabled and not settings.sandbox_auth_secret:
raise ValueError("SANDBOX_AUTH_ENABLED=true 但 SANDBOX_AUTH_SECRET 未配置")
sandbox_http = httpx.AsyncClient(trust_env=False)
sandbox = SandboxClient(sandbox_http, settings)
_app.state.baigong_sandbox = sandbox
_app.state.baigong_executor = Executor(sandbox, http_client=sandbox_http)
log_with_context("百工执行器就绪(乾坤沙箱已接入)")
except Exception as exc: # noqa: BLE001 — 配置缺失降级,不阻断启动
_app.state.baigong_sandbox = None
_app.state.baigong_executor = Executor(None)
log_with_context(f"乾坤沙箱未就绪,qiankun 类能力将返回 503:{exc}", level="WARNING")
await load_registered_mcp_servers(
_app.state.tool_runtime,
process_manager=_app.state.mcp_process_manager,
)
except Exception: # noqa: BLE001
log_with_context("MCP 启动加载失败,已跳过", level="WARNING")
except Exception as exc: # noqa: BLE001 — 任意配置/构造失败均降级,不阻断启动
_app.state.tool_runtime = None
log_with_context(f"百工工具运行时未就绪,工具 API 将返回 503:{exc}", level="WARNING")
else:
_app.state.tool_runtime = None
log_with_context("ENABLE_TOOL_RUNTIME=false,工具运行时未启用", level="INFO")
# 启动登记 MCP 传输实例(stdio 不自动启动;派生能力已在登记/刷新时落库)
try:
from app.services.mcp import load_registered_mcp_servers
await load_registered_mcp_servers(
process_manager=_app.state.mcp_process_manager,
)
except Exception: # noqa: BLE001
log_with_context("MCP 启动加载失败,已跳过", level="WARNING")
yield
@@ -156,8 +138,6 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
await _app.state.dispatcher.stop()
if sandbox_http is not None:
await sandbox_http.aclose()
if tools_http is not None:
await tools_http.aclose()
# MCP 进程管理器关闭所有 stdio 子进程
if getattr(_app.state, "mcp_process_manager", None) is not None:
await _app.state.mcp_process_manager.shutdown_all()
+5 -2
View File
@@ -15,6 +15,7 @@ from app.models.governance import (
)
from app.models.idempotency import IdempotencyKey
from app.models.imports import CharacterImportJob, ImportItem
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat
from app.models.lorebook import Lorebook, LorebookEntry
from app.models.mcp_servers import McpServer
@@ -30,11 +31,13 @@ from app.models.rbac import Permission, Role, RolePermission, UserRole
from app.models.runtime import Event, Run
from app.models.xirang import FactualRecord, Memory as XirangMemory, MemoryTrigger
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.models.skills import Skill
from app.models.user import User
from app.models.world import CharacterWorldEntity, WorldRelation
__all__ = [
"Capability",
"Mandate",
"ToolCall",
"AgentRosterEntry",
"AuditEvent",
"BigCycle",
@@ -63,7 +66,7 @@ __all__ = [
"Role",
"RolePermission",
"Run",
"Skill",
"User",
"UserRole",
"WorldEntity",
-45
View File
@@ -149,49 +149,4 @@ class GenerationPreferences(Base):
)
class ToolPolicy(Base):
"""百工枢工具策略治理(api-spec §15 / §16)。
工具的风险/权限策略持久化层;``name`` 为主键。builtin 工具启动时由种子 upsert
(``source=builtin``),自定义工具经 API 注册(``source=custom``)。司命决策器
(SafetyGovernor)从本表加载覆盖策略;``/tools`` 与 ``/safety/policies`` 两组 API
共享本表——前者返回完整元数据,后者只返回策略子集。
"""
__tablename__ = "tool_policies"
name: Mapped[str] = mapped_column(String(80), primary_key=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
risk_level: Mapped[str] = mapped_column(String(20), nullable=False, default="L2")
permission_policy: Mapped[str] = mapped_column(String(20), nullable=False, default="ask")
side_effect: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
audit_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
source: Mapped[str] = mapped_column(String(20), nullable=False, default="custom")
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
class ModelEndpointOverride(Base):
"""模型网关 endpoint 运行时覆盖(DB 优先于 .env / yaml,api-spec §4.4)。
每个 (logical_model_name, endpoint_name) 一行;字段非空才覆盖同名 endpoint 配置,
None = 不覆盖、回退 yaml/env。base_url/api_key 经 resolved_* 优先 endpoint 字段生效,
故 DB 写入后即「DB 优先 env」。本地单用户边界下 API key 明文存储,GET 脱敏不回值。
"""
__tablename__ = "model_endpoint_overrides"
logical_model_name: Mapped[str] = mapped_column(String(120), primary_key=True)
endpoint_name: Mapped[str] = mapped_column(String(120), primary_key=True)
base_url: Mapped[str | None] = mapped_column(String(500))
api_key: Mapped[str | None] = mapped_column(String(500))
status: Mapped[str | None] = mapped_column(String(40))
weight: Mapped[int | None] = mapped_column(Integer)
priority: Mapped[int | None] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
+3
View File
@@ -50,5 +50,8 @@ class PendingConfirmationRecord(Base):
character_id: Mapped[str | None] = mapped_column(String(80), index=True)
trace_id: Mapped[str | None] = mapped_column(String(80))
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending", index=True)
# M5 请命改造:超时=拒绝执行(HJ-1302);brief_json 为请命六要素说明
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
brief_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}", server_default="{}")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
-33
View File
@@ -1,33 +0,0 @@
"""百工 Skill 能力包模型(skills-design §4.1;契约字典 §10.5 skill_ 前缀)。
Skill 是显式注册的能力包:触发时注入 prompt_fragment 到对话 contract,
tool_bindings 仅作为 prompt 提示(不改变 ToolRuntime 注册集)。
"""
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_skill_id
from app.core.time import utc_now
from app.db.base import Base
class Skill(Base):
"""Skill 能力包。"""
__tablename__ = "skills"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=new_skill_id)
name: Mapped[str] = mapped_column(String(120), nullable=False, unique=True)
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
trigger: Mapped[str] = mapped_column(Text, nullable=False, default="")
prompt_fragment: Mapped[str] = mapped_column(Text, nullable=False, default="")
tool_bindings_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
risk_level: Mapped[str] = mapped_column(String(8), nullable=False, default="L1")
status: Mapped[str] = mapped_column(String(40), nullable=False, default="enabled", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utc_now, onupdate=utc_now
)
-19
View File
@@ -1,19 +0,0 @@
"""通用 Agent 的纯领域契约。"""
from app.modules.agent.definition import (
DEFAULT_CHAT_AGENT,
AgentDefinition,
ExecutionPolicy,
ToolPolicy,
)
from app.modules.agent.task import AgentTask, Plan, PlanStep
__all__ = [
"DEFAULT_CHAT_AGENT",
"AgentDefinition",
"AgentTask",
"ExecutionPolicy",
"Plan",
"PlanStep",
"ToolPolicy",
]
-44
View File
@@ -1,44 +0,0 @@
"""通用 Agent 定义。
定义保持纯数据;模型调用、工具执行与持久化仍由既有运行时负责。
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class ExecutionPolicy:
"""一次 Agent 运行的硬性边界。"""
max_iterations: int = 8
def __post_init__(self) -> None:
if self.max_iterations < 1:
raise ValueError("max_iterations 必须 >= 1")
@dataclass(frozen=True)
class ToolPolicy:
"""本次运行允许暴露给模型的工具名称。空值表示沿用运行时默认工具集。"""
allowed_tools: tuple[str, ...] = ()
@dataclass(frozen=True)
class AgentDefinition:
"""可组合的 Agent 身份、提示、技能与执行边界。"""
name: str
system_prompt: str
model_hint: str | None = None
skill_refs: tuple[str, ...] = ()
tool_policy: ToolPolicy = field(default_factory=ToolPolicy)
execution_policy: ExecutionPolicy = field(default_factory=ExecutionPolicy)
DEFAULT_CHAT_AGENT = AgentDefinition(
name="镜师",
system_prompt="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
)
-46
View File
@@ -1,46 +0,0 @@
"""通用 Agent 的任务与计划纯领域契约。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
TaskStatus = Literal["queued", "running", "waiting_confirmation", "completed", "failed", "cancelled"]
PlanStatus = Literal["draft", "approved", "running", "completed", "failed"]
StepStatus = Literal["pending", "running", "completed", "failed", "skipped"]
@dataclass(frozen=True)
class PlanStep:
"""可追踪的 Agent 计划步骤。"""
id: str
title: str
description: str = ""
tool: str | None = None
risk_level: str = "L2"
status: StepStatus = "pending"
result_summary: str | None = None
@dataclass(frozen=True)
class Plan:
"""任务的显式执行计划。"""
steps: tuple[PlanStep, ...]
assumptions: tuple[str, ...] = ()
risks: tuple[str, ...] = ()
status: PlanStatus = "draft"
@dataclass(frozen=True)
class AgentTask:
"""一次 Agent 执行的目标与可选计划。"""
id: str
objective: str
agent_name: str
run_id: str | None = None
status: TaskStatus = "queued"
plan: Plan | None = None
constraints: tuple[str, ...] = ()
+2 -3
View File
@@ -1,6 +1,5 @@
"""运行时:Run / Event / Trace 与小周天执行状态(见后端架构计划 5.2)。"""
"""运行时事件契约(Event/EventStore):一炁事件流的数据结构。"""
from app.modules.runtime.events import Event, EventStore, InMemoryEventStore
from app.modules.runtime.goals import build_goals_context
__all__ = ["Event", "EventStore", "InMemoryEventStore", "build_goals_context"]
__all__ = ["Event", "EventStore", "InMemoryEventStore"]
-41
View File
@@ -1,41 +0,0 @@
"""运行时契约。"""
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from app.modules.agent import AgentDefinition
class RuntimeContract(BaseModel):
"""从业务上下文传递给 LLM 基础设施的结构化契约。"""
model_config = ConfigDict(arbitrary_types_allowed=True)
character_id: str
session_id: str
run_id: str
model_hint: str
agent: AgentDefinition | None = None
system_instructions: list[str] = Field(default_factory=list)
# 卡的 system_prompt(酒馆):作道体增补层注入,绝不覆盖恒定层(§6 安全边界)
system_prompt: str | None = None
session_goal: str | None = None
personality_summary: str | None = None
# 道体核心使命(设计理念 §2.1):对应太一 highest goal 的恒定快照,组装时不可被覆盖。
mission: str | None = None
# 道体安全边界:人物自身基础行为约束(区别于司命 safety_constraints 的动态治理)
safety_guidelines: str | None = None
# 世谱(Lorebook)注入:before/after 相对 personality_summary 的位置(酒馆 before_char/after_char)
lorebook_before: str | None = None
lorebook_after: str | None = None
emotion_state: str | None = None
memory_context: str | None = None
temporal_context: str | None = None
active_goals: str | None = Field(
default=None,
description="格式化后的活跃目标上下文(由 build_goals_context 生成)。",
)
recent_messages: list[Any] = Field(default_factory=list)
safety_constraints: list[str] = Field(default_factory=list)
trace_id: str
-36
View File
@@ -1,36 +0,0 @@
"""运行时目标上下文构建(纯函数,无 IO)。"""
from typing import Any
_LEVEL_LABEL: dict[str, str] = {
"highest": "最高目标",
"long_term": "长期目标",
"session": "会话目标",
"task": "任务目标",
"action": "行动",
}
def build_goals_context(goals: list[dict[str, Any]]) -> str:
"""把活跃目标列表格式化为 system prompt 片段。
按 priority 降序排列;空列表返回空串。
"""
if not goals:
return ""
sorted_goals = sorted(goals, key=lambda g: g.get("priority", 0), reverse=True)
lines: list[str] = ["当前活跃目标:"]
for g in sorted_goals:
level_label = _LEVEL_LABEL.get(g.get("level", ""), g.get("level", ""))
progress = g.get("progress", 0.0)
bar = _progress_bar(progress)
line = f"- [{level_label}] {g['title']} [{bar} {int(progress * 100)}%]"
lines.append(line)
return "\n".join(lines)
def _progress_bar(value: float, width: int = 8) -> str:
filled = int(value * width)
return "■" * filled + "□" * (width - filled)
-30
View File
@@ -1,30 +0,0 @@
"""道体完整性校验。
组装运行时契约后,确认恒定层(mission / safety_guidelines)仍等于 Character 的值,
未被任何动态注入(system_prompt / 世谱 / 眉批)在数据层覆盖或稀释。
这是魂镜相对酒馆的核心安全增量——酒馆的 system_prompt 设计上完全替换前端 prompt,
魂镜则保证道体恒定层不可被覆盖(设计理念 §2.2、融合设计 §6)。
注:字段层校验防组装错误与数据层覆盖;prompt 层的注入诱导(「忽略你的使命」)
属另一类问题,不在此校验范围。
"""
from __future__ import annotations
from app.modules.runtime.contract import RuntimeContract
def validate_daoti_integrity(
contract: RuntimeContract,
*,
mission: str | None = None,
safety_guidelines: str | None = None,
) -> None:
"""校验契约道体字段仍等于 Character 恒定层;失配抛 ValueError。
``None`` 的道体字段跳过校验(未设定时不强求)。
"""
if mission is not None and contract.mission != mission:
raise ValueError("道体完整性校验失败:mission 与 Character 恒定层不一致")
if safety_guidelines is not None and contract.safety_guidelines != safety_guidelines:
raise ValueError("道体完整性校验失败:safety_guidelines 与 Character 恒定层不一致")
-31
View File
@@ -1,31 +0,0 @@
"""司命枢:权限、风险与审批(见 safety-governor-design)。"""
from app.modules.safety.confirmation import (
ConfirmationStore,
InMemoryConfirmationStore,
PendingConfirmation,
new_confirmation_id,
)
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation, SafetyError
from app.modules.safety.governor import (
PermissionPolicy,
SafetyDecision,
SafetyGovernor,
SafetyResult,
ToolPolicy,
)
__all__ = [
"ConfirmationStore",
"InMemoryConfirmationStore",
"PendingConfirmation",
"PermissionDenied",
"PermissionPolicy",
"RequiresConfirmation",
"SafetyDecision",
"SafetyError",
"SafetyGovernor",
"SafetyResult",
"ToolPolicy",
"new_confirmation_id",
]
-178
View File
@@ -1,178 +0,0 @@
"""待确认动作的存储(高风险工具调用的确认中转)。
司命决策为 ask 时不直接执行,而是登记一个 PendingConfirmation 并返回 confirmation_id;
前端展示确认卡片,用户 approve/deny 后由安全 API 消费。
提供两种实现:
- ``InMemoryConfirmationStore``:进程内 dict,默认开发模式,不依赖 DB
- ``DbConfirmationStore``:数据库持久化,生产多 worker 模式,确认项跨进程可见
"""
from __future__ import annotations
import json
import uuid
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from typing import Any, Protocol
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.safety import PendingConfirmationRecord
def _now_iso() -> str:
return datetime.now(UTC).isoformat()
def new_confirmation_id() -> str:
return f"confirm_{uuid.uuid4().hex}"
@dataclass(frozen=True)
class PendingConfirmation:
"""一次待确认的工具调用。"""
confirmation_id: str
tool: str
input: dict[str, Any]
risk_level: str
reason: str
run_id: str | None = None
character_id: str | None = None
trace_id: str | None = None
created_at: str = field(default_factory=_now_iso)
class ConfirmationStore(Protocol):
"""确认存储协议(全异步接口)。"""
async def create(self, pending: PendingConfirmation) -> PendingConfirmation: ...
async def get(self, confirmation_id: str) -> PendingConfirmation | None: ...
async def pop(self, confirmation_id: str) -> PendingConfirmation | None: ...
async def list_pending(self) -> list[PendingConfirmation]: ...
async def mark_timed_out(self, confirmation_id: str) -> None: ...
class InMemoryConfirmationStore:
"""内存确认存储,供本地与测试使用。"""
def __init__(self) -> None:
self._items: dict[str, PendingConfirmation] = {}
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
self._items[pending.confirmation_id] = pending
return pending
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
return self._items.get(confirmation_id)
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
return self._items.pop(confirmation_id, None)
async def list_pending(self) -> list[PendingConfirmation]:
return sorted(self._items.values(), key=lambda item: item.created_at)
async def mark_timed_out(self, confirmation_id: str) -> None:
self._items.pop(confirmation_id, None)
class DbConfirmationStore:
"""数据库确认存储,供多 worker 生产环境使用。
待确认项持久化到 ``pending_confirmations`` 表,跨进程可见。
需要从 ``app.state.session_factory`` 或 ``async_sessionmaker`` 注入。
"""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._session_factory = session_factory
async def create(self, pending: PendingConfirmation) -> PendingConfirmation:
async with self._session_factory() as session:
record = PendingConfirmationRecord(
id=pending.confirmation_id,
tool=pending.tool,
input_json=json.dumps(pending.input, ensure_ascii=False),
risk_level=pending.risk_level,
reason=pending.reason,
run_id=pending.run_id,
character_id=pending.character_id,
trace_id=pending.trace_id,
)
session.add(record)
await session.commit()
return pending
async def get(self, confirmation_id: str) -> PendingConfirmation | None:
async with self._session_factory() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.id == confirmation_id,
PendingConfirmationRecord.status == "pending",
)
)
record = result.scalar_one_or_none()
if record is None:
return None
return self._record_to_pending(record)
async def pop(self, confirmation_id: str) -> PendingConfirmation | None:
async with self._session_factory() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.id == confirmation_id,
PendingConfirmationRecord.status == "pending",
)
)
record = result.scalar_one_or_none()
if record is None:
return None
record.status = "consumed"
await session.commit()
return self._record_to_pending(record)
async def list_pending(self) -> list[PendingConfirmation]:
async with self._session_factory() as session:
result = await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.asc())
)
records: Sequence[PendingConfirmationRecord] = result.scalars().all()
return [self._record_to_pending(r) for r in records]
async def mark_timed_out(self, confirmation_id: str) -> None:
"""将待确认记录标记为超时(Agent 循环确认等待超时)。"""
async with self._session_factory() as session:
result = await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.id == confirmation_id,
PendingConfirmationRecord.status == "pending",
)
)
record = result.scalar_one_or_none()
if record is None:
return
record.status = "timed_out"
record.resolved_at = datetime.now(UTC)
await session.commit()
@staticmethod
def _record_to_pending(record: PendingConfirmationRecord) -> PendingConfirmation:
return PendingConfirmation(
confirmation_id=record.id,
tool=record.tool,
input=json.loads(record.input_json) if record.input_json else {},
risk_level=record.risk_level,
reason=record.reason,
run_id=record.run_id,
character_id=record.character_id,
trace_id=record.trace_id,
created_at=record.created_at.isoformat() if record.created_at else _now_iso(),
)
-30
View File
@@ -1,30 +0,0 @@
"""司命异常。错误码映射契约字典 API 标准错误码。"""
from __future__ import annotations
class SafetyError(Exception):
"""司命决策异常基类。"""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def __str__(self) -> str:
return self.message
class PermissionDenied(SafetyError):
"""动作被司命拒绝(映射 permission_denied)。"""
def __init__(self, message: str) -> None:
super().__init__("permission_denied", message)
class RequiresConfirmation(SafetyError):
"""高风险动作需用户确认(映射 requires_confirmation)。"""
def __init__(self, message: str, *, risk_level: str | None = None) -> None:
super().__init__("requires_confirmation", message)
self.risk_level = risk_level
-115
View File
@@ -1,115 +0,0 @@
"""司命决策器:按工具策略 + 风险等级 + 是否已确认,输出 allow / ask / deny。
风险分级与权限策略取值见契约字典(risk_level L0–L4、permission_policy
allow/ask/always_ask/forbidden);决策枚举对齐 AuditEvent.decision(allow/deny/ask)。
策略来源:``SafetyGovernor()`` 用代码内置默认;``from_policy_dict(data)`` 从已解析字典
加载(文件 IO 由 ``services/safety.load_safety_governor`` 承担,本模块保持纯领域、零 IO)。
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class PermissionPolicy(StrEnum):
allow = "allow"
ask = "ask"
always_ask = "always_ask"
forbidden = "forbidden"
class SafetyDecision(StrEnum):
allow = "allow"
ask = "ask"
deny = "deny"
# 风险等级排序,便于阈值判断
RISK_RANK: dict[str, int] = {"L0": 0, "L1": 1, "L2": 2, "L3": 3, "L4": 4}
def default_policy(risk_level: str) -> PermissionPolicy:
"""无显式工具策略时,按风险等级推导默认策略。"""
rank = RISK_RANK.get(risk_level, 2)
if rank >= 4:
return PermissionPolicy.forbidden
if rank >= 3:
return PermissionPolicy.always_ask
if rank >= 2:
return PermissionPolicy.ask
return PermissionPolicy.allow
@dataclass(frozen=True)
class ToolPolicy:
"""工具的固定策略(由工具定义或配置提供)。"""
risk_level: str # L0–L4
permission_policy: PermissionPolicy
@dataclass(frozen=True)
class SafetyResult:
decision: SafetyDecision
reason: str
risk_level: str
policy: PermissionPolicy
class SafetyGovernor:
"""司命决策器。无状态、同步——便于测试与上层组合确认流。"""
def __init__(self, policies: dict[str, ToolPolicy] | None = None) -> None:
self._policies: dict[str, ToolPolicy] = dict(policies or {})
@classmethod
def from_policy_dict(cls, data: dict) -> SafetyGovernor:
"""从已解析的策略字典构造 Governor(纯逻辑,不读文件)。
文件读取由 ``services/safety.load_safety_governor`` 承担,调用方传入已
``yaml.safe_load`` 的字典;空字典或缺 ``tools`` 返回默认(空策略)Governor。
"""
policies: dict[str, ToolPolicy] = {}
tools = data.get("tools") if isinstance(data, dict) else None
if isinstance(tools, dict):
for name, cfg in tools.items():
if not isinstance(cfg, dict):
continue
policies[name] = ToolPolicy(
risk_level=str(cfg.get("risk_level", "L2")),
permission_policy=PermissionPolicy(str(cfg.get("permission_policy", "ask"))),
)
return cls(policies)
def evaluate(
self,
*,
tool: str,
risk_level: str | None = None,
confirmed: bool = False,
) -> SafetyResult:
policy = self._policies.get(tool)
if policy is None:
resolved_risk = risk_level or "L2"
perm = default_policy(resolved_risk)
else:
resolved_risk = policy.risk_level
perm = policy.permission_policy
# L4 一律禁止,即便策略写 allow
if RISK_RANK.get(resolved_risk, 2) >= 4:
return SafetyResult(
SafetyDecision.deny, "L4 风险禁止执行", resolved_risk, PermissionPolicy.forbidden
)
if perm is PermissionPolicy.forbidden:
return SafetyResult(SafetyDecision.deny, "工具策略为 forbidden", resolved_risk, perm)
if perm is PermissionPolicy.allow:
return SafetyResult(SafetyDecision.allow, "策略放行", resolved_risk, perm)
# ask / always_ask
if confirmed:
return SafetyResult(SafetyDecision.allow, "用户已确认", resolved_risk, perm)
return SafetyResult(
SafetyDecision.ask, f"策略 {perm.value} 需用户确认", resolved_risk, perm
)
-5
View File
@@ -1,5 +0,0 @@
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
__all__ = ["SkillManifest", "parse_skill_manifest"]
-40
View File
@@ -1,40 +0,0 @@
"""Skill Manifest:将现有 Skill 记录转换为运行时无 IO 描述。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
class SkillLike(Protocol):
name: str
description: str
trigger: str
prompt_fragment: str
risk_level: str
status: str
tool_bindings_json: str
@dataclass(frozen=True)
class SkillManifest:
"""一次运行需要的技能声明。"""
name: str
description: str
triggers: tuple[str, ...]
prompt: str
allowed_tools: tuple[str, ...]
risk_level: str
def parse_skill_manifest(skill: SkillLike, tool_bindings: list[str]) -> SkillManifest:
"""从既有 Skill ORM 构建 manifest,避免改变数据库结构。"""
return SkillManifest(
name=skill.name,
description=skill.description,
triggers=tuple(item.strip() for item in skill.trigger.split(",") if item.strip()),
prompt=skill.prompt_fragment,
allowed_tools=tuple(tool_bindings),
risk_level=skill.risk_level,
)
-20
View File
@@ -1,20 +0,0 @@
"""Skill 关键词触发匹配(纯函数,无 IO)。
按 ``trigger`` 逗号分隔关键词,``context_text`` 包含任一关键词(大小写不敏感)则命中。
仅 ``status=enabled`` 的 skill 参与;返回保持输入顺序。
"""
from collections.abc import Sequence
def match_skills[T](context_text: str, skills: Sequence[T]) -> list[T]:
text = context_text.lower()
result: list[T] = []
for skill in skills:
if getattr(skill, "status", "enabled") != "enabled":
continue
trigger = getattr(skill, "trigger", "") or ""
keywords = [k.strip().lower() for k in trigger.split(",") if k.strip()]
if any(kw in text for kw in keywords):
result.append(skill)
return result
@@ -1,10 +0,0 @@
"""百工工具运行时领域结构(ToolSpec / ToolResult / ToolInput)。
工具的实际编排(``ToolRuntime``)与内置工具注册(``builtins``)已迁至
``application/tool_runtime/``——它们需要协调沙箱(integrations)、司命(modules)、
事件存储(services)等多层,属于应用编排而非纯领域。本目录只保留无 IO 的工具规格数据结构。
"""
from app.modules.tool_runtime.definition import ToolInput, ToolResult, ToolSpec
__all__ = ["ToolInput", "ToolResult", "ToolSpec"]
@@ -1,92 +0,0 @@
"""工具定义:ToolSpec 描述工具元数据与请求构造方式,ToolResult 为执行产出。
字段口径对齐 docs/architecture/service-boundaries.md(工具定义字段、风险分级、
权限策略)。工具的实际执行有两条路径:
- 沙箱路径:``build_request`` 产出沙箱 ExecRequest,由 SandboxClient 执行(文件/shell 类)。
- 直接执行路径:``executor`` 非 None 时由 runtime 注入 http_client、密钥等上下文直接执行
(HTTP 类工具如 fetch_url,不走沙箱)。
本模块是纯领域数据结构(无 IO):``ExecRequest`` 仅在类型注解中引用(``TYPE_CHECKING``),
运行时不依赖 ``integrations``;实际 ExecRequest 的构造在 ``application/tool_runtime/``。
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol
from app.modules.safety.governor import PermissionPolicy
if TYPE_CHECKING:
# 仅类型注解用:build_request 产出沙箱 ExecRequest,但本模块运行时不依赖 integrations
# (保持 modules 零 IO 依赖);实际构造在 application/tool_runtime/builtins。
from app.integrations.sandbox.models import ExecRequest
ToolInput = dict[str, Any]
RequestBuilder = Callable[[ToolInput], "ExecRequest"]
# 前向引用 ToolResult(定义在下方),避免循环依赖;TYPE_CHECKING 下解析为真实类。
ToolExecutor = Callable[[ToolInput, dict[str, Any] | None], Awaitable["ToolResult"]]
class ToolRuntimeLike(Protocol):
"""Agent loop 所需的最小工具运行时契约。"""
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: ...
@dataclass(frozen=True)
class ToolResult:
"""工具执行产出(映射沙箱 ExecResponse 或 executor 直接产出)。"""
ok: bool
exit_code: int | None
stdout: str
stderr: str
duration_ms: int
timeout: bool
truncated: bool
trace_id: str
@dataclass(frozen=True)
class ToolSpec:
"""工具规格:名称、风险、权限策略与请求构造器/执行器。
执行路径二选一:
- ``executor`` 非 None:直接执行,跳过沙箱(HTTP 类工具);``build_request`` 此时可为占位。
- ``executor`` 为 None:走沙箱路径,用 ``build_request`` 构造 ExecRequest。
"""
name: str
description: str
risk_level: str # L0–L4
permission_policy: PermissionPolicy
build_request: RequestBuilder = field(default_factory=lambda: _placeholder_builder)
default_timeout_ms: int = 30_000
side_effect: bool = True
audit_required: bool = True
# 是否按 run/trace 隔离工作目录(文件类工具):runtime 会把 workdir 改写为
# ``<workdir>/<run_key>``,防跨 run 信息泄漏与文件名冲突;run_key 经 sanitize 防路径注入。
isolated_workspace: bool = False
# 非 None 时走直接执行路径(HTTP 类工具),跳过沙箱;context 由 runtime 注入。
executor: ToolExecutor | None = None
# 工具入参 JSON Schema(供 LLM 生成正确参数),None 表示无参数。
input_schema: dict[str, Any] | None = None
def _placeholder_builder(
_input: ToolInput,
) -> ExecRequest: # pragma: no cover - 仅占位,executor 工具不走此路径
"""executor 工具的 build_request 占位:正常不应被调用(runtime 见 executor 非 None 会跳过)。"""
raise NotImplementedError("该工具使用 executor 直接执行,不应调用 build_request")
-14
View File
@@ -13,7 +13,6 @@ from app.models.governance import (
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
@@ -157,19 +156,6 @@ async def add_agent(session: AsyncSession, agent: AgentRosterEntry) -> AgentRost
# —— 百工枢 · 工具策略(§15/§16 共享)——
async def list_tool_policies(session: AsyncSession) -> list[ToolPolicy]:
result = await session.execute(select(ToolPolicy).order_by(ToolPolicy.name))
return list(result.scalars().all())
async def get_tool_policy(session: AsyncSession, name: str) -> ToolPolicy | None:
return await session.get(ToolPolicy, name)
async def add_tool_policy(session: AsyncSession, policy: ToolPolicy) -> ToolPolicy:
session.add(policy)
await session.flush()
return policy
# —— 计数(供九枢健康度聚合)——
@@ -1,47 +0,0 @@
"""模型网关 endpoint 覆盖数据访问(api-spec §4.4)。
按 (logical_model_name, endpoint_name) 唯一;upsert 手动 find+update,兼容 SQLite。
字段 None 表示不覆盖该字段(回退 yaml/env)。
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.governance import ModelEndpointOverride
async def list_overrides(session: AsyncSession) -> list[ModelEndpointOverride]:
result = await session.execute(select(ModelEndpointOverride))
return list(result.scalars().all())
async def get_override(
session: AsyncSession,
logical_model_name: str,
endpoint_name: str,
) -> ModelEndpointOverride | None:
result = await session.execute(
select(ModelEndpointOverride).where(
ModelEndpointOverride.logical_model_name == logical_model_name,
ModelEndpointOverride.endpoint_name == endpoint_name,
)
)
return result.scalars().first()
async def upsert_override(
session: AsyncSession,
override: ModelEndpointOverride,
) -> ModelEndpointOverride:
"""存在则更新非 None 字段,否则新建。返回最终实体(未 commit)。"""
existing = await get_override(session, override.logical_model_name, override.endpoint_name)
if existing is not None:
existing.base_url = override.base_url
existing.api_key = override.api_key
existing.status = override.status
existing.weight = override.weight
existing.priority = override.priority
return existing
session.add(override)
await session.flush()
return override
+2 -67
View File
@@ -1,73 +1,8 @@
"""安全 / 工具策略 API schema(api-spec §15 / §16)。
`tool_policies` 表同时服务两组 API:
- `/tools`(§15):返回 `ToolPolicyResponse`(完整元数据 + executable 派生位)。
- `/safety/policies`(§16):返回 `SafetyPolicyItem`(策略子集)。
"""
"""安全 API schema:一炁 tool.* 事件详情(策略面已由百工域 capabilities 承接)。"""
from pydantic import BaseModel, ConfigDict, Field
class ToolPolicyResponse(BaseModel):
"""工具策略完整元数据(GET /tools 元素、POST/PATCH /tools 响应)。"""
model_config = ConfigDict(from_attributes=True)
name: str = Field(description="工具名(主键)。")
description: str = Field(default="", description="工具描述。")
risk_level: str = Field(description="风险等级 L0–L4。")
permission_policy: str = Field(description="权限策略 allow/ask/always_ask/forbidden。")
side_effect: bool = Field(description="是否有副作用。")
audit_required: bool = Field(description="是否强制记审计。")
source: str = Field(description="来源:builtin / custom。")
enabled: bool = Field(description="是否启用(禁用的工具不进入司命策略)。")
executable: bool = Field(
default=False,
description="是否具备执行实现(builtin 或已注册 ToolSpec);纯治理声明为 false。",
)
created_at: str = Field(description="创建时间,UTC ISO 8601。")
updated_at: str = Field(description="更新时间,UTC ISO 8601。")
class SafetyPolicyItem(BaseModel):
"""策略子集(GET /safety/policies 元素)。"""
model_config = ConfigDict(from_attributes=True)
tool: str = Field(description="工具名。")
risk_level: str = Field(description="风险等级 L0–L4。")
permission_policy: str = Field(description="权限策略。")
side_effect: bool = Field(description="是否有副作用。")
audit_required: bool = Field(description="是否强制记审计。")
class CreateToolPolicyRequest(BaseModel):
"""注册工具 / 新增策略请求(POST /tools、POST /safety/policies 共用)。"""
name: str = Field(description="工具名(唯一)。")
description: str = Field(default="", description="工具描述。")
risk_level: str = Field(default="L2", description="风险等级 L0–L4。")
permission_policy: str = Field(default="ask", description="权限策略。")
side_effect: bool = Field(default=True, description="是否有副作用。")
audit_required: bool = Field(default=True, description="是否强制记审计。")
enabled: bool = Field(default=True, description="是否启用。")
class UpdateToolPolicyRequest(BaseModel):
"""编辑工具 / 策略请求(PATCH /tools/{name}、PATCH /safety/policies/{tool} 共用)。
所有字段可选;spec §15 要求 risk_level、§16 要求 permission_policy / risk_level,
此处统一放开为完整元数据编辑(至少传一个)。
"""
description: str | None = None
risk_level: str | None = None
permission_policy: str | None = None
side_effect: bool | None = None
audit_required: bool | None = None
enabled: bool | None = None
class ToolCallDetail(BaseModel):
"""单次工具调用事件详情(GET /tools/calls/{tool_call_id})。
@@ -77,7 +12,7 @@ class ToolCallDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
event_id: str = Field(description="事件 ID(即路径 tool_call_id)。")
event_id: str = Field(description="事件 ID(即路径 tool_call_id}。")
type: str = Field(description="事件类型:tool.started / tool.completed / tool.failed 等。")
tool: str | None = Field(default=None, description="工具名(payload 解出)。")
run_id: str | None = Field(default=None)
+48 -36
View File
@@ -1,14 +1,13 @@
"""司命台管理聚合服务(api-spec §19.1–§19.3)。
九枢健康度由各枢运行/治理数据派生(不落长期状态,§19.1);朱批待办复用
ConfirmationStore(§19.2);起居注审计复用 audit_events(§19.3)。
待决请命(§19.2);起居注审计复用 audit_events(§19.3)。
"""
import json
from sqlalchemy import func, select
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import settings
from app.core.security import redact_text
from app.core.time import to_iso, utc_now
@@ -23,8 +22,7 @@ from app.models.governance import (
from app.models.idempotency import IdempotencyKey
from app.models.xirang import Memory
from app.models.runtime import Event, Run
from app.models.safety import AuditEvent
from app.modules.safety.confirmation import ConfirmationStore, PendingConfirmation
from app.models.safety import AuditEvent, PendingConfirmationRecord
from app.repositories import audit as audit_repo
from app.repositories import characters as character_repo
from app.repositories import governance as governance_repo
@@ -66,12 +64,22 @@ def _summarize_input(value: dict | None) -> str:
async def get_pivots_health(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
owner_scope: str = settings.default_owner_scope,
) -> PivotHealthReport:
async with AsyncSessionLocal() as session:
pending_count = len(await confirmation_store.list_pending())
pending_count = len(
list(
(
await session.execute(
select(PendingConfirmationRecord).where(
PendingConfirmationRecord.status == "pending"
)
)
)
.scalars()
.all()
)
)
goals_n = await governance_repo.count(session, Goal)
world_n = await governance_repo.count(session, WorldEntity)
plans_n = await governance_repo.count(session, Plan)
@@ -80,7 +88,7 @@ async def get_pivots_health(
memories_n = await governance_repo.count(session, Memory, Memory.status != "tombstoned")
character = await character_repo.get_active_character(session, owner_scope)
has_personality = bool(character and character.current_personality_version_id)
tool_enabled = settings.enable_tool_runtime and tool_runtime is not None
tool_enabled = True # 百工域常驻(乾坤可用性按端点区分)
now = to_iso(utc_now())
raw: list[tuple[str, str, str, bool, str]] = [
@@ -128,38 +136,42 @@ async def get_pivots_health(
)
async def get_pending_approvals(
*,
confirmation_store: ConfirmationStore,
tool_runtime: ToolRuntime | None,
) -> list[PendingApproval]:
specs = {spec.name: spec for spec in tool_runtime.list_specs()} if tool_runtime else {}
async def get_pending_approvals() -> list[PendingApproval]:
async with AsyncSessionLocal() as session:
records = (
(
await session.execute(
select(PendingConfirmationRecord)
.where(PendingConfirmationRecord.status == "pending")
.order_by(PendingConfirmationRecord.created_at.asc())
)
)
.scalars()
.all()
)
approvals: list[PendingApproval] = []
for pending in await confirmation_store.list_pending():
spec = specs.get(pending.tool)
permission_policy = str(spec.permission_policy) if spec else "ask"
possible_impact = "可能产生副作用" if (spec and spec.side_effect) else None
approvals.append(_pending_to_approval(pending, permission_policy, possible_impact))
for record in records:
try:
input_payload = json.loads(record.input_json) if record.input_json else {}
except ValueError:
input_payload = {}
approvals.append(
PendingApproval(
confirmation_id=record.id,
character_id=record.character_id,
tool=record.tool,
risk_level=record.risk_level,
permission_policy="ask",
action_summary=f"调用能力 {record.tool}",
input_summary=_summarize_input(input_payload),
possible_impact=None,
reason=record.reason,
created_at=record.created_at.isoformat() if record.created_at else "",
)
)
return approvals
def _pending_to_approval(
pending: PendingConfirmation, permission_policy: str, possible_impact: str | None
) -> PendingApproval:
return PendingApproval(
confirmation_id=pending.confirmation_id,
character_id=pending.character_id,
tool=pending.tool,
risk_level=pending.risk_level,
permission_policy=permission_policy,
action_summary=f"调用工具 {pending.tool}",
input_summary=_summarize_input(pending.input),
possible_impact=possible_impact,
reason=pending.reason,
created_at=pending.created_at,
)
async def get_audit_timeline(
*,
pivot: str | None = None,
+4 -123
View File
@@ -14,7 +14,6 @@ from app.models.governance import (
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.modules.runtime.events import Event
@@ -39,11 +38,7 @@ from app.schemas.admin import (
WorldEntityResponse,
)
from app.schemas.safety import (
CreateToolPolicyRequest,
SafetyPolicyItem,
ToolCallDetail,
ToolPolicyResponse,
UpdateToolPolicyRequest,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
@@ -51,8 +46,6 @@ from app.services.serializers import (
goal_to_response,
perception_channel_to_response,
plan_to_response,
tool_policy_to_response,
tool_policy_to_safety_item,
world_entity_to_response,
)
@@ -81,28 +74,17 @@ async def load_active_goals_context(character_id: str) -> str | None:
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
from app.modules.goal_orchestrator import prioritize_active_goals
from app.modules.runtime.goals import build_goals_context
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
goals_dicts = [
{
"title": g.title,
"level": g.level,
"status": g.status,
"priority": g.priority,
"progress": g.progress,
}
for g in goals
lines = [
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
for g in sorted(goals, key=lambda g: -(g.priority or 0))
]
# 优先级排序后注入运行时,使最高优先级目标最先被看到
sorted_goals = prioritize_active_goals(goals_dicts)
return build_goals_context(sorted_goals)
return "【当前目标】\n" + "\n".join(lines)
async def emit_goal_event(
@@ -520,107 +502,6 @@ async def delete_agent(role: str) -> None:
# —— 百工枢 · 工具策略(§15 / §16 共享)——
def _builtin_tool_names() -> set[str]:
"""builtin 工具名集合(惰性 import,避免 application→services 循环依赖)。"""
from app.application.tool_runtime.builtins import builtin_specs
return set(builtin_specs().keys())
async def list_tool_policies() -> list[ToolPolicyResponse]:
"""GET /tools:完整元数据,executable 按 builtin 派生。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
builtins = _builtin_tool_names()
return [tool_policy_to_response(p, executable=p.name in builtins) for p in policies]
async def list_safety_policies() -> list[SafetyPolicyItem]:
"""GET /safety/policies:策略子集。"""
async with AsyncSessionLocal() as session:
policies = await governance_repo.list_tool_policies(session)
return [tool_policy_to_safety_item(p) for p in policies]
async def create_tool_policy(payload: CreateToolPolicyRequest) -> ToolPolicyResponse:
"""POST /tools、POST /safety/policies:注册工具策略(source=custom)。"""
async with AsyncSessionLocal() as session:
if await governance_repo.get_tool_policy(session, payload.name) is not None:
raise AppError("invalid_request", f"工具 {payload.name} 已存在。")
policy = ToolPolicy(
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
permission_policy=payload.permission_policy,
side_effect=payload.side_effect,
audit_required=payload.audit_required,
source="custom",
enabled=payload.enabled,
)
await governance_repo.add_tool_policy(session, policy)
await record_audit_event(
session,
action="tool.policy_created",
actor="user",
risk_level=payload.risk_level,
input_summary=payload.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def update_tool_policy(name: str, payload: UpdateToolPolicyRequest) -> ToolPolicyResponse:
"""PATCH /tools/{name}、PATCH /safety/policies/{tool}:编辑策略元数据。"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
updates = payload.model_dump(exclude_unset=True)
if not updates:
raise AppError("invalid_request", "至少需要提供一个待更新字段。")
for key, value in updates.items():
setattr(policy, key, value)
policy.updated_at = utc_now()
await record_audit_event(
session,
action="tool.policy_updated",
actor="user",
risk_level=policy.risk_level,
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
await session.refresh(policy)
return tool_policy_to_response(policy, executable=policy.name in _builtin_tool_names())
async def delete_tool_policy(name: str) -> None:
"""DELETE /tools/{name}、DELETE /safety/policies/{tool}:注销策略。
builtin 工具的策略记录不可删除(执行实现仍在代码层);只允许注销 custom 工具。
"""
async with AsyncSessionLocal() as session:
policy = await governance_repo.get_tool_policy(session, name)
if policy is None:
raise AppError("not_found", f"工具 {name} 不存在。")
if policy.source == "builtin":
raise AppError("invalid_request", f"内置工具 {name} 不可删除,可改用 PATCH 禁用。")
await session.delete(policy)
await record_audit_event(
session,
action="tool.policy_deleted",
actor="user",
input_summary=policy.name,
resource_type="tool_policy",
resource_id=policy.name,
)
await session.commit()
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
+87 -105
View File
@@ -13,8 +13,9 @@ from __future__ import annotations
import json
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.application.tool_runtime.runtime import ToolRuntime
from sqlalchemy import select
from app.domains.baigong.models import Capability
from app.core.errors import AppError
from app.core.ids import new_mcp_server_id
from app.db.session import AsyncSessionLocal
@@ -38,7 +39,6 @@ async def register_mcp_server(
env_json: str | None = None,
cwd: str | None = None,
status: str = "enabled",
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
async with AsyncSessionLocal() as session:
@@ -65,9 +65,7 @@ async def register_mcp_server(
transport_instance = get_transport(server)
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
if process_manager is not None:
process_manager.register_transport(server.id, transport_instance)
@@ -79,11 +77,7 @@ async def register_mcp_server(
await transport_instance.start()
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(
server, tools, runtime.http_client, transport=transport_instance
):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
await mcp_repo.add_server(session, server)
await record_audit_event(
@@ -109,7 +103,6 @@ async def list_mcp_servers(*, status: str | None = None) -> list[McpServerRespon
async def delete_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> None:
async with AsyncSessionLocal() as session:
@@ -124,13 +117,7 @@ async def delete_mcp_server(
await transport.stop()
process_manager.unregister(server_id)
# unregister ToolSpec
if runtime is not None and server.tools_json:
tools = json.loads(server.tools_json) if server.tools_json else []
for tool in tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
await _drop_capabilities(session, server.name)
name = server.name
await session.delete(server)
@@ -148,7 +135,6 @@ async def delete_mcp_server(
async def start_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""启动 stdio MCP server 子进程,拉取工具清单并注册。"""
@@ -174,15 +160,11 @@ async def start_mcp_server(
server.tools_json = dumps(tools)
await session.commit()
# 注册 ToolSpec
if runtime is not None and runtime.http_client is not None:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
# MCP 工具派生为百工能力(博闻登记接入点)
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is not None:
await _sync_capabilities(session, server, tools)
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
@@ -191,7 +173,6 @@ async def start_mcp_server(
async def stop_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""停止 stdio MCP server 子进程并注销 ToolSpec。"""
@@ -207,16 +188,10 @@ async def stop_mcp_server(
await transport.stop()
# unregister ToolSpec
if runtime is not None:
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server and server.tools_json:
tools = json.loads(server.tools_json)
for tool in tools:
name = tool.get("name") if isinstance(tool, dict) else None
if name:
runtime.unregister(f"mcp_{server.name}_{name}")
async with AsyncSessionLocal() as session:
server = await mcp_repo.get_server(session, server_id)
if server is not None:
await _sync_capabilities(session, server, [])
status = await process_manager.status(server_id)
return McpServerStatusResponse(**status)
@@ -225,18 +200,16 @@ async def stop_mcp_server(
async def restart_mcp_server(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerStatusResponse:
"""重启 stdio MCP server。"""
await stop_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
return await start_mcp_server(server_id, runtime=runtime, process_manager=process_manager)
await stop_mcp_server(server_id, process_manager=process_manager)
return await start_mcp_server(server_id, process_manager=process_manager)
async def refresh_mcp_server_tools(
server_id: str,
*,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""重新拉取 MCP server 工具清单,更新缓存并注册到运行时。"""
@@ -245,13 +218,6 @@ async def refresh_mcp_server_tools(
if server is None:
raise AppError("not_found", "MCP server 不存在。")
# 注销旧工具
old_tools = json.loads(server.tools_json) if server.tools_json else []
if runtime is not None:
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 重新拉取工具清单(优先用 process_manager 中的传输实例,确保 stdio 进程已启动)
transport_instance = None
@@ -262,10 +228,7 @@ async def refresh_mcp_server_tools(
tools = await transport_instance.list_tools()
server.tools_json = dumps(tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, tools)
await record_audit_event(
session,
@@ -302,7 +265,6 @@ async def update_mcp_server(
args_json: str | None = None,
env_json: str | None = None,
cwd: str | None = None,
runtime: ToolRuntime | None = None,
process_manager: ProcessManager | None = None,
) -> McpServerResponse:
"""更新 MCP server 字段(部分更新,不传的字段保持不变)。
@@ -320,31 +282,22 @@ async def update_mcp_server(
if status is not None:
old_status = server.status
server.status = status
# 启用→禁用:注销运行时中的工具
if (
status == "disabled"
and old_status == "enabled"
and runtime is not None
and old_tools_json
):
for tool_name in _tool_names_from_json(old_tools_json):
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 禁用→启用(HTTP):重新拉取工具并注册
# 启用→禁用:撤下派生能力
if status == "disabled" and old_status == "enabled" and old_tools_json:
await _drop_capabilities(session, server.name)
# 禁用→启用(HTTP):重新拉取工具清单并派生
if (
status == "enabled"
and old_status == "disabled"
and server.transport == "streamable-http"
and server.endpoint_url
):
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, new_tools)
endpoint_changed = endpoint_url is not None and endpoint_url != server.endpoint_url
if endpoint_url is not None:
@@ -360,20 +313,11 @@ async def update_mcp_server(
if cwd is not None:
server.cwd = cwd
# streamable-http 且 endpoint 变化时:重新拉取工具清单并注册
# streamable-http 且 endpoint 变化时:重新拉取工具清单并派生能力
if endpoint_changed and server.transport == "streamable-http" and server.endpoint_url:
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.integrations.mcp.transports import get_transport
# 注销旧工具
if runtime is not None and old_tools_json:
old_tools = json.loads(old_tools_json) if old_tools_json else []
for tool in old_tools:
tool_name = tool.get("name") if isinstance(tool, dict) else None
if tool_name:
runtime.unregister(f"mcp_{server.name}_{tool_name}")
# 拉取新工具(优先用 process_manager 中的传输实例)
await _drop_capabilities(session, server.name)
transport_instance = None
if process_manager is not None:
transport_instance = process_manager.get(server_id)
@@ -381,11 +325,7 @@ async def update_mcp_server(
transport_instance = get_transport(server)
new_tools = await transport_instance.list_tools()
server.tools_json = dumps(new_tools)
# 注册新工具
if runtime is not None and runtime.http_client is not None:
for spec in make_mcp_tool_specs(server, new_tools, runtime.http_client):
runtime.register(spec)
await _sync_capabilities(session, server, new_tools)
await record_audit_event(
session,
@@ -419,29 +359,18 @@ async def get_mcp_server_status(
return McpServerStatusResponse(**status)
async def load_registered_mcp_servers(
runtime: ToolRuntime, process_manager: ProcessManager | None = None
) -> None:
"""启动加载:按 transport 类型注册 MCP server。"""
if runtime.http_client is None:
return
async def load_registered_mcp_servers(process_manager: ProcessManager | None = None) -> None:
"""启动加载:登记传输层实例(stdio 不自动启动);派生能力已在登记/刷新时落库。"""
async with AsyncSessionLocal() as session:
servers = await mcp_repo.list_servers(session, status="enabled")
for server in servers:
try:
if server.transport == "streamable-http":
tools = json.loads(server.tools_json) if server.tools_json else []
for spec in make_mcp_tool_specs(server, tools, runtime.http_client):
runtime.register(spec)
# 创建传输层实例但 no-op start
if process_manager is not None:
transport_instance = get_transport(server, runtime.http_client)
process_manager.register_transport(server.id, transport_instance)
elif server.transport == "stdio":
# stdio 不自动启动,只注册传输层实例到管理器
if process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
if server.transport == "streamable-http" and process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
elif server.transport == "stdio" and process_manager is not None:
transport_instance = get_transport(server)
process_manager.register_transport(server.id, transport_instance)
except Exception: # noqa: BLE001
pass
@@ -470,3 +399,56 @@ def _to_response(server: McpServer) -> McpServerResponse:
created_at=server.created_at,
updated_at=server.updated_at,
)
async def _sync_capabilities(session, server, tools: list) -> None:
"""MCP 工具清单同步为百工能力(executor_type=mcp,博闻等外部能力统一入目录)。"""
import json as _json
names = set()
for tool in tools or []:
name = tool.get("name") if isinstance(tool, dict) else None
if not name:
continue
names.add(name)
cap_name = f"mcp_{server.name}_{name}"
existing = await session.execute(
select(Capability).where(
Capability.owner_scope == "local-user", Capability.name == cap_name
)
)
cap = existing.scalars().first()
if cap is None:
session.add(
Capability(
name=cap_name,
owner_scope="local-user",
description=(tool.get("description") or "")[:500],
executor_type="mcp",
executor_ref=server.name,
risk_dims_json=_json.dumps(
{"capability": "L1", "data": "L2", "consequence": "L1", "context": "L1"}
),
data_scope="MCP 工具参数与返回",
destinations=f"mcp:{server.name}",
source="user_custom",
)
)
await _drop_capabilities(session, server.name, keep=names)
async def _drop_capabilities(session, server_name: str, *, keep: set | None = None) -> None:
"""撤下该 MCP server 不再提供的派生能力。"""
prefix = f"mcp_{server_name}_"
result = await session.execute(
select(Capability).where(
Capability.owner_scope == "local-user",
Capability.executor_type == "mcp",
Capability.name.startswith(prefix), # type: ignore[union-attr]
)
)
for cap in result.scalars().all():
tool_name = cap.name[len(prefix) :]
if keep is not None and tool_name in keep:
continue
await session.delete(cap)
-105
View File
@@ -1,105 +0,0 @@
"""安全服务:审计裁定、对话安全决策与司命策略加载。"""
from __future__ import annotations
from pathlib import Path
import yaml
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.modules.safety.governor import SafetyGovernor
from app.services.audit import record_audit_event
async def allow_action(
session: AsyncSession,
*,
action: str,
character_id: str | None = None,
run_id: str | None = None,
reason: str = "当前阶段低风险动作默认允许。",
) -> None:
await record_audit_event(
session,
action="safety.decision",
character_id=character_id,
run_id=run_id,
actor="system",
risk_level="L1",
decision="auto",
reason=reason,
input_summary=action,
)
class ChatSafetyDecision(BaseModel):
"""对话安全决策结果(当前阶段默认低风险放行 + 基础安全约束)。"""
decision: str = "allow"
risk_level: str = "L1"
constraints: list[str] = Field(
default_factory=lambda: ["不得泄露 API Key、Authorization header 或内部系统配置。"]
)
async def decide_chat_safety(
session: AsyncSession,
*,
character_id: str,
run_id: str,
) -> ChatSafetyDecision:
"""对话消息的安全决策:当前周期记录审计后默认放行。"""
await allow_action(
session,
action="chat.message.received",
character_id=character_id,
run_id=run_id,
)
return ChatSafetyDecision()
def load_safety_governor(path: str | None = None) -> SafetyGovernor:
"""从 YAML 加载司命工具策略;未配置或文件不存在时返回默认(空策略)Governor。
配置路径取自 ``path`` 参数或 ``settings.safety_policy_path``(即
``SAFETY_POLICY_PATH`` 环境变量,示例见 ``config/safety.example.yaml``)。
文件解析后交给 ``SafetyGovernor.from_policy_dict``
(纯逻辑),本函数只承担文件 IO,governor 本身保持零 IO。
注:运行时主策略源为 tool_policies 表(见 load_governor_from_db / ToolRuntime.reload_governor);
本函数保留为可选的 YAML 覆盖入口与遗留兼容。
"""
config_path = path or settings.safety_policy_path
data: dict = {}
if config_path:
config_file = Path(config_path)
if config_file.exists():
with config_file.open(encoding="utf-8") as file:
loaded = yaml.safe_load(file) or {}
if isinstance(loaded, dict):
data = loaded
return SafetyGovernor.from_policy_dict(data)
async def load_governor_from_db() -> SafetyGovernor:
"""从 tool_policies 表加载司命策略(启动期主源)。
读取所有 enabled 策略,构造覆盖层 Governor;运行时写操作后改走
``ToolRuntime.reload_governor`` 原地刷新,避免重建 ToolRuntime。
"""
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
}
}
return SafetyGovernor.from_policy_dict(data)
-30
View File
@@ -12,7 +12,6 @@ from app.models.governance import (
Goal,
PerceptionChannel,
Plan,
ToolPolicy,
WorldEntity,
)
from app.models.imports import CharacterImportJob, ImportItem
@@ -35,7 +34,6 @@ from app.schemas.imports import ImportItemResponse, ImportJobResponse
from app.schemas.personality import PersonalityProposalResponse, PersonalityResponse
from app.schemas.preferences import GenerationPreferencesResponse
from app.schemas.runtime import EventResponse, RunResponse
from app.schemas.safety import SafetyPolicyItem, ToolPolicyResponse
from app.schemas.world import (
WorldEntityResponse as CharacterWorldEntityResponse,
)
@@ -301,34 +299,6 @@ def audit_to_response(event: AuditEvent) -> AuditEntry:
)
def tool_policy_to_response(policy: ToolPolicy, *, executable: bool = False) -> ToolPolicyResponse:
"""工具策略完整元数据;executable 由调用方按 builtin/registered specs 派生。"""
return ToolPolicyResponse(
name=policy.name,
description=policy.description,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
source=policy.source,
enabled=policy.enabled,
executable=executable,
created_at=to_iso(policy.created_at),
updated_at=to_iso(policy.updated_at),
)
def tool_policy_to_safety_item(policy: ToolPolicy) -> SafetyPolicyItem:
"""策略子集(§16 GET /safety/policies 元素)。"""
return SafetyPolicyItem(
tool=policy.name,
risk_level=policy.risk_level,
permission_policy=policy.permission_policy,
side_effect=policy.side_effect,
audit_required=policy.audit_required,
)
def import_job_to_response(job: CharacterImportJob) -> ImportJobResponse:
return ImportJobResponse(
id=job.id,
-180
View File
@@ -1,180 +0,0 @@
"""Skill 能力包服务(CRUD + 关键词触发匹配)。
CRUD 记审计;``match_active_skills`` 加载 enabled skills 调 matcher,返回 Skill ORM
列表(供 send_message 取 prompt_fragment 注入 contract)。
"""
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.skills import Skill
from app.modules.skills.matcher import match_skills
from app.repositories import skills as skills_repo
from app.repositories.json_utils import dumps, loads_list
from app.schemas.skills import SkillResponse, ToolCapability
from app.services.audit import record_audit_event
async def create_skill(
*,
name: str,
description: str = "",
trigger: str = "",
prompt_fragment: str = "",
tool_bindings: list[str] | None = None,
risk_level: str = "L1",
status: str = "enabled",
) -> SkillResponse:
async with AsyncSessionLocal() as session:
existing = await skills_repo.get_skill_by_name(session, name)
if existing is not None:
raise AppError("conflict", f"Skill 名「{name}」已存在。")
skill = Skill(
name=name,
description=description,
trigger=trigger,
prompt_fragment=prompt_fragment,
tool_bindings_json=dumps(tool_bindings or []),
risk_level=risk_level,
status=status,
)
await skills_repo.add_skill(session, skill)
await record_audit_event(
session,
action="skill.created",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def get_skill(skill_id: str) -> SkillResponse | None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
return _to_response(skill) if skill else None
async def list_skills(*, status: str | None = None) -> list[SkillResponse]:
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status=status)
return [_to_response(s) for s in skills]
async def list_all_capabilities(runtime) -> list[ToolCapability]:
"""统一列举 builtin + mcp(runtime.list_specs)+ skill(DB),含来源标记。"""
capabilities: list[ToolCapability] = []
if runtime is not None:
for spec in runtime.list_specs():
source = "mcp" if spec.name.startswith("mcp:") else "builtin"
capabilities.append(
ToolCapability(
name=spec.name,
description=spec.description,
source=source,
risk_level=spec.risk_level,
status="enabled",
)
)
for s in await list_skills(status="enabled"):
capabilities.append(
ToolCapability(
name=s.name,
description=s.description,
source="skill",
risk_level=s.risk_level,
status=s.status,
)
)
return capabilities
async def update_skill(
skill_id: str,
*,
description: str | None = None,
trigger: str | None = None,
prompt_fragment: str | None = None,
tool_bindings: list[str] | None = None,
risk_level: str | None = None,
status: str | None = None,
) -> SkillResponse:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
if description is not None:
skill.description = description
if trigger is not None:
skill.trigger = trigger
if prompt_fragment is not None:
skill.prompt_fragment = prompt_fragment
if tool_bindings is not None:
skill.tool_bindings_json = dumps(tool_bindings)
if risk_level is not None:
skill.risk_level = risk_level
if status is not None:
skill.status = status
await record_audit_event(
session,
action="skill.updated",
actor="user",
input_summary=skill.name[:200],
resource_type="skill",
resource_id=skill.id,
)
await session.commit()
await session.refresh(skill)
return _to_response(skill)
async def delete_skill(skill_id: str) -> None:
async with AsyncSessionLocal() as session:
skill = await skills_repo.get_skill(session, skill_id)
if skill is None:
raise AppError("not_found", "Skill 不存在。")
name = skill.name
await session.delete(skill)
await record_audit_event(
session,
action="skill.deleted",
actor="user",
input_summary=name[:200],
resource_type="skill",
resource_id=skill_id,
)
await session.commit()
async def match_active_skills(context_text: str) -> list[Skill]:
"""加载 enabled skills,关键词匹配;返回命中的 Skill ORM(供注入 prompt_fragment)。"""
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return match_skills(context_text, skills)
async def get_enabled_skills_by_names(names: tuple[str, ...]) -> list[Skill]:
"""按名称返回 enabled Skills,供 Agent 显式引用。"""
wanted = set(names)
if not wanted:
return []
async with AsyncSessionLocal() as session:
skills = await skills_repo.list_skills(session, status="enabled")
return [skill for skill in skills if skill.name in wanted]
def _to_response(skill: Skill) -> SkillResponse:
return SkillResponse(
id=skill.id,
name=skill.name,
description=skill.description,
trigger=skill.trigger,
prompt_fragment=skill.prompt_fragment,
tool_bindings=loads_list(skill.tool_bindings_json),
risk_level=skill.risk_level,
status=skill.status,
created_at=skill.created_at,
updated_at=skill.updated_at,
)