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

@@ -0,0 +1,153 @@
"""百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表
Revision ID: 006
Revises: 005
Create Date: 2026-08-27 18:52:47.720303
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '006'
down_revision: Union[str, Sequence[str], None] = '005'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('capabilities',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('name', sa.String(length=120), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('description', sa.Text(), nullable=False),
sa.Column('executor_type', sa.String(length=20), nullable=False),
sa.Column('executor_ref', sa.String(length=200), nullable=False),
sa.Column('params_schema_json', sa.Text(), nullable=False),
sa.Column('risk_dims_json', sa.Text(), nullable=False),
sa.Column('data_scope', sa.Text(), nullable=False),
sa.Column('destinations', sa.Text(), nullable=False),
sa.Column('source', sa.String(length=20), nullable=False),
sa.Column('version', sa.String(length=40), nullable=False),
sa.Column('consent_mode', sa.String(length=10), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_capabilities_owner_scope'), 'capabilities', ['owner_scope'], unique=False)
op.create_index('uq_capabilities_owner_name', 'capabilities', ['owner_scope', 'name'], unique=True)
op.create_table('mandates',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('owner_scope', sa.String(length=80), nullable=False),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('capability_id', sa.String(length=80), nullable=False),
sa.Column('kind', sa.String(length=10), nullable=False),
sa.Column('scope_json', sa.JSON(), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('budget_total', sa.Integer(), nullable=False),
sa.Column('budget_used', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('granted_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['capability_id'], ['capabilities.id'], ),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_mandates_capability_id'), 'mandates', ['capability_id'], unique=False)
op.create_index(op.f('ix_mandates_character_id'), 'mandates', ['character_id'], unique=False)
op.create_index(op.f('ix_mandates_owner_scope'), 'mandates', ['owner_scope'], unique=False)
op.create_index(op.f('ix_mandates_status'), 'mandates', ['status'], unique=False)
op.create_table('tool_calls',
sa.Column('id', sa.String(length=80), nullable=False),
sa.Column('run_id', sa.String(length=80), nullable=True),
sa.Column('character_id', sa.String(length=80), nullable=True),
sa.Column('capability_id', sa.String(length=80), nullable=False),
sa.Column('trace_id', sa.String(length=80), nullable=True),
sa.Column('decision', sa.String(length=40), nullable=False),
sa.Column('mandate_id', sa.String(length=80), nullable=True),
sa.Column('status', sa.String(length=20), nullable=False),
sa.Column('error_code', sa.String(length=40), nullable=True),
sa.Column('input_digest', sa.Text(), nullable=False),
sa.Column('output_digest', sa.Text(), nullable=False),
sa.Column('evidence_json', sa.JSON(), nullable=False),
sa.Column('duration_ms', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
sa.ForeignKeyConstraint(['run_id'], ['runs.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tool_calls_capability_id'), 'tool_calls', ['capability_id'], unique=False)
op.create_index(op.f('ix_tool_calls_character_id'), 'tool_calls', ['character_id'], unique=False)
op.create_index(op.f('ix_tool_calls_created_at'), 'tool_calls', ['created_at'], unique=False)
op.create_index(op.f('ix_tool_calls_run_id'), 'tool_calls', ['run_id'], unique=False)
op.create_index(op.f('ix_tool_calls_trace_id'), 'tool_calls', ['trace_id'], unique=False)
op.drop_index(op.f('ix_skills_status'), table_name='skills')
op.drop_table('skills')
op.drop_table('tool_policies')
op.drop_table('model_endpoint_overrides')
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('model_endpoint_overrides',
sa.Column('logical_model_name', sa.VARCHAR(length=120), nullable=False),
sa.Column('endpoint_name', sa.VARCHAR(length=120), nullable=False),
sa.Column('base_url', sa.VARCHAR(length=500), nullable=True),
sa.Column('api_key', sa.VARCHAR(length=500), nullable=True),
sa.Column('status', sa.VARCHAR(length=40), nullable=True),
sa.Column('weight', sa.INTEGER(), nullable=True),
sa.Column('priority', sa.INTEGER(), nullable=True),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('logical_model_name', 'endpoint_name')
)
op.create_table('tool_policies',
sa.Column('name', sa.VARCHAR(length=80), nullable=False),
sa.Column('description', sa.TEXT(), nullable=False),
sa.Column('risk_level', sa.VARCHAR(length=20), nullable=False),
sa.Column('permission_policy', sa.VARCHAR(length=20), nullable=False),
sa.Column('side_effect', sa.BOOLEAN(), nullable=False),
sa.Column('audit_required', sa.BOOLEAN(), nullable=False),
sa.Column('source', sa.VARCHAR(length=20), nullable=False),
sa.Column('enabled', sa.BOOLEAN(), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('name')
)
op.create_table('skills',
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
sa.Column('name', sa.VARCHAR(length=120), nullable=False),
sa.Column('description', sa.TEXT(), nullable=False),
sa.Column('trigger', sa.TEXT(), nullable=False),
sa.Column('prompt_fragment', sa.TEXT(), nullable=False),
sa.Column('tool_bindings_json', sa.TEXT(), nullable=False),
sa.Column('risk_level', sa.VARCHAR(length=8), nullable=False),
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
sa.Column('created_at', sa.DATETIME(), nullable=False),
sa.Column('updated_at', sa.DATETIME(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_index(op.f('ix_skills_status'), 'skills', ['status'], unique=False)
op.drop_index(op.f('ix_tool_calls_trace_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_run_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_created_at'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_character_id'), table_name='tool_calls')
op.drop_index(op.f('ix_tool_calls_capability_id'), table_name='tool_calls')
op.drop_table('tool_calls')
op.drop_index(op.f('ix_mandates_status'), table_name='mandates')
op.drop_index(op.f('ix_mandates_owner_scope'), table_name='mandates')
op.drop_index(op.f('ix_mandates_character_id'), table_name='mandates')
op.drop_index(op.f('ix_mandates_capability_id'), table_name='mandates')
op.drop_table('mandates')
op.drop_index('uq_capabilities_owner_name', table_name='capabilities')
op.drop_index(op.f('ix_capabilities_owner_scope'), table_name='capabilities')
op.drop_table('capabilities')
# ### end Alembic commands ###
+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,
)
@@ -1,76 +0,0 @@
"""Skill 能力包 API 测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_create_and_list_skills(client: AsyncClient) -> None:
resp = await client.post(
"/api/v1/tools/skills",
json={
"name": "coding",
"trigger": "代码,编程",
"prompt_fragment": "你是代码助手",
"tool_bindings": ["read_file"],
},
)
assert resp.status_code == 201
assert resp.json()["name"] == "coding"
assert resp.json()["tool_bindings"] == ["read_file"]
listed = await client.get("/api/v1/tools/skills")
assert listed.status_code == 200
assert listed.json()[0]["name"] == "coding"
@pytest.mark.asyncio
async def test_update_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.patch(f"/api/v1/tools/skills/{sid}", json={"status": "disabled"})
assert resp.status_code == 200
assert resp.json()["status"] == "disabled"
@pytest.mark.asyncio
async def test_delete_skill(client: AsyncClient) -> None:
create = await client.post("/api/v1/tools/skills", json={"name": "s", "trigger": "代码"})
sid = create.json()["id"]
resp = await client.delete(f"/api/v1/tools/skills/{sid}")
assert resp.status_code == 204
listed = await client.get("/api/v1/tools/skills")
assert listed.json() == []
@pytest.mark.asyncio
async def test_create_duplicate_conflict(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "dup"})
resp = await client.post("/api/v1/tools/skills", json={"name": "dup"})
assert resp.status_code == 409
assert resp.json()["error"]["code"] == "conflict"
@pytest.mark.asyncio
async def test_list_skills_status_filter(client: AsyncClient) -> None:
await client.post("/api/v1/tools/skills", json={"name": "a", "status": "enabled"})
await client.post("/api/v1/tools/skills", json={"name": "b", "status": "disabled"})
resp = await client.get("/api/v1/tools/skills", params={"status": "enabled"})
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "a"
-150
View File
@@ -1,150 +0,0 @@
"""工具与确认 API 测试:TestClient + respx 模拟沙箱。"""
from __future__ import annotations
from collections.abc import Iterator
import httpx
import pytest
import respx
from fastapi.testclient import TestClient
from app.api.v1.deps import get_confirmation_store, get_tool_runtime
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.main import app
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.confirmation import InMemoryConfirmationStore
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
def _settings() -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ok() -> httpx.Response:
return httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 3,
"timeout": False,
"truncated": False,
"trace_id": "t1",
},
)
@pytest.fixture
def runtime_bundle() -> Iterator[None]:
"""注入 mock ToolRuntime / 确认存储到 app 依赖覆盖;返回值无意义。"""
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
runtime = ToolRuntime(sandbox, SafetyGovernor(), store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
yield
finally:
app.dependency_overrides.clear()
@respx.mock
def test_list_tools(runtime_bundle: None) -> None:
_ = runtime_bundle # fixture 仅用于副作用(注入依赖覆盖)
with TestClient(app) as client:
resp = client.get("/api/v1/tools")
assert resp.status_code == 200
names = {tool["name"] for tool in resp.json()}
assert {"datetime", "shell", "fetch_url"} <= names
@respx.mock
def test_invoke_datetime_completed(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/datetime/invoke",
json={"input": {}, "trace_id": "t1"},
)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
assert body["result"]["stdout"] == "42\n"
@respx.mock
def test_shell_invoke_then_approve(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "trace_id": "t1", "run_id": "run_tools_test"},
)
assert resp.status_code == 200
ask = resp.json()
assert ask["status"] == "requires_confirmation"
assert ask["risk_level"] == "L3"
confirmation_id = ask["confirmation_id"]
approved = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert approved.status_code == 200
assert approved.json()["status"] == "resolved"
@respx.mock
def test_deny_then_approve_returns_404(runtime_bundle: None) -> None:
_ = runtime_bundle
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke",
json={"input": {"command": "echo hi"}, "run_id": "run_tools_test2"},
)
confirmation_id = resp.json()["confirmation_id"]
denied = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/deny")
assert denied.status_code == 200
assert denied.json()["status"] in ("cancelled", "resolved")
again = client.post(f"/api/v1/safety/confirmations/{confirmation_id}/approve")
assert again.status_code == 404
assert again.json()["error"]["code"] == "not_found"
@respx.mock
def test_forbidden_tool_returns_403() -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok())
store = InMemoryEventStore()
confirmations = InMemoryConfirmationStore()
http = httpx.AsyncClient()
sandbox = SandboxClient(http, _settings())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
runtime = ToolRuntime(sandbox, governor, store)
app.dependency_overrides[get_tool_runtime] = lambda: runtime
app.dependency_overrides[get_confirmation_store] = lambda: confirmations
try:
with TestClient(app) as client:
resp = client.post(
"/api/v1/tools/shell/invoke", json={"input": {"command": "rm -rf /"}}
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "permission_denied"
finally:
app.dependency_overrides.clear()
@@ -1,36 +0,0 @@
"""GET /tools/capabilities 统一能力列举测试。"""
from collections.abc import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
from app.services import skills as skills_service
@pytest.fixture
async def client() -> AsyncGenerator[AsyncClient, None]:
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
yield http_client
@pytest.mark.asyncio
async def test_list_capabilities_includes_skills(client: AsyncClient) -> None:
await skills_service.create_skill(name="coding", trigger="代码", prompt_fragment="代码助手")
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
data = resp.json()
# runtime 在测试环境可能未就绪(ASGITransport 不跑 lifespan),至少含 skill
skill_caps = [c for c in data if c["source"] == "skill"]
assert any(c["name"] == "coding" for c in skill_caps)
@pytest.mark.asyncio
async def test_list_capabilities_source_values(client: AsyncClient) -> None:
resp = await client.get("/api/v1/tools/capabilities")
assert resp.status_code == 200
for c in resp.json():
assert c["source"] in {"builtin", "mcp", "skill"}
assert "name" in c and "risk_level" in c
@@ -1,86 +0,0 @@
"""内置工具规格测试:覆盖风险分级、env 传参与必填校验。"""
from __future__ import annotations
import pytest
from app.application.tool_runtime.builtins import builtin_specs
from app.modules.safety.governor import PermissionPolicy
def test_builtin_specs_cover_risk_levels() -> None:
specs = builtin_specs()
assert {"L0", "L1", "L2", "L3"} <= {s.risk_level for s in specs.values()}
assert {
"datetime",
"read_file",
"list_dir",
"write_file",
"shell",
"fetch_url",
} == set(specs)
def test_write_file_is_ask_and_side_effect() -> None:
spec = builtin_specs()["write_file"]
assert spec.permission_policy is PermissionPolicy.ask
assert spec.side_effect is True
def test_read_tools_allow_and_no_side_effect() -> None:
specs = builtin_specs()
for name in ["datetime", "read_file", "list_dir"]:
assert specs[name].permission_policy is PermissionPolicy.allow
assert specs[name].side_effect is False
def test_file_tools_use_workspace() -> None:
req = builtin_specs()["read_file"].build_request({"path": "a.txt"})
assert req.workdir == "workspace"
assert req.env.get("PATH_ARG") == "a.txt"
def test_read_file_pagination_builds_sed() -> None:
"""read_file 传 offset/limit 时用 sed 行分页,缺省时 cat 全文。"""
specs = builtin_specs()
full = specs["read_file"].build_request({"path": "a.txt"})
assert full.argv[2] == 'cat -- "$PATH_ARG"'
paged = specs["read_file"].build_request({"path": "a.txt", "offset": 10, "limit": 5})
assert paged.argv[2] == "sed -n '10,14p' \"$PATH_ARG\""
assert paged.env.get("PATH_ARG") == "a.txt"
assert paged.workdir == "workspace"
tail = specs["read_file"].build_request({"path": "a.txt", "offset": 100})
assert tail.argv[2] == "sed -n '100,$p' \"$PATH_ARG\""
def test_read_file_pagination_ignores_bad_input() -> None:
"""offset/limit 非正整数时回退全文 cat,不误构造非法 sed 地址。"""
spec = builtin_specs()["read_file"]
assert (
spec.build_request({"path": "a", "offset": "x", "limit": -3}).argv[2]
== 'cat -- "$PATH_ARG"'
)
assert spec.build_request({"path": "a", "offset": 0}).argv[2] == 'cat -- "$PATH_ARG"'
def test_required_inputs_validated() -> None:
specs = builtin_specs()
with pytest.raises(ValueError):
specs["read_file"].build_request({})
with pytest.raises(ValueError):
specs["write_file"].build_request({"content": "x"})
with pytest.raises(ValueError):
specs["shell"].build_request({})
def test_fetch_url_uses_executor_not_sandbox() -> None:
"""fetch_url 使用 executor 直接执行,不走沙箱。"""
specs = builtin_specs()
spec = specs["fetch_url"]
assert spec.executor is not None
assert spec.permission_policy is PermissionPolicy.ask
assert spec.side_effect is True
assert spec.audit_required is True
@@ -1,74 +0,0 @@
"""MCP 工具转 ToolSpec 测试。"""
import pytest
from app.application.tool_runtime import mcp as mcp_module
from app.application.tool_runtime.mcp import make_mcp_tool_specs
from app.modules.safety.governor import PermissionPolicy
from app.modules.tool_runtime.definition import ToolResult
class _Server:
def __init__(self, name, endpoint_url, auth_token=None):
self.name = name
self.endpoint_url = endpoint_url
self.auth_token = auth_token
def test_make_mcp_tool_specs_basic() -> None:
tools = [{"name": "weather", "description": "查天气", "risk_level": "L1"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert len(specs) == 1
s = specs[0]
assert s.name == "mcp_srv_weather"
assert s.description == "查天气"
assert s.risk_level == "L1"
assert s.permission_policy == PermissionPolicy.allow
def test_make_mcp_tool_specs_default_risk_l2() -> None:
tools = [{"name": "t"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert specs[0].risk_level == "L2"
assert specs[0].permission_policy == PermissionPolicy.ask
def test_make_mcp_tool_specs_skips_unnamed() -> None:
tools = [{"name": "t"}, {"description": "无名"}]
specs = make_mcp_tool_specs(_Server("srv", "http://mcp.local"), tools, http_client=None)
assert len(specs) == 1
@pytest.mark.asyncio
async def test_mcp_executor_calls_call_tool(monkeypatch: pytest.MonkeyPatch) -> None:
captured: dict = {}
async def fake_call_tool(
_http_client, endpoint_url, tool_name, input, auth_token=None, **_kwargs
):
captured.update(tool=tool_name, input=input, endpoint=endpoint_url, auth=auth_token)
return ToolResult(
ok=True,
exit_code=0,
stdout="ok",
stderr="",
duration_ms=1,
timeout=False,
truncated=False,
trace_id="",
)
monkeypatch.setattr(mcp_module, "call_tool", fake_call_tool)
tools = [{"name": "t"}]
specs = make_mcp_tool_specs(
_Server("srv", "http://mcp.local", auth_token="tok"), tools, http_client="HTTP"
)
result = await specs[0].executor({"x": 1}, None)
assert result.ok is True
assert captured == {
"tool": "t",
"input": {"x": 1},
"endpoint": "http://mcp.local",
"auth": "tok",
}
@@ -1,382 +0,0 @@
"""Tool Runtime 编排闭环测试:司命评估 → 沙箱执行 → 一炁事件回写。
沙箱用 respx 模拟,事件存储用 InMemoryEventStore。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from app.application.tool_runtime.runtime import ToolRuntime
from app.core.config import SandboxSettings
from app.integrations.sandbox.client import SandboxClient
from app.modules.runtime.events import InMemoryEventStore
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
from app.modules.safety.governor import PermissionPolicy, SafetyGovernor, ToolPolicy
def _settings() -> SandboxSettings:
return SandboxSettings(
rust_sandbox_url="http://sandbox.test",
sandbox_auth_enabled=True,
sandbox_auth_secret="shared-secret",
sandbox_default_timeout_ms=30000,
sandbox_auth_ttl_seconds=30,
)
def _ok_response() -> httpx.Response:
return httpx.Response(
200,
json={
"ok": True,
"exit_code": 0,
"stdout": "42\n",
"stderr": "",
"duration_ms": 3,
"timeout": False,
"truncated": False,
"trace_id": "t1",
},
)
@pytest.fixture
async def sandbox_client() -> AsyncIterator[SandboxClient]:
async with httpx.AsyncClient() as http:
yield SandboxClient(http, _settings())
@respx.mock
async def test_allow_executes_and_records_started_completed(
sandbox_client: SandboxClient,
) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("datetime", {}, run_id="run_1", trace_id="t1")
assert result.ok
assert result.stdout == "42\n"
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
async def test_cancelled_sandbox_execute_requests_remote_cancel() -> None:
sandbox = MagicMock()
started = asyncio.Event()
async def exec_forever(*_args, **_kwargs):
started.set()
await asyncio.Event().wait()
sandbox.exec = AsyncMock(side_effect=exec_forever)
sandbox.cancel = AsyncMock(return_value=True)
runtime = ToolRuntime(sandbox, SafetyGovernor(), InMemoryEventStore())
task = asyncio.create_task(runtime.execute("datetime", {}, trace_id="trace_cancel"))
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
sandbox.cancel.assert_awaited_once()
assert sandbox.cancel.await_args.args[0] == "trace_cancel"
@respx.mock
async def test_high_risk_requires_confirmation(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(RequiresConfirmation):
await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1")
# 未确认不应进入执行,故无 started 事件
assert all(e.type != "tool.started" for e in store.all())
@respx.mock
async def test_confirmed_high_risk_executes(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
result = await runtime.execute("shell", {"command": "echo hi"}, run_id="run_1", confirmed=True)
assert result.ok
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
@respx.mock
async def test_forbidden_records_failed_event(sandbox_client: SandboxClient) -> None:
respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
governor = SafetyGovernor(
{"shell": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, governor, store)
with pytest.raises(PermissionDenied):
await runtime.execute("shell", {"command": "x"}, run_id="run_1", trace_id="t1")
failed = [e for e in store.all() if e.type == "tool.failed"]
assert len(failed) == 1
assert failed[0].payload["error_code"] == "permission_denied"
assert failed[0].trace_id == "t1"
async def test_unknown_tool_denied(sandbox_client: SandboxClient) -> None:
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
with pytest.raises(PermissionDenied):
await runtime.execute("nope", {}, run_id="run_1")
assert any(e.type == "tool.failed" for e in store.all())
@respx.mock
async def test_file_tool_isolates_workdir_by_character(
sandbox_client: SandboxClient,
) -> None:
"""文件类工具的 workdir 应按角色隔离,同一角色跨 run 共享 workspace。"""
route = respx.post("http://sandbox.test/v1/exec").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
await runtime.execute(
"write_file",
{"path": "a.txt", "content": "x"},
character_id="char_01HABC",
run_id="run_42",
confirmed=True,
)
body = json.loads(route.calls.last.request.content)
# workdir 按 character_id 隔离(而非 run_id),保证同一角色跨 run 文件互通
assert body["workdir"] == "workspace/char_01HABC"
async def test_isolate_workdir_sanitizes_and_falls_back(
sandbox_client: SandboxClient,
) -> None:
"""隔离键经 sanitize 杜绝路径逃逸;缺主体/run/trace 时逐级回退。"""
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 路径分隔符与 .. 被 sanitize 清除,无法逃逸沙箱根
traversed = runtime._isolate_workdir(
"workspace", subject_id="../../etc", run_id=None, trace_id=None
)
key = traversed.removeprefix("workspace/")
assert key == "etc"
assert "/" not in key and ".." not in key
# 正常 subject_id(character_id)原样保留
assert (
runtime._isolate_workdir("workspace", subject_id="char_01HABC", run_id=None, trace_id=None)
== "workspace/char_01HABC"
)
# 缺 subject_id 时回退 run_id
assert (
runtime._isolate_workdir("workspace", subject_id=None, run_id="run_01HABC", trace_id=None)
== "workspace/run_01HABC"
)
# 缺 subject_id/run_id/trace 时回退 adhoc 前缀
assert runtime._isolate_workdir(
"workspace", subject_id=None, run_id=None, trace_id=None
).startswith("workspace/adhoc_")
@respx.mock
async def test_execute_stream_forwards_stdout_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""流式执行:转发 stdout/done,started/completed 落一炁,stdout 增量不落库。"""
sse = (
b"event: stdout\n"
b"data: aGVsbG8K\n"
b"\n"
b"event: done\n"
b'data: {"exit_code":0,"duration_ms":3,"timeout":false,"cancelled":false,"truncated":false}\n'
b"\n"
)
respx.post("http://sandbox.test/v1/exec/stream").mock(
return_value=httpx.Response(200, content=sse)
)
store = InMemoryEventStore()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), store)
events = [ev async for ev in runtime.execute_stream("datetime", {}, run_id="run_1")]
assert [e.kind for e in events] == ["stdout", "done"]
assert events[-1].payload["exit_code"] == 0
assert [e.type for e in store.all()] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_1" for e in store.all())
@respx.mock
async def test_execute_stream_requires_confirmation_for_high_risk(
sandbox_client: SandboxClient,
) -> None:
"""流式调用未确认的高风险工具:抛 RequiresConfirmation(端点据此发 requires_confirmation 事件)。"""
respx.post("http://sandbox.test/v1/exec/stream").mock(return_value=_ok_response())
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
with pytest.raises(RequiresConfirmation):
async for _ in runtime.execute_stream("shell", {"command": "echo hi"}):
pass
# --------------------------------------------------------------------------- #
# executor 工具集成测试(fetch_url 走直接执行路径)
# --------------------------------------------------------------------------- #
@respx.mock
async def test_executor_tool_runs_directly_and_records_events(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具(fetch_url)走直接执行路径,跳过沙箱,started/completed 落库。"""
respx.get("https://example.test").mock(
return_value=httpx.Response(200, text="<html><body>Hello</body></html>")
)
async with httpx.AsyncClient() as tools_http:
store = InMemoryEventStore()
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
store,
http_client=tools_http,
)
# fetch_url permission=ask,confirmed=True 绕过确认。
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
run_id="run_fu",
trace_id="t_fu",
confirmed=True,
)
assert result.ok
assert "Hello" in result.stdout
assert result.trace_id == "t_fu"
# 沙箱未被调用(直接执行路径)
events = store.all()
assert [e.type for e in events] == ["tool.started", "tool.completed"]
assert all(e.run_id == "run_fu" for e in events)
@respx.mock
async def test_executor_tool_requires_confirmation_when_ask(
sandbox_client: SandboxClient,
) -> None:
"""executor 工具的 permission_policy=ask 同样触发 RequiresConfirmation。"""
async with httpx.AsyncClient() as tools_http:
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
http_client=tools_http,
)
with pytest.raises(RequiresConfirmation):
await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
)
async def test_executor_tool_without_http_client_returns_error(
sandbox_client: SandboxClient,
) -> None:
"""未注入 http_client 时 executor 返回 ok=False(由 executor 自行兜底)。"""
runtime = ToolRuntime(
sandbox_client,
SafetyGovernor(),
InMemoryEventStore(),
# 故意不传 http_client
)
result = await runtime.execute(
"fetch_url",
{"url": "https://example.test"},
confirmed=True,
)
assert not result.ok
assert "http_client" in result.stderr
# --------------------------------------------------------------------------- #
# 热刷新:builtin_specs() 的变更对已存在的 ToolRuntime 实例立即生效,无需重启
# --------------------------------------------------------------------------- #
def test_builtin_changes_reflected_in_list_specs_without_reinit(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""删除/修改 builtin 工具后,同一个 ToolRuntime 实例的 list_specs 立即反映变化。"""
from app.application.tool_runtime import builtins as builtins_module
original = builtins_module.builtin_specs()
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
# 基线:包含全部 builtin 工具
baseline_names = {s.name for s in runtime.list_specs()}
assert "datetime" in baseline_names
assert "shell" in baseline_names
# 模拟 builtin_specs 删除了 shell(例如配置关闭或被下线)
pruned = {k: v for k, v in original.items() if k != "shell"}
def _pruned_specs() -> dict:
return dict(pruned)
monkeypatch.setattr(builtins_module, "builtin_specs", _pruned_specs)
after_names = {s.name for s in runtime.list_specs()}
assert "datetime" in after_names
assert "shell" not in after_names # 删除立即生效,未重新构造 ToolRuntime
def test_register_overrides_builtin_and_survives_builtin_change(
sandbox_client: SandboxClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""register 的工具覆盖 builtin 同名;builtin 变更不影响已注册的覆盖层。"""
from app.application.tool_runtime import builtins as builtins_module
from app.modules.tool_runtime.definition import ToolSpec
custom_shell = ToolSpec(
name="shell",
description="自定义 shell(覆盖 builtin)",
risk_level="L1",
permission_policy=PermissionPolicy.allow,
build_request=lambda _input: _ok_exec_request(),
side_effect=False,
)
runtime = ToolRuntime(sandbox_client, SafetyGovernor(), InMemoryEventStore())
runtime.register(custom_shell)
registered = {s.name: s for s in runtime.list_specs()}
assert registered["shell"].description == "自定义 shell(覆盖 builtin)"
# builtin 变更(如整体被清空)不应影响 register 叠加的工具
monkeypatch.setattr(builtins_module, "builtin_specs", lambda: {})
after = {s.name: s for s in runtime.list_specs()}
assert after["shell"].description == "自定义 shell(覆盖 builtin)"
def _ok_exec_request():
"""供 register 测试构造 ToolSpec.build_request 用的占位 ExecRequest。"""
from app.integrations.sandbox.models import ExecRequest
return ExecRequest(argv=["sh", "-c", "echo ok"])
@@ -1,117 +0,0 @@
"""fetch_url executor 单元测试。
mock 边界:
- ``respx`` 模拟 httpx.AsyncClient 的 GET(不真实联网)。
- executor 直接调用,不经过 ToolRuntime(runtime 集成路径由 test_runtime 覆盖)。
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Any
import httpx
import pytest
import respx
from app.application.tool_runtime.web_tools import _fetch_url_executor
@pytest.fixture
async def http_client() -> AsyncIterator[httpx.AsyncClient]:
async with httpx.AsyncClient() as client:
yield client
def _ctx(http_client: httpx.AsyncClient, **extra: Any) -> dict[str, Any]:
"""构造 executor 上下文。"""
base: dict[str, Any] = {"http_client": http_client, "trace_id": "trace_test"}
base.update(extra)
return base
@respx.mock
async def test_fetch_url_converts_html_to_markdown(
http_client: httpx.AsyncClient,
) -> None:
"""fetch_url 把 HTML 转成 markdown 并截断。"""
html = (
"<html><head><title>T</title></head>"
"<body><h1>标题</h1><p>这是一段正文。</p>"
'<a href="https://example.com">链接</a></body></html>'
)
respx.get("https://example.com/page").mock(return_value=httpx.Response(200, text=html))
result = await _fetch_url_executor({"url": "https://example.com/page"}, _ctx(http_client))
assert result.ok
assert "标题" in result.stdout
assert "这是一段正文。" in result.stdout
# html2text 默认保留链接
assert "example.com" in result.stdout
@respx.mock
async def test_fetch_url_truncates_to_max_chars(
http_client: httpx.AsyncClient,
) -> None:
"""max_chars 截断生效。"""
long_text = "A" * 500
html = f"<html><body><p>{long_text}</p></body></html>"
respx.get("https://example.com/big").mock(return_value=httpx.Response(200, text=html))
result = await _fetch_url_executor(
{"url": "https://example.com/big", "max_chars": 50}, _ctx(http_client)
)
assert result.ok
assert "已截断" in result.stdout
assert len(result.stdout) < 500
@respx.mock
async def test_fetch_url_http_error_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""HTTP 错误返回 ok=False。"""
respx.get("https://example.com/missing").mock(
return_value=httpx.Response(404, text="not found")
)
result = await _fetch_url_executor({"url": "https://example.com/missing"}, _ctx(http_client))
assert not result.ok
assert "404" in result.stderr
async def test_fetch_url_missing_url_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""缺 url 输入时返回 ok=False。"""
result = await _fetch_url_executor({}, _ctx(http_client))
assert not result.ok
assert "url" in result.stderr
async def test_fetch_url_missing_http_client_returns_error() -> None:
"""缺注入的 http_client 时返回 ok=False。"""
result = await _fetch_url_executor({"url": "https://example.com"}, None)
assert not result.ok
assert "http_client" in result.stderr
@respx.mock
async def test_fetch_url_network_error_returns_error(
http_client: httpx.AsyncClient,
) -> None:
"""网络错误(HTTPError)返回 ok=False 而不抛异常。"""
respx.get("https://example.com/neterr").mock(
side_effect=httpx.ConnectError("connection refused")
)
result = await _fetch_url_executor({"url": "https://example.com/neterr"}, _ctx(http_client))
assert not result.ok
assert "请求失败" in result.stderr
+5
View File
@@ -31,6 +31,11 @@ else:
os.environ["JWT_SECRET"] = "test-jwt-secret-for-testing-pytest"
# 预热模型注册(必须在上述环境变量设置之后导入,避免 settings 读到开发库配置):
# 先完整初始化 app.models,避免测试模块直接导入域模型时
# 触发 app.db.base ↔ app.models 的部分初始化循环
import app.models # noqa: E402,F401
def _assert_test_database_url(database_url: str) -> None:
if database_url.startswith("sqlite+aiosqlite:///"):
@@ -1,50 +0,0 @@
"""测试目标上下文注入。"""
from app.modules.runtime.goals import build_goals_context
def test_build_goals_context_with_active_goals():
"""生成了 Active Goals 系统提示片段。"""
goals = [
{
"title": "学习 Python",
"level": "long_term",
"status": "active",
"priority": 80,
"progress": 0.5,
},
{
"title": "完成项目文档",
"level": "task",
"status": "active",
"priority": 60,
"progress": 0.0,
"parent_id": "goal_1",
},
]
result = build_goals_context(goals)
assert "学习 Python" in result
assert "任务目标" in result or "task" in result or "长期目标" in result
assert result.startswith("当前活跃目标:") or "当前" in result
def test_build_goals_context_empty():
assert build_goals_context([]) == ""
def test_build_goals_context_sorts_by_priority():
"""按优先级降序排列输出。"""
goals = [
{"title": "low", "level": "task", "status": "active", "priority": 10, "progress": 0.0},
{
"title": "high",
"level": "long_term",
"status": "active",
"priority": 90,
"progress": 0.5,
},
]
result = build_goals_context(goals)
high_idx = result.index("high")
low_idx = result.index("low")
assert high_idx < low_idx # high priority first
@@ -1,120 +0,0 @@
"""DbConfirmationStore 单元测试。"""
from __future__ import annotations
from collections.abc import AsyncGenerator
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from app.db.base import Base
from app.modules.safety.confirmation import (
DbConfirmationStore,
PendingConfirmation,
new_confirmation_id,
)
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
yield session
await engine.dispose()
@pytest_asyncio.fixture
async def store(db_session: AsyncSession) -> DbConfirmationStore:
"""创建绑定 db_session 的 DbConfirmationStore。"""
session_factory = sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
return DbConfirmationStore(session_factory)
class TestDbConfirmationStore:
"""数据库确认存储 CRUD 测试。"""
async def test_create_and_get(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="fetch_url",
input={"q": "test"},
risk_level="L2",
reason="测试确认",
)
created = await store.create(pending)
assert created.confirmation_id == pending.confirmation_id
fetched = await store.get(pending.confirmation_id)
assert fetched is not None
assert fetched.tool == "fetch_url"
assert fetched.input == {"q": "test"}
async def test_get_non_existent(self, store: DbConfirmationStore) -> None:
result = await store.get("confirm_nonexistent")
assert result is None
async def test_pop_removes_and_returns(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="shell",
input={"cmd": "ls"},
risk_level="L3",
reason="高风险操作",
)
await store.create(pending)
popped = await store.pop(pending.confirmation_id)
assert popped is not None
assert popped.tool == "shell"
# pop 后不应再可取到
fetched = await store.get(pending.confirmation_id)
assert fetched is None
async def test_pop_non_existent(self, store: DbConfirmationStore) -> None:
result = await store.pop("confirm_nonexistent")
assert result is None
async def test_list_pending(self, store: DbConfirmationStore) -> None:
# 插入两条
id1 = new_confirmation_id()
id2 = new_confirmation_id()
await store.create(
PendingConfirmation(
confirmation_id=id1, tool="fetch_url", input={}, risk_level="L1", reason="a"
)
)
await store.create(
PendingConfirmation(
confirmation_id=id2, tool="shell", input={}, risk_level="L3", reason="b"
)
)
items = await store.list_pending()
assert len(items) == 2
# 按创建时间升序
assert items[0].confirmation_id == id1
assert items[1].confirmation_id == id2
async def test_list_pending_empty(self, store: DbConfirmationStore) -> None:
items = await store.list_pending()
assert items == []
async def test_popped_not_in_list_pending(self, store: DbConfirmationStore) -> None:
pending = PendingConfirmation(
confirmation_id=new_confirmation_id(),
tool="fetch_url",
input={},
risk_level="L2",
reason="测试",
)
await store.create(pending)
await store.pop(pending.confirmation_id)
items = await store.list_pending()
ids = [i.confirmation_id for i in items]
assert pending.confirmation_id not in ids
@@ -1,66 +0,0 @@
"""司命决策器测试:风险等级 × 权限策略 × 是否确认 的决策矩阵,以及策略字典加载。"""
from __future__ import annotations
from app.modules.safety.governor import (
PermissionPolicy,
SafetyDecision,
SafetyGovernor,
ToolPolicy,
)
def test_low_risk_defaults_to_allow() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="calc", risk_level="L1")
assert result.decision is SafetyDecision.allow
def test_medium_risk_unconfirmed_asks() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L2")
assert result.decision is SafetyDecision.ask
def test_confirmed_becomes_allow() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L3", confirmed=True)
assert result.decision is SafetyDecision.allow
def test_l4_always_denied() -> None:
governor = SafetyGovernor()
result = governor.evaluate(tool="x", risk_level="L4")
assert result.decision is SafetyDecision.deny
def test_forbidden_policy_denied() -> None:
governor = SafetyGovernor(
{"pay": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
)
result = governor.evaluate(tool="pay", risk_level="L3")
assert result.decision is SafetyDecision.deny
assert result.policy is PermissionPolicy.forbidden
def test_explicit_tool_policy_overrides_default() -> None:
governor = SafetyGovernor(
{"read": ToolPolicy(risk_level="L2", permission_policy=PermissionPolicy.allow)}
)
result = governor.evaluate(tool="read")
assert result.decision is SafetyDecision.allow
def test_from_policy_dict_loads_policies() -> None:
governor = SafetyGovernor.from_policy_dict(
{"tools": {"custom": {"risk_level": "L2", "permission_policy": "allow"}}}
)
result = governor.evaluate(tool="custom")
assert result.decision is SafetyDecision.allow
def test_from_policy_dict_empty_returns_default() -> None:
governor = SafetyGovernor.from_policy_dict({})
# 空 dict → 空 Governor,未列工具按 risk 推导:L2 → ask
result = governor.evaluate(tool="x", risk_level="L2")
assert result.decision is SafetyDecision.ask
@@ -1,55 +0,0 @@
"""Skill 关键词触发匹配纯函数测试。"""
from types import SimpleNamespace
from app.modules.skills.matcher import match_skills
def _skill(name: str, trigger: str, status: str = "enabled") -> SimpleNamespace:
return SimpleNamespace(name=name, trigger=trigger, status=status)
def test_match_by_keyword() -> None:
skills = [_skill("coding", "代码,编程")]
assert len(match_skills("帮我写代码", skills)) == 1
def test_no_match() -> None:
skills = [_skill("coding", "代码,编程")]
assert match_skills("今天天气不错", skills) == []
def test_case_insensitive() -> None:
skills = [_skill("en", "Python")]
assert len(match_skills("用 python 写", skills)) == 1
def test_multiple_keywords_any_match() -> None:
skills = [_skill("dev", "代码,debug,编程")]
assert len(match_skills("帮我 debug", skills)) == 1
def test_disabled_not_matched() -> None:
skills = [_skill("coding", "代码", status="disabled")]
assert match_skills("写代码", skills) == []
def test_empty_trigger_not_matched() -> None:
skills = [_skill("empty", "")]
assert match_skills("任意文本", skills) == []
def test_multiple_skills_order_preserved() -> None:
skills = [_skill("a", "代码"), _skill("b", "天气"), _skill("c", "编程")]
matched = match_skills("写代码和编程", skills)
assert [s.name for s in matched] == ["a", "c"]
def test_empty_context() -> None:
skills = [_skill("a", "代码")]
assert match_skills("", skills) == []
def test_whitespace_only_trigger_ignored() -> None:
skills = [_skill("ws", " , , ")]
assert match_skills("任意文本", skills) == []
@@ -1,65 +0,0 @@
"""Skill 数据访问测试。"""
import pytest
from sqlalchemy.exc import IntegrityError
from app.db.session import AsyncSessionLocal
from app.models.skills import Skill
from app.repositories import skills as skills_repo
def _skill(name: str = "coding", **overrides) -> Skill:
base = {
"name": name,
"description": "代码辅助",
"trigger": "代码,编程",
"prompt_fragment": "你是代码助手",
"tool_bindings_json": "[]",
"risk_level": "L1",
"status": "enabled",
}
base.update(overrides)
return Skill(**base)
@pytest.mark.asyncio
async def test_add_and_get_skill() -> None:
async with AsyncSessionLocal() as session:
s = _skill()
await skills_repo.add_skill(session, s)
await session.commit()
sid = s.id
async with AsyncSessionLocal() as session:
fetched = await skills_repo.get_skill(session, sid)
assert fetched is not None
assert fetched.name == "coding"
assert fetched.trigger == "代码,编程"
assert fetched.status == "enabled"
@pytest.mark.asyncio
async def test_list_skills_with_status_filter() -> None:
async with AsyncSessionLocal() as session:
await skills_repo.add_skill(session, _skill("a", status="enabled"))
await skills_repo.add_skill(session, _skill("b", status="disabled"))
await session.commit()
async with AsyncSessionLocal() as session:
enabled = await skills_repo.list_skills(session, status="enabled")
all_skills = await skills_repo.list_skills(session)
assert len(enabled) == 1
assert enabled[0].name == "a"
assert len(all_skills) == 2
@pytest.mark.asyncio
async def test_name_unique_conflict() -> None:
async with AsyncSessionLocal() as session:
await skills_repo.add_skill(session, _skill("dup"))
await session.commit()
async with AsyncSessionLocal() as session:
with pytest.raises(IntegrityError):
await skills_repo.add_skill(session, _skill("dup"))
await session.commit()
@@ -1,75 +0,0 @@
"""Skill 服务测试(CRUD + 关键词触发匹配)。"""
import pytest
from app.core.errors import AppError
from app.services import skills as skills_service
async def _create(
name: str = "coding",
trigger: str = "代码,编程",
status: str = "enabled",
) -> None:
await skills_service.create_skill(
name=name,
description="代码辅助",
trigger=trigger,
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
risk_level="L1",
status=status,
)
@pytest.mark.asyncio
async def test_create_and_list() -> None:
resp = await skills_service.create_skill(
name="coding",
trigger="代码",
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
)
assert resp.name == "coding"
assert resp.tool_bindings == ["read_file"]
listed = await skills_service.list_skills()
assert len(listed) == 1
@pytest.mark.asyncio
async def test_create_duplicate_name_conflicts() -> None:
await _create("dup")
with pytest.raises(AppError):
await _create("dup")
@pytest.mark.asyncio
async def test_update_skill_status() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
updated = await skills_service.update_skill(sid, status="disabled")
assert updated.status == "disabled"
@pytest.mark.asyncio
async def test_match_active_skills() -> None:
await _create("coding", trigger="代码,编程")
await _create("weather", trigger="天气", status="disabled")
matched = await skills_service.match_active_skills("帮我写代码")
assert len(matched) == 1
assert matched[0].name == "coding"
assert matched[0].prompt_fragment == "你是代码助手"
@pytest.mark.asyncio
async def test_delete_skill() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
await skills_service.delete_skill(sid)
assert await skills_service.get_skill(sid) is None
-71
View File
@@ -114,40 +114,6 @@ async def test_personality_propose_approve_reject_and_rollback(client: AsyncClie
app.dependency_overrides.pop(require_admin, None)
@pytest.mark.asyncio
async def test_skills_crud(client: AsyncClient) -> None:
"""Skill 能力包 CRUD——覆盖 services/skills 路径。"""
created = await client.post(
"/api/v1/tools/skills",
json={
"name": "测试技能",
"description": "用于集成测试",
"trigger": "测试触发",
"prompt_fragment": "测试提示片段",
"tool_bindings": [],
"risk_level": "L0",
},
)
assert created.status_code == 201
skill_id = created.json()["id"]
listed = await client.get("/api/v1/tools/skills")
assert listed.status_code == 200
assert any(item["id"] == skill_id for item in listed.json())
# Skills 没有 GET single 端点,跳过
updated = await client.patch(
f"/api/v1/tools/skills/{skill_id}",
json={"description": "改描述", "trigger": "新触发"},
)
assert updated.status_code == 200
assert updated.json()["trigger"] == "新触发"
deleted = await client.delete(f"/api/v1/tools/skills/{skill_id}")
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_mcp_server_crud(client: AsyncClient) -> None:
"""MCP 服务器注册 CRUD——覆盖 services/mcp 路径。"""
@@ -213,43 +179,6 @@ async def test_world_entities_crud(client: AsyncClient) -> None:
assert gone.status_code in (200, 404)
@pytest.mark.asyncio
async def test_safety_policy_crud(client: AsyncClient) -> None:
"""安全策略 CRUD——覆盖 services/governance 部分路径。"""
import uuid
policy_name = f"test-policy-{uuid.uuid4().hex[:8]}"
created = await client.post(
"/api/v1/safety/policies",
json={
"name": policy_name,
"description": "集成测试策略",
"risk_level": "L3",
"permission_policy": "ask",
"side_effect": True,
"audit_required": True,
},
)
assert created.status_code == 201, created.text
data = created.json()
assert data["name"] == policy_name
listed = await client.get("/api/v1/safety/policies")
assert listed.status_code == 200
# SafetyPolicyItem 使用 tool 字段名(不是 name)
assert any(item["tool"] == policy_name for item in listed.json())
updated = await client.patch(
f"/api/v1/safety/policies/{policy_name}",
json={"description": "改后描述", "risk_level": "L2"},
)
assert updated.status_code == 200, updated.text
assert updated.json()["risk_level"] == "L2"
deleted = await client.delete(f"/api/v1/safety/policies/{policy_name}")
assert deleted.status_code == 204
@pytest.mark.asyncio
async def test_preferences_flow(client: AsyncClient) -> None:
"""偏好设置读写——覆盖 services/preferences 路径。"""
+220
View File
@@ -0,0 +1,220 @@
"""M5 百工与司命验收测试(rewrite-plan M5 验收门)。
四条验收:
① 未授契能力调用被拒并留痕(audit_events 有拒绝记录);
② 请命超时不执行(过期确认 → HJ-1302 语义,执行计数为 0);
③ 禁域规则无法被任何 Profile/插件关闭(恶意注入 dims/consent 仍拒);
④ 工具结果先为观察(调用后记忆/实录表无新行,tool_calls 带观察标证)。
"""
from collections.abc import AsyncGenerator
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.domains.baigong.models import Capability, Mandate, ToolCall
from app.models.user import User
from app.main import app
from app.models.safety import AuditEvent
from app.models.xirang import Memory # noqa: TC001 — 断言用
from app.domains.baigong import siming
from app.domains.baigong.models import Capability as Cap
@pytest.fixture
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
"""手工装配百工执行器(builtin 回显,不经沙箱;测试不经 lifespan)。"""
from app.domains.baigong.executor import Executor
app.state.baigong_executor = Executor(None)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
yield c
del app.state.baigong_executor
async def _test_owner_scope() -> str:
"""取最新测试用户的 owner_scope(auth_headers fixture 所建)。"""
from sqlalchemy import select
async with AsyncSessionLocal() as session:
users = (await session.execute(select(User).order_by(User.created_at.desc()))).scalars().all()
return users[0].id # 最新创建者即本测试 fixture 用户
async def _seed_capability(
*,
name: str = "cap.test.echo",
executor_type: str = "builtin",
consent_mode: str = "allow",
risk_dims: dict | None = None,
) -> Capability:
import json
owner = await _test_owner_scope()
async with AsyncSessionLocal() as session:
cap = Capability(
name=name,
owner_scope=owner,
description="测试能力",
executor_type=executor_type,
executor_ref="echo",
params_schema_json="{}",
risk_dims_json=json.dumps(risk_dims or {}),
consent_mode=consent_mode,
)
session.add(cap)
await session.commit()
session.expunge(cap)
return cap
async def _grant(capability_id: str, *, kind: str = "charter", budget: int = 10) -> None:
owner = await _test_owner_scope()
async with AsyncSessionLocal() as session:
session.add(
Mandate(
owner_scope=owner,
capability_id=capability_id,
kind=kind,
budget_total=budget,
)
)
await session.commit()
async def _audit_actions(capability_name: str) -> list[str]:
async with AsyncSessionLocal() as session:
rows = await session.execute(
select(AuditEvent.action).where(AuditEvent.resource_id == capability_name)
)
return list(rows.scalars().all())
# ── ① 未授契能力调用被拒并留痕 ──────────────────────────────
@pytest.mark.asyncio
async def test_unmandated_invocation_rejected_and_audited(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.unmandated")
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "hi"}}
)
assert resp.status_code == 403
body = resp.json()
assert body["error"]["code"] == "HJ-1303"
async with AsyncSessionLocal() as session:
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert len(records) == 1
assert records[0].decision == "denied_unmandated"
# 留痕:行动账可查
actions = await _audit_actions(cap.name)
assert any("denied" in a for a in actions)
# ── ② 请命超时不执行 ──────────────────────────────
@pytest.mark.asyncio
async def test_petition_expiry_does_not_execute(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.askflow", consent_mode="ask")
await _grant(cap.id)
# 首次调用 → 请命(不执行)
ask = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "hi"}}
)
assert ask.status_code == 200
assert ask.json()["status"] == "requires_petition"
petition_id = ask.json()["petition_id"]
# 超时:直接把截止时间拨到过去,再批准 → 语义为拒绝执行(HJ-1302)
from app.models.safety import PendingConfirmationRecord
async with AsyncSessionLocal() as session:
record = await session.get(PendingConfirmationRecord, petition_id)
assert record is not None
record.expires_at = datetime.now(UTC) - timedelta(seconds=1)
await session.commit()
expired = await client.post(f"/api/v1/petitions/{petition_id}/approve")
assert expired.status_code == 410
assert expired.json()["error"]["code"] == "HJ-1302"
# 执行计数为 0(只有一条 ask 裁决记录,无成功执行)
async with AsyncSessionLocal() as session:
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert all(r.status != "ok" for r in records)
assert all(r.decision != "allow" for r in records)
# ── ③ 禁域无法被关闭(专项) ──────────────────────────────
@pytest.mark.asyncio
async def test_forbidden_zone_cannot_be_disabled(client: AsyncClient) -> None:
cap = await _seed_capability(
name="cap.test.shell",
executor_type="qiankun",
# 恶意标注:四维全 L0、consent 放行——禁域仍必须拒绝
risk_dims={"capability": "L0", "data": "L0", "consequence": "L0", "context": "L0"},
consent_mode="allow",
)
await _grant(cap.id)
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke",
json={"params": {"command": "rm -rf / --no-preserve-root"}},
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "HJ-1301"
# 代码级专项:直接以被篡改的领域对象断言禁域检查仍生效
tampered = Cap(
name="cap.tampered",
owner_scope="local-user",
executor_type="qiankun",
risk_dims_json="{}",
consent_mode="allow",
enabled=True,
)
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"command": "dd if=/dev/zero of=/dev/sda"})
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"url": "file:///etc/passwd"})
with pytest.raises(siming.ForbiddenViolation):
siming.check_forbidden(tampered, {"text": "sk-abcdefghijklmnopqrst"})
# ── ④ 工具结果先为观察 ──────────────────────────────
@pytest.mark.asyncio
async def test_tool_result_is_observation_not_memory(client: AsyncClient) -> None:
cap = await _seed_capability(name="cap.test.observe")
await _grant(cap.id)
async with AsyncSessionLocal() as session:
before = len(list((await session.execute(select(Memory))).scalars().all()))
resp = await client.post(
f"/api/v1/capabilities/{cap.id}/invoke", json={"params": {"text": "工具产出的内容"}}
)
assert resp.status_code == 200
body = resp.json()
# 观察标证:结果带来源与观察语义标注,非事实
assert body["observation"]["kind"] == "observation"
assert body["observation"]["capability"] == cap.name
async with AsyncSessionLocal() as session:
after = len(list((await session.execute(select(Memory))).scalars().all()))
calls = await session.execute(select(ToolCall).where(ToolCall.capability_id == cap.id))
records = list(calls.scalars().all())
assert after == before, "工具结果不得自动写入记忆"
assert records and records[-1].evidence_json.get("kind") == "observation"