M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明) - 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301 - 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账 - MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch) - 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证) - 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页 - 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
This commit is contained in:
1 parent
144415247e
commit
684261f4b4
97 files changed
+3075
-5554
No files matched your search
@@ -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 ###
|
||||
@@ -18,6 +18,7 @@ from app.api.v1.endpoints import (
|
||||
kernel_runs,
|
||||
lorebooks,
|
||||
me,
|
||||
baigong,
|
||||
mirror_trees,
|
||||
mcp,
|
||||
personality,
|
||||
@@ -25,7 +26,6 @@ from app.api.v1.endpoints import (
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
skills,
|
||||
tools,
|
||||
world,
|
||||
xirang,
|
||||
@@ -39,6 +39,8 @@ api_router.include_router(character_import.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(kernel_runs.router)
|
||||
api_router.include_router(me.router)
|
||||
for _baigong_r in baigong.routers:
|
||||
api_router.include_router(_baigong_r)
|
||||
api_router.include_router(embryo_drafts.router)
|
||||
api_router.include_router(jingsheng_characters.router)
|
||||
api_router.include_router(mirror_trees.router)
|
||||
@@ -47,7 +49,6 @@ api_router.include_router(mirror_trees.node_router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(skills.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""API 层依赖注入。
|
||||
|
||||
从 ``app.state`` 取 lifespan 装配的共享运行时(ToolRuntime / ConfirmationStore / EventStore)。
|
||||
从 ``app.state`` 取 lifespan 装配的共享运行时(乾坤沙箱客户端 / EventStore)。
|
||||
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
|
||||
"""
|
||||
|
||||
@@ -11,11 +11,10 @@ import hmac
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.modules.runtime.events import EventStore
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.auth import CurrentUser as CurrentUserSchema
|
||||
from app.services.auth import decode_token, get_user_by_id, get_user_permissions
|
||||
|
||||
@@ -27,23 +26,11 @@ def _unavailable(message: str) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
def get_tool_runtime(request: Request) -> ToolRuntime:
|
||||
runtime: ToolRuntime | None = getattr(request.app.state, "tool_runtime", None)
|
||||
if runtime is None:
|
||||
raise _unavailable("工具运行时未就绪(沙箱未配置或未启动)")
|
||||
return runtime
|
||||
|
||||
|
||||
def get_optional_tool_runtime(request: Request) -> ToolRuntime | None:
|
||||
"""宽松版:tool_runtime 未就绪时返回 None(不抛 503),供聚合视图降级呈现。"""
|
||||
return getattr(request.app.state, "tool_runtime", None)
|
||||
|
||||
|
||||
def get_confirmation_store(request: Request) -> ConfirmationStore:
|
||||
store = getattr(request.app.state, "confirmation_store", None)
|
||||
if store is None:
|
||||
raise _unavailable("确认存储未就绪")
|
||||
return store
|
||||
def get_sandbox_client(request: Request) -> SandboxClient:
|
||||
sandbox: SandboxClient | None = getattr(request.app.state, "baigong_sandbox", None)
|
||||
if sandbox is None:
|
||||
raise _unavailable("乾坤沙箱未就绪(SANDBOX 未配置或未启动)")
|
||||
return sandbox
|
||||
|
||||
|
||||
def get_event_store(request: Request) -> EventStore:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
|
||||
|
||||
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
|
||||
``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。
|
||||
聚合视图自持查询(百工域常驻),无注入运行时。
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -13,14 +13,10 @@ from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.v1.deps import (
|
||||
get_confirmation_store,
|
||||
get_current_user,
|
||||
get_optional_tool_runtime,
|
||||
require_admin,
|
||||
)
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.models.runtime import Event
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.admin import (
|
||||
AdminCreateUserRequest,
|
||||
AdminUpdateUserRequest,
|
||||
@@ -80,13 +76,9 @@ router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(requir
|
||||
|
||||
@router.get("/pivots/health", response_model=PivotHealthReport)
|
||||
async def pivots_health(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PivotHealthReport:
|
||||
return await get_pivots_health(
|
||||
confirmation_store=confirmation_store,
|
||||
tool_runtime=tool_runtime,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
@@ -96,12 +88,8 @@ async def pivots_health(
|
||||
|
||||
@router.get("/safety/pending", response_model=Page[PendingApproval])
|
||||
async def safety_pending(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Page[PendingApproval]:
|
||||
items = await get_pending_approvals(
|
||||
confirmation_store=confirmation_store, tool_runtime=tool_runtime
|
||||
)
|
||||
items = await get_pending_approvals()
|
||||
return Page(items=items, page=1, page_size=len(items), total=len(items))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""百工与司命路由(域实现见 app/domains/baigong/api.py)。"""
|
||||
|
||||
from app.domains.baigong.api import routers
|
||||
|
||||
__all__ = ["routers"]
|
||||
@@ -7,15 +7,12 @@ stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import status as http_status
|
||||
|
||||
from app.api.v1.deps import get_optional_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.integrations.mcp.process_manager import get_process_manager
|
||||
from app.schemas.mcp import (
|
||||
CreateMcpServerRequest,
|
||||
McpServerResponse,
|
||||
McpServerStatusResponse,
|
||||
UpdateMcpServerRequest,
|
||||
)
|
||||
UpdateMcpServerRequest)
|
||||
from app.services import mcp as mcp_service
|
||||
|
||||
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
|
||||
@@ -24,12 +21,9 @@ router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
|
||||
@router.post(
|
||||
"",
|
||||
response_model=McpServerResponse,
|
||||
status_code=http_status.HTTP_201_CREATED,
|
||||
)
|
||||
status_code=http_status.HTTP_201_CREATED)
|
||||
async def register_mcp_server(
|
||||
payload: CreateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
payload: CreateMcpServerRequest) -> McpServerResponse:
|
||||
"""注册 MCP server。
|
||||
|
||||
streamable-http:同步拉取工具清单并注册到运行时。
|
||||
@@ -45,28 +39,23 @@ async def register_mcp_server(
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
status=payload.status,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[McpServerResponse])
|
||||
async def list_mcp_servers(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[McpServerResponse]:
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled")) -> list[McpServerResponse]:
|
||||
"""列举 MCP server(含工具清单摘要)。"""
|
||||
return await mcp_service.list_mcp_servers(status=status)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{server_id}",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
response_model=McpServerResponse)
|
||||
async def update_mcp_server(
|
||||
server_id: str,
|
||||
payload: UpdateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
payload: UpdateMcpServerRequest) -> McpServerResponse:
|
||||
"""更新 MCP server 字段(部分更新)。"""
|
||||
return await mcp_service.update_mcp_server(
|
||||
server_id,
|
||||
@@ -77,79 +66,61 @@ async def update_mcp_server(
|
||||
args_json=payload.args_json,
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> None:
|
||||
server_id: str) -> None:
|
||||
"""删除 MCP server(先 stop 子进程再删)。"""
|
||||
await mcp_service.delete_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/start",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
response_model=McpServerStatusResponse)
|
||||
async def start_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
server_id: str) -> McpServerStatusResponse:
|
||||
"""启动 stdio MCP server 子进程,拉取工具并注册。"""
|
||||
return await mcp_service.start_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/stop",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
response_model=McpServerStatusResponse)
|
||||
async def stop_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
server_id: str) -> McpServerStatusResponse:
|
||||
"""停止 stdio MCP server 子进程,注销工具。"""
|
||||
return await mcp_service.stop_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/restart",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
response_model=McpServerStatusResponse)
|
||||
async def restart_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
server_id: str) -> McpServerStatusResponse:
|
||||
"""重启 stdio MCP server。"""
|
||||
return await mcp_service.restart_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{server_id}/status",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
response_model=McpServerStatusResponse)
|
||||
async def get_mcp_server_status(
|
||||
server_id: str,
|
||||
) -> McpServerStatusResponse:
|
||||
server_id: str) -> McpServerStatusResponse:
|
||||
"""查询 MCP server 进程状态。"""
|
||||
return await mcp_service.get_mcp_server_status(
|
||||
server_id,
|
||||
@@ -159,15 +130,11 @@ async def get_mcp_server_status(
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/refresh",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
response_model=McpServerResponse)
|
||||
async def refresh_mcp_server_tools(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
server_id: str) -> McpServerResponse:
|
||||
"""重新拉取 MCP server 工具清单并注册到运行时。"""
|
||||
return await mcp_service.refresh_mcp_server_tools(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
@@ -1,149 +1,30 @@
|
||||
"""安全 API(api-spec §16)。
|
||||
"""安全 API(§16):审计时间线查询。
|
||||
|
||||
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 落库裁决
|
||||
高风险工具确认(pending → approved/denied),客户端随后以 confirmed=True 重新调用。
|
||||
工具策略治理与确认流已由百工域承接(/capabilities /mandates /petitions);
|
||||
本路由保留 audit-events(行动账时间线)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.api.v1.deps import get_optional_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.admin import AuditEntry
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
SafetyPolicyItem,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.services import audit as audit_service
|
||||
from app.services import governance as governance_service
|
||||
|
||||
router = APIRouter(prefix="/safety", tags=["safety"])
|
||||
|
||||
|
||||
# —— §16 审计事件查询 ——
|
||||
|
||||
|
||||
@router.get("/audit-events", response_model=Page[AuditEntry])
|
||||
async def list_audit_events(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[AuditEntry]:
|
||||
return await audit_service.query_audit_events(
|
||||
character_id=character_id, pivot=pivot, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
# —— §16 工具策略治理 ——
|
||||
|
||||
|
||||
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
|
||||
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
|
||||
|
||||
@router.get("/policies", response_model=list[SafetyPolicyItem])
|
||||
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
||||
return await governance_service.list_safety_policies()
|
||||
|
||||
|
||||
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_safety_policy(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
|
||||
async def update_safety_policy(
|
||||
tool: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool, payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_safety_policy(
|
||||
tool: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool)
|
||||
await _refresh_governor(runtime)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §16 高风险工具确认 ——
|
||||
|
||||
|
||||
def _not_found(confirmation_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _cancelled(confirmation_id: str) -> dict[str, str]:
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/approve")
|
||||
async def approve(confirmation_id: str) -> dict[str, bool | str]:
|
||||
"""批准工具执行:pending 记录落库为 approved,客户端以 confirmed=True 重新调用。"""
|
||||
record = await _resolve_confirmation_record(confirmation_id, approved=True)
|
||||
return {"status": "resolved", "approved": True, "run_id": record.run_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/deny")
|
||||
async def deny(confirmation_id: str) -> dict[str, str]:
|
||||
"""拒绝工具执行:pending 记录落库为 denied。"""
|
||||
await _resolve_confirmation_record(confirmation_id, approved=False)
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
|
||||
|
||||
async def _resolve_confirmation_record(
|
||||
confirmation_id: str, *, approved: bool
|
||||
) -> PendingConfirmationRecord:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.run_id is None:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = (
|
||||
update(PendingConfirmationRecord)
|
||||
.where(
|
||||
PendingConfirmationRecord.id == confirmation_id,
|
||||
PendingConfirmationRecord.status == "pending",
|
||||
)
|
||||
.values(
|
||||
status="approved" if approved else "denied",
|
||||
resolved_at=datetime.now(UTC),
|
||||
)
|
||||
)
|
||||
updated = await session.execute(stmt)
|
||||
await session.commit()
|
||||
if updated.rowcount == 0:
|
||||
raise _not_found(confirmation_id)
|
||||
return record
|
||||
@@ -22,8 +22,9 @@ import mimetypes
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.v1.deps import get_current_user, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.api.v1.deps import get_current_user, get_sandbox_client
|
||||
from app.domains.baigong import files as baigong_files
|
||||
from app.integrations.sandbox.client import SandboxClient
|
||||
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
@@ -54,7 +55,7 @@ async def read_sandbox_file(
|
||||
"write_file 写入的文件在 workspace/<character_id>/ 下,"
|
||||
"传递 write_file 的 path 时保持 True 即可。",
|
||||
),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
sandbox: SandboxClient = Depends(get_sandbox_client),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
|
||||
@@ -76,8 +77,9 @@ async def read_sandbox_file(
|
||||
|
||||
# 2) 从沙箱读取
|
||||
try:
|
||||
result = await runtime.read_sandbox_file(
|
||||
path=path,
|
||||
result = await baigong_files.read_sandbox_file(
|
||||
sandbox,
|
||||
path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
@@ -117,7 +119,7 @@ async def list_sandbox_files(
|
||||
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
|
||||
),
|
||||
isolated: bool = Query(True, description="是否自动补全隔离前缀"),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
sandbox: SandboxClient = Depends(get_sandbox_client),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[dict[str, object]]:
|
||||
"""列出沙箱工作区目录内容。
|
||||
@@ -126,8 +128,9 @@ async def list_sandbox_files(
|
||||
适用于前端文件浏览器导航。
|
||||
"""
|
||||
try:
|
||||
entries = await runtime.list_sandbox_dir(
|
||||
path=path,
|
||||
entries = await baigong_files.list_sandbox_dir(
|
||||
sandbox,
|
||||
path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Skill 能力包 API(/tools/skills,skills-design §6)。"""
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.schemas.skills import (
|
||||
CreateSkillRequest,
|
||||
SkillResponse,
|
||||
UpdateSkillRequest,
|
||||
)
|
||||
from app.services import skills as skills_service
|
||||
|
||||
router = APIRouter(prefix="/tools/skills", tags=["skills"])
|
||||
|
||||
|
||||
@router.post("", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_skill(payload: CreateSkillRequest) -> SkillResponse:
|
||||
"""注册 Skill 能力包(name 唯一)。"""
|
||||
return await skills_service.create_skill(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SkillResponse])
|
||||
async def list_skills(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[SkillResponse]:
|
||||
"""列举 Skill(可按 status 过滤)。"""
|
||||
return await skills_service.list_skills(status=status)
|
||||
|
||||
|
||||
@router.patch("/{skill_id}", response_model=SkillResponse)
|
||||
async def update_skill(skill_id: str, payload: UpdateSkillRequest) -> SkillResponse:
|
||||
"""更新 / 启用 / 禁用 Skill。"""
|
||||
return await skills_service.update_skill(
|
||||
skill_id,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_skill(skill_id: str) -> None:
|
||||
"""删除 Skill。"""
|
||||
await skills_service.delete_skill(skill_id)
|
||||
@@ -1,252 +1,37 @@
|
||||
"""工具调用 API(api-spec §15)。
|
||||
"""工具 API(§15 兼容面):清单走百工能力目录,调用走 /capabilities/{id}/invoke。
|
||||
|
||||
- GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
|
||||
- POST /tools:注册自定义工具策略(百工枢器用治理声明,记审计)。
|
||||
- GET /tools/calls/{tool_call_id}:单次工具调用事件详情。
|
||||
- PATCH /tools/{tool_name}:编辑工具策略元数据(spec §15: risk_level 等)。
|
||||
- DELETE /tools/{tool_name}:注销自定义工具(builtin 不可删)。
|
||||
- POST /tools/{tool_name}/invoke:调用工具;高风险返回 requires_confirmation + confirmation_id,
|
||||
交由 /safety/confirmations/{id}/approve 消费。
|
||||
- POST /tools/{tool_name}/invoke/stream:流式调用(SSE),实时推送 tool.stdout/tool.stderr
|
||||
增量与 tool.completed;司命 deny/ask-未确认/沙箱错误分别以 tool.failed / tool.requires_confirmation 结束流。
|
||||
|
||||
策略写操作(POST/PATCH/DELETE)后刷新 ToolRuntime 的司命决策器,使变更即时生效;
|
||||
tool_runtime 未就绪时仅写库(重启后从 db 加载)。
|
||||
旧 ToolRuntime/策略治理端点已随 M5 百工重写退役;本路由只保留
|
||||
`/tools`(能力目录投影)与 `/tools/calls/{id}`(一炁事件详情)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.api.v1.deps import get_confirmation_store, get_optional_tool_runtime, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.integrations.sandbox.exceptions import SandboxError
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
ToolCallDetail,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.schemas.skills import ToolCapability
|
||||
from app.services import governance as governance_service
|
||||
from app.services import skills as skills_service
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.domains.baigong import service as baigong
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.safety import ToolCallDetail
|
||||
from app.services.governance import get_tool_call
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||
|
||||
|
||||
class InvokeRequest(BaseModel):
|
||||
input: dict[str, Any]
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
confirmed: bool = False
|
||||
|
||||
|
||||
class ToolResultPayload(BaseModel):
|
||||
ok: bool
|
||||
exit_code: int | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
timeout: bool = False
|
||||
truncated: bool = False
|
||||
trace_id: str = ""
|
||||
|
||||
|
||||
class CompletedResponse(BaseModel):
|
||||
status: str = "completed"
|
||||
result: ToolResultPayload
|
||||
|
||||
|
||||
class RequiresConfirmationResponse(BaseModel):
|
||||
status: str = "requires_confirmation"
|
||||
confirmation_id: str
|
||||
risk_level: str
|
||||
reason: str
|
||||
|
||||
|
||||
async def _sync_confirmation_record(pending: PendingConfirmation) -> None:
|
||||
"""把 InMemoryConfirmationStore 的待确认记录同步持久化到 DB。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input) if pending.input else "{}",
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason or "",
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— §15 工具策略治理 ——
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolPolicyResponse])
|
||||
async def list_tools() -> list[ToolPolicyResponse]:
|
||||
return await governance_service.list_tool_policies()
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=list[ToolCapability])
|
||||
async def list_capabilities(
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> list[ToolCapability]:
|
||||
"""统一列举所有能力(builtin / mcp / skill,含来源标记,skills-design §6)。"""
|
||||
return await skills_service.list_all_capabilities(runtime)
|
||||
|
||||
|
||||
@router.post("", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_tool(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
@router.get("")
|
||||
async def list_tools(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
|
||||
"""能力目录投影(新链路以 /capabilities 为准;此处给旧消费方过渡读取)。"""
|
||||
caps = await baigong.list_capabilities(user.owner_scope)
|
||||
return [
|
||||
{
|
||||
"name": c["name"],
|
||||
"description": c["description"],
|
||||
"risk_level": max(c["risk_dims"].values(), default="L1"),
|
||||
"permission_policy": "ask" if c["consent_mode"] == "ask" else "allow",
|
||||
"enabled": c["enabled"],
|
||||
"source": c["source"],
|
||||
}
|
||||
for c in caps
|
||||
]
|
||||
|
||||
|
||||
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
|
||||
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
|
||||
return await governance_service.get_tool_call(tool_call_id)
|
||||
|
||||
|
||||
@router.patch("/{tool_name}", response_model=ToolPolicyResponse)
|
||||
async def update_tool(
|
||||
tool_name: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool_name, payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{tool_name}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tool(
|
||||
tool_name: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool_name)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §15 工具调用 ——
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke", response_model=None)
|
||||
async def invoke_tool(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
confirmations: ConfirmationStore = Depends(get_confirmation_store),
|
||||
) -> CompletedResponse | RequiresConfirmationResponse:
|
||||
try:
|
||||
result = await runtime.execute(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
)
|
||||
except RequiresConfirmation as exc:
|
||||
pending = await confirmations.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool=tool_name,
|
||||
input=body.input,
|
||||
risk_level=exc.risk_level or "L2",
|
||||
reason=exc.message,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
)
|
||||
)
|
||||
# 同步持久化 PendingConfirmationRecord(供 safety/confirmations 新端点查找)
|
||||
await _sync_confirmation_record(pending)
|
||||
return RequiresConfirmationResponse(
|
||||
confirmation_id=pending.confirmation_id,
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
)
|
||||
|
||||
return CompletedResponse(
|
||||
result=ToolResultPayload(
|
||||
ok=result.ok,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
trace_id=result.trace_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke/stream")
|
||||
async def invoke_tool_stream(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
) -> EventSourceResponse:
|
||||
"""流式调用工具(SSE)。
|
||||
|
||||
事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``,前端解码)、
|
||||
``tool.completed``(data 为结束汇总 JSON)。司命 deny → ``tool.failed``;
|
||||
ask 且未确认 → ``tool.requires_confirmation``(前端应先走非流式 invoke 完成 confirmation,
|
||||
再以 confirmed=True 调用本端点);沙箱错误 → ``tool.failed``。
|
||||
"""
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
async for ev in runtime.execute_stream(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
):
|
||||
if ev.kind in ("stdout", "stderr"):
|
||||
yield {"event": f"tool.{ev.kind}", "data": json.dumps({"data": ev.payload})}
|
||||
elif ev.kind == "done":
|
||||
yield {"event": "tool.completed", "data": json.dumps(ev.payload)}
|
||||
except RequiresConfirmation as exc:
|
||||
yield {
|
||||
"event": "tool.requires_confirmation",
|
||||
"data": json.dumps({"risk_level": exc.risk_level, "reason": exc.message}),
|
||||
}
|
||||
except PermissionDenied as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": "permission_denied", "error_message": str(exc)}),
|
||||
}
|
||||
except SandboxError as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": exc.code, "error_message": str(exc)}),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_gen())
|
||||
async def get_tool_call_detail(tool_call_id: str) -> ToolCallDetail:
|
||||
return await get_tool_call(tool_call_id)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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_")
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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]
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
@@ -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
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。",
|
||||
)
|
||||
@@ -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, ...] = ()
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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 恒定层不一致")
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。"""
|
||||
|
||||
from app.modules.skills.manifest import SkillManifest, parse_skill_manifest
|
||||
|
||||
__all__ = ["SkillManifest", "parse_skill_manifest"]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 路径。"""
|
||||
|
||||
@@ -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"
|
||||
@@ -134,10 +134,10 @@ erDiagram
|
||||
| --- | --- |
|
||||
| `runs` | 运行:种类(前台/后台受托)、状态、命台 epoch、Profile 快照引用 |
|
||||
| `run_events` | 一炁事件流(现有 event_store 改造):单调序号、类型、可见性、载荷;SSE 与回放的数据源 |
|
||||
| `tools` / `skills` | 百工注册表:稳定标识、参数 Schema、执行类型(HTTP/MCP/乾坤/内置)、四维风险标注 |
|
||||
| `capabilities` | 百工能力注册表(M5 落地单表统一契约):稳定标识、参数 Schema、执行类型(builtin/qiankun/mcp/http)、四维风险标注、来源与版本 |
|
||||
| `mandates` | 授契(单次范围)与行契(重复执行:能力/参数/数据/目标/预算/时间窗);可撤销,撤销立即生效 |
|
||||
| `confirmations` | 请命:待确认动作、风险说明、超时策略(超时=拒绝执行);含死亡、重忆、铸魂等高影响确认 |
|
||||
| `tool_calls` / `evidence` | 调用记录与证据链:输入输出摘要(净化后)、trace 关联 |
|
||||
| `confirmations`(`pending_confirmations`) | 请命:六要素说明(brief_json)、超时策略(超时=拒绝执行 HJ-1302);含死亡、重忆、铸魂等高影响确认 |
|
||||
| `tool_calls` | 调用记录与证据链(evidence_json 内嵌):净化后输入输出摘要、观察标证、裁决与授契关联、trace 关联 |
|
||||
| `audit_events` / 行动账 | 现有审计表改造:所有工具调用(用户+时间+工具+参数+结果)与外部行动 |
|
||||
| `mcp_servers` | MCP 服务登记(博闻等);玄关退役后由百工持有 |
|
||||
| ST 兼容层 | `user_personas`(本相)、`generation_presets`(火候)、`instruct_templates`(科仪)、`quick_replies`(锦囊)、`author_notes`(眉批)、macros(符文引擎,无独立表,组装期展开) |
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 基础设施复用(auth/RBAC/审计/事件/幂等/SSE) | M0 | 局部实现 | auth/事件存储/SSE 已被 Kernel 复用;M2b 删除旧 chat/knocking/情绪/纪念日模型(迁移 003),time_sense 纯计算核保留待 M8 辰正重接;重组入 `infra/` 待后续里程碑 |
|
||||
| 乾坤沙箱 | M5 | 局部实现 | 隔离执行/HMAC/超时取消已有;设备安全与双裁决待扩 |
|
||||
| 博闻 MCP 搜索 | M5 | 局部实现 | 多引擎/密钥池/熔断/缓存可用;待登记入百工 |
|
||||
| 百工与司命 | M5 | 已验证 | M5 收口(2026-08-27,本地开发档):能力注册表(capabilities 表 + 四维风险 + 内置种子 echo/datetime/qiankun.shell/web.fetch)、授契/行契(撤销即失效)、请命(六要素 brief + 超时拒绝 HJ-1302)、行动账(tool_calls 观察标证 + audit 留痕)、乾坤 HMAC 执行、MCP→能力派生;验收四测试(未授契被拒留痕/请命超时不执行/禁域不可关闭专项/结果为观察不入忆)全绿;前端百工页四区。沙箱进程隔离 e2e 预存环境失败与本域无关 |
|
||||
| 天目 / 元炁 | M4/M7 | 局部实现 | 服务可用;元炁待接入神识 Endpoint,天目待接通感 |
|
||||
| 镜树前端 / 记忆治理 / 百工页 | M2+ | 局部实现 | 镜树页与灵胚页已上线(M2c,2026-08-21):路径阅读流/候答切换/采用归真/局部树图/SSE 芽体/导入卡;type-check + vitest 210 passed;记忆治理页随 M3、百工页随 M5 |
|
||||
| 导入(角色卡 V2/V3 → 灵胚) | M2 | 局部实现 | M2b 已验证(2026-08-21):POST /characters/import-card 产出可审阅灵胚(identity_baseline.import_card 暂存 character_book),启镜时物化世谱书+条目(dict/list 双形态兼容);导出方向保持人物→ST V3 快照 |
|
||||
|
||||
@@ -124,10 +124,11 @@
|
||||
⑤ 配置:`config/models|providers|pricing.example.yaml` 归档至 shenshi 目录或删除,`.env.example` 模型段收敛为 SHENSHI_*;
|
||||
⑥ 验收:rg 审计灵台无 Provider SDK import(`rg -l 'openai|anthropic' app/` 排除 shenshi_client 为空);全量后端/前端测试绿;迁移无涉及(纯代码删除)。
|
||||
|
||||
### M5 百工与司命
|
||||
### M5 百工与司命 — ✅ 已完成(2026-08-27)
|
||||
|
||||
- 交付:百工注册表+四维风险标注;授契/行契/请命确认流;乾坤 HMAC 对接与执行审计;博闻登记接入;工具结果净化标证;行动账视图;前端百工页;**删除旧 skills/tool_runtime/safety/mcp 网关 remnants**。
|
||||
- 验收:未授契能力调用被拒并留痕;请命超时不执行;禁域规则无法被任何 Profile/插件关闭(专项测试);工具结果先为观察(不自动成事实/记忆)。
|
||||
- 交付:百工域 `domains/baigong`——能力注册表(`capabilities`:统一契约 + 执行类型 builtin/qiankun/mcp/http + 四维风险标注 capability/data/consequence/context)、授契/行契(`mandates`:grant 单次范围 / charter 预算时间窗重复执行,撤销立即生效)、请命(`pending_confirmations` 改造:六要素说明 + 超时=拒绝 HJ-1302)、行动账(`tool_calls` 净化摘要 + 观察标证 + audit_events 留痕);司命裁决器 `siming.py`(四维取最高档 + **禁域代码常量**:递归删根/格式化/写裸设备/fork 炸弹、file://、密钥外发——任何 Profile/插件/配置不可关闭);乾坤经 SandboxClient HMAC 授契执行;MCP 工具登记/刷新自动派生能力(博闻接入点);`/capabilities` `/mandates` `/petitions` `/tool-calls` 四组端点 + 前端百工页(四区);**旧 skills/tool_runtime/safety/agent 模块与 tool_policies/skills/model_endpoint_overrides 表退役**(迁移 006)。
|
||||
- 验收(tests/test_baigong_siming.py 四用例钉死):① 未授契调用 403 HJ-1303 且 audit_events/tool_calls 双留痕;② 请命超时(expires_at 拨过)approve → 410 HJ-1302、无成功执行;③ 禁域专项(恶意 L0 标注 + 篡改领域对象仍拒 HJ-1301,代码级三形态断言);④ 调用后 memories 表零新增、tool_calls.evidence 带 observation_not_fact 标证。
|
||||
- 证据:后端 454 passed(2 个预存环境失败不变);前端 type-check + 220 passed(百工组件 4 用例 + api 6 用例);四套件(backend/shenshi/bff/frontend)全绿。
|
||||
|
||||
### M6 缘契、人格与世界
|
||||
|
||||
|
||||
@@ -1,20 +1,44 @@
|
||||
import { ToolsConsole } from "@/components/tools/ToolsConsole";
|
||||
import { BaigongWorkshop } from "@/components/baigong/BaigongWorkshop";
|
||||
|
||||
import { serverApi } from "@/lib/api/server-client";
|
||||
import type { ToolInfo } from "@/types/api";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type {
|
||||
Capability,
|
||||
Mandate,
|
||||
Petition,
|
||||
ToolCallRecord,
|
||||
} from "@/types/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* 工具页:日常工具调用控制台。Server 预取 GET /tools 注入 ToolsConsole。
|
||||
* 百工枢治理(能力总览 / 工具注册 / Skill / MCP)在司命台 /admin/pivots/tool_runtime。
|
||||
* 百工页:能力目录 / 授契与行契 / 请命 / 行动账。
|
||||
* 司命裁决在后端(四维 + 禁域不可配置);本页呈现与操作。
|
||||
*/
|
||||
export default async function ToolsPage() {
|
||||
let tools: ToolInfo[] = [];
|
||||
export default async function BaigongPage() {
|
||||
let capabilities: Capability[] = [];
|
||||
let mandates: Mandate[] = [];
|
||||
let petitions: Petition[] = [];
|
||||
let calls: ToolCallRecord[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
tools = await serverApi.get<ToolInfo[]>("/tools");
|
||||
} catch {
|
||||
tools = [];
|
||||
[capabilities, mandates, petitions, calls] = await Promise.all([
|
||||
serverApi.get<Capability[]>("/capabilities"),
|
||||
serverApi.get<Mandate[]>("/mandates"),
|
||||
serverApi.get<Petition[]>("/petitions"),
|
||||
serverApi.get<ToolCallRecord[]>("/tool-calls", { limit: 100 }),
|
||||
]);
|
||||
} catch (e) {
|
||||
error = describeError(e);
|
||||
}
|
||||
return <ToolsConsole initialTools={tools} />;
|
||||
|
||||
return (
|
||||
<BaigongWorkshop
|
||||
initialCapabilities={capabilities}
|
||||
initialMandates={mandates}
|
||||
initialPetitions={petitions}
|
||||
initialCalls={calls}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { PlanConsole } from "@/components/admin/PlanConsole";
|
||||
import { AgentRoster } from "@/components/admin/AgentRoster";
|
||||
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
|
||||
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
|
||||
import { SkillManager } from "@/components/tools/SkillManager";
|
||||
import { serverApi } from "@/lib/api/server-client";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type { Pivot } from "@/types/domain";
|
||||
@@ -30,7 +29,6 @@ import type {
|
||||
PersonalityVersion,
|
||||
PlanSummary,
|
||||
SafetyPolicy,
|
||||
Skill,
|
||||
ToolInfo,
|
||||
WorldEntity,
|
||||
} from "@/types/api";
|
||||
@@ -154,16 +152,14 @@ async function SafetyGovernorDetail() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 百工枢(百工阁):能力总览 + 工具注册表 + Skill 能力包 + MCP 服务器统一治理。 */
|
||||
/** 百工枢(百工阁):能力目录 + 工具注册表 + MCP 服务器统一治理。 */
|
||||
async function ToolRuntimeDetail() {
|
||||
let tools: ToolInfo[] = [];
|
||||
let skills: Skill[] = [];
|
||||
let mcpServers: McpServer[] = [];
|
||||
let error: string | null = null;
|
||||
try {
|
||||
[tools, skills, mcpServers] = await Promise.all([
|
||||
[tools, mcpServers] = await Promise.all([
|
||||
serverApi.get<ToolInfo[]>("/tools"),
|
||||
serverApi.get<Skill[]>("/tools/skills"),
|
||||
serverApi.get<McpServer[]>("/tools/mcp-servers"),
|
||||
]);
|
||||
} catch (e) {
|
||||
@@ -173,7 +169,7 @@ async function ToolRuntimeDetail() {
|
||||
return (
|
||||
<PageContainer
|
||||
title="百工枢"
|
||||
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
|
||||
description="器用技艺 · 能力目录与 MCP 登记统一治理"
|
||||
maxWidth="max-w-5xl"
|
||||
>
|
||||
{error ? (
|
||||
@@ -209,19 +205,6 @@ async function ToolRuntimeDetail() {
|
||||
</section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: "Skill 能力包",
|
||||
badge: skills.length,
|
||||
content: (
|
||||
<section>
|
||||
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
|
||||
Skill 能力包
|
||||
</h2>
|
||||
<SkillManager initial={skills} />
|
||||
</section>
|
||||
),
|
||||
},
|
||||
]}
|
||||
defaultTab="tools"
|
||||
/>
|
||||
|
||||
@@ -3,32 +3,24 @@ import { ErrorState, EmptyState } from "@/components/shared/StateViews";
|
||||
import { ToolRegistry } from "@/components/admin/ToolRegistry";
|
||||
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
|
||||
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
|
||||
import { SkillManager } from "@/components/tools/SkillManager";
|
||||
|
||||
import { serverApi } from "@/lib/api/server-client";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type { McpServer, Skill, ToolInfo } from "@/types/api";
|
||||
import type { McpServer, ToolInfo } from "@/types/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* 百工枢 · 器用技艺治理(对应设计稿 admin-preview.html 百工枢 视图)。
|
||||
*
|
||||
* 三标签页:
|
||||
* ① 工具注册表 — 清单与管理
|
||||
* ② MCP 服务器 — 外部能力接入
|
||||
* ③ Skill 能力包 — 可插拔能力
|
||||
* 百工枢 · 器用治理(能力目录与 MCP 登记;授契/请命/行动账在 /tools 百工页)。
|
||||
*/
|
||||
export default async function AdminToolsPage() {
|
||||
let tools: ToolInfo[] = [];
|
||||
let skills: Skill[] = [];
|
||||
let mcpServers: McpServer[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
[tools, skills, mcpServers] = await Promise.all([
|
||||
[tools, mcpServers] = await Promise.all([
|
||||
serverApi.get<ToolInfo[]>("/tools"),
|
||||
serverApi.get<Skill[]>("/tools/skills"),
|
||||
serverApi.get<McpServer[]>("/tools/mcp-servers"),
|
||||
]);
|
||||
} catch (e) {
|
||||
@@ -38,7 +30,7 @@ export default async function AdminToolsPage() {
|
||||
return (
|
||||
<PageContainer
|
||||
title="百工枢"
|
||||
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
|
||||
description="器用技艺 · 能力目录与 MCP 登记统一治理"
|
||||
maxWidth="max-w-5xl"
|
||||
>
|
||||
{error ? (
|
||||
@@ -80,26 +72,6 @@ export default async function AdminToolsPage() {
|
||||
</section>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: "Skill 能力包",
|
||||
badge: skills.length,
|
||||
content: (
|
||||
<section>
|
||||
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
|
||||
Skill 能力包
|
||||
</h2>
|
||||
{skills.length === 0 ? (
|
||||
<EmptyState
|
||||
title="暂无 Skill"
|
||||
description="当前没有安装 Skill 能力包。"
|
||||
/>
|
||||
) : (
|
||||
<SkillManager initial={skills} />
|
||||
)}
|
||||
</section>
|
||||
),
|
||||
},
|
||||
]}
|
||||
defaultTab="tools"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import { BaigongWorkshop } from "./BaigongWorkshop";
|
||||
import { baigongApi } from "@/lib/api";
|
||||
import type {
|
||||
Capability,
|
||||
Mandate,
|
||||
Petition,
|
||||
ToolCallRecord,
|
||||
} from "@/types/api";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
baigongApi: {
|
||||
grantMandate: vi.fn(),
|
||||
revokeMandate: vi.fn(),
|
||||
approvePetition: vi.fn(),
|
||||
denyPetition: vi.fn(),
|
||||
listMandates: vi.fn(),
|
||||
listPetitions: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const caps: Capability[] = [
|
||||
{
|
||||
id: "cap_1",
|
||||
name: "qiankun.shell",
|
||||
description: "在乾坤沙箱中执行命令",
|
||||
executor_type: "qiankun",
|
||||
executor_ref: "",
|
||||
risk_dims: {
|
||||
capability: "L2",
|
||||
data: "L1",
|
||||
consequence: "L3",
|
||||
context: "L1",
|
||||
},
|
||||
data_scope: "沙箱工作目录",
|
||||
destinations: "本机沙箱进程",
|
||||
source: "builtin",
|
||||
version: "1",
|
||||
consent_mode: "ask",
|
||||
enabled: true,
|
||||
mandated: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mandates: Mandate[] = [
|
||||
{
|
||||
id: "mdt_1",
|
||||
capability_id: "cap_1",
|
||||
character_id: null,
|
||||
kind: "charter",
|
||||
scope: {},
|
||||
budget_total: 20,
|
||||
budget_used: 3,
|
||||
status: "active",
|
||||
granted_at: "2026-08-27T10:00:00Z",
|
||||
expires_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
const petitions: Petition[] = [
|
||||
{
|
||||
id: "confirm_1",
|
||||
capability: "qiankun.shell",
|
||||
risk_level: "L3",
|
||||
reason: "请命:高风险动作需当次确认",
|
||||
brief: {
|
||||
who: "character:c1",
|
||||
what: "在乾坤沙箱中执行命令",
|
||||
consequences: "风险等级 L3",
|
||||
destinations: "本机沙箱进程",
|
||||
},
|
||||
run_id: "run_1",
|
||||
character_id: "c1",
|
||||
created_at: "2026-08-27T10:05:00Z",
|
||||
expires_at: "2026-08-27T10:15:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const calls: ToolCallRecord[] = [
|
||||
{
|
||||
id: "tc_1",
|
||||
capability_id: "cap_1",
|
||||
run_id: "run_1",
|
||||
character_id: "c1",
|
||||
trace_id: null,
|
||||
decision: "allow",
|
||||
mandate_id: "mdt_1",
|
||||
status: "ok",
|
||||
error_code: null,
|
||||
input_digest: "{}",
|
||||
output_digest: "[echo] hi",
|
||||
evidence: {
|
||||
kind: "observation",
|
||||
capability: "qiankun.shell",
|
||||
epistemic: "observation_not_fact",
|
||||
},
|
||||
duration_ms: 2,
|
||||
created_at: "2026-08-27T10:06:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function renderPage(
|
||||
overrides: Partial<Parameters<typeof BaigongWorkshop>[0]> = {},
|
||||
) {
|
||||
return render(
|
||||
<BaigongWorkshop
|
||||
initialCapabilities={caps}
|
||||
initialMandates={mandates}
|
||||
initialPetitions={petitions}
|
||||
initialCalls={calls}
|
||||
error={null}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("BaigongWorkshop(百工)", () => {
|
||||
it("四区渲染:能力目录四维标注 / 请命 / 授契 / 行动账观察标证", () => {
|
||||
renderPage();
|
||||
expect(screen.getByText("能力目录")).toBeInTheDocument();
|
||||
expect(screen.getByText("请命")).toBeInTheDocument();
|
||||
expect(screen.getByText("授契与行契")).toBeInTheDocument();
|
||||
expect(screen.getByText("行动账")).toBeInTheDocument();
|
||||
// 四维风险标注呈现
|
||||
expect(screen.getByText("能力 L2")).toBeInTheDocument();
|
||||
expect(screen.getByText("后果 L3")).toBeInTheDocument();
|
||||
// 观察标证
|
||||
expect(screen.getByText("观察(非事实)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("授行契走 grantMandate 并刷新清单", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(baigongApi.grantMandate).mockResolvedValue({} as Mandate);
|
||||
vi.mocked(baigongApi.listMandates).mockResolvedValue(mandates);
|
||||
renderPage({ initialMandates: [] });
|
||||
await user.click(screen.getByRole("button", { name: /授行契/ }));
|
||||
await waitFor(() => {
|
||||
expect(baigongApi.grantMandate).toHaveBeenCalledWith({
|
||||
capability_id: "cap_1",
|
||||
kind: "charter",
|
||||
budget_total: 20,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("请命批准与拒绝动作", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(baigongApi.approvePetition).mockResolvedValue({
|
||||
status: "resolved",
|
||||
approved: true,
|
||||
});
|
||||
vi.mocked(baigongApi.listPetitions).mockResolvedValue([]);
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: "批准" }));
|
||||
await waitFor(() =>
|
||||
expect(baigongApi.approvePetition).toHaveBeenCalledWith("confirm_1"),
|
||||
);
|
||||
});
|
||||
|
||||
it("撤销授契立即刷新", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(baigongApi.revokeMandate).mockResolvedValue(undefined);
|
||||
vi.mocked(baigongApi.listMandates).mockResolvedValue([]);
|
||||
renderPage();
|
||||
await user.click(screen.getByRole("button", { name: /撤销/ }));
|
||||
await waitFor(() =>
|
||||
expect(baigongApi.revokeMandate).toHaveBeenCalledWith("mdt_1"),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("3/20 次")).toBeNull());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BoxesIcon,
|
||||
KeyRoundIcon,
|
||||
PetitionIcon,
|
||||
ScrollTextIcon,
|
||||
ShieldCheckIcon,
|
||||
TrashIcon,
|
||||
} from "@/components/ui/Icons";
|
||||
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { baigongApi } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type {
|
||||
Capability,
|
||||
Mandate,
|
||||
Petition,
|
||||
ToolCallRecord,
|
||||
} from "@/types/api";
|
||||
|
||||
interface Props {
|
||||
initialCapabilities: Capability[];
|
||||
initialMandates: Mandate[];
|
||||
initialPetitions: Petition[];
|
||||
initialCalls: ToolCallRecord[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RISK_TONE: Record<string, string> = {
|
||||
L0: "text-muted",
|
||||
L1: "text-muted",
|
||||
L2: "text-gold",
|
||||
L3: "text-cinnabar",
|
||||
L4: "text-cinnabar",
|
||||
};
|
||||
|
||||
const DIM_LABELS: [string, string][] = [
|
||||
["capability", "能力"],
|
||||
["data", "数据"],
|
||||
["consequence", "后果"],
|
||||
["context", "情境"],
|
||||
];
|
||||
|
||||
const EXECUTOR_LABEL: Record<string, string> = {
|
||||
builtin: "内置",
|
||||
qiankun: "乾坤",
|
||||
mcp: "MCP",
|
||||
http: "HTTP",
|
||||
};
|
||||
|
||||
/**
|
||||
* 百工:能力目录 / 授契与行契 / 请命 / 行动账 四区。
|
||||
*
|
||||
* 司命在后端裁决(四维 + 禁域不可配置);本页只呈现与操作:
|
||||
* 授契、请命批准/拒绝、行动账观察标证回读。
|
||||
*/
|
||||
export function BaigongWorkshop({
|
||||
initialCapabilities,
|
||||
initialMandates,
|
||||
initialPetitions,
|
||||
initialCalls,
|
||||
error,
|
||||
}: Props) {
|
||||
const [caps] = useState(initialCapabilities);
|
||||
const [mandates, setMandates] = useState(initialMandates);
|
||||
const [petitions, setPetitions] = useState(initialPetitions);
|
||||
const [calls] = useState(initialCalls);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const capName = (id: string) => caps.find((c) => c.id === id)?.name ?? id;
|
||||
|
||||
async function handleGrant(cap: Capability) {
|
||||
setBusy(cap.id);
|
||||
setNotice(null);
|
||||
try {
|
||||
await baigongApi.grantMandate({
|
||||
capability_id: cap.id,
|
||||
kind: "charter",
|
||||
budget_total: 20,
|
||||
});
|
||||
setMandates(await baigongApi.listMandates());
|
||||
setNotice(`已授行契:${cap.name}(预算 20 次)`);
|
||||
} catch (e) {
|
||||
setNotice(e instanceof Error ? e.message : "授契失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(mandate: Mandate) {
|
||||
setBusy(mandate.id);
|
||||
try {
|
||||
await baigongApi.revokeMandate(mandate.id);
|
||||
setMandates(await baigongApi.listMandates());
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePetition(id: string, approved: boolean) {
|
||||
setBusy(id);
|
||||
setNotice(null);
|
||||
try {
|
||||
await (approved
|
||||
? baigongApi.approvePetition(id)
|
||||
: baigongApi.denyPetition(id));
|
||||
setPetitions(await baigongApi.listPetitions());
|
||||
setNotice(approved ? "已批准(当次有效)" : "已拒绝");
|
||||
} catch (e) {
|
||||
setNotice(e instanceof Error ? e.message : "请命处理失败");
|
||||
setPetitions(await baigongApi.listPetitions());
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="glass rounded-2xl p-6 text-sm text-muted">{error}</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{notice && (
|
||||
<div className="glass rounded-xl px-4 py-2 text-sm text-patina">
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══ 请命(最前:有待决先处理) ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<PetitionIcon className="size-4 text-gold" />
|
||||
请命
|
||||
</h3>
|
||||
<span className="set-card-eng">Petitions</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
高风险动作需当次同意;拒绝或超时不执行(HJ-1302)。
|
||||
</p>
|
||||
{petitions.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||
暂无待决请命。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||
{petitions.map((p) => (
|
||||
<li key={p.id} className="px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-serif text-sm text-fg">
|
||||
{p.capability}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-xs",
|
||||
RISK_TONE[p.risk_level],
|
||||
)}
|
||||
>
|
||||
{p.risk_level}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted">
|
||||
{p.brief.what} · {p.brief.consequences} · 去向:
|
||||
{p.brief.destinations || "无"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={busy === p.id}
|
||||
onClick={() => handlePetition(p.id, true)}
|
||||
>
|
||||
批准
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy === p.id}
|
||||
onClick={() => handlePetition(p.id, false)}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 能力目录 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<BoxesIcon className="size-4 text-patina" />
|
||||
能力目录
|
||||
</h3>
|
||||
<span className="set-card-eng">Capabilities</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
统一能力契约:四维风险标注(能力/数据/后果/情境);未授契调用被拒并留痕。
|
||||
</p>
|
||||
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||
{caps.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-serif text-sm text-fg">{c.name}</span>
|
||||
<span className="rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
|
||||
{EXECUTOR_LABEL[c.executor_type] ?? c.executor_type}
|
||||
</span>
|
||||
{c.mandated ? (
|
||||
<span className="flex items-center gap-1 text-xs text-patina">
|
||||
<ShieldCheckIcon className="size-3.5" /> 已授契
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted">未授契</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-muted">
|
||||
{c.description || "(无描述)"}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap gap-2 font-mono text-[10px]">
|
||||
{DIM_LABELS.map(([dim, label]) => {
|
||||
const lv = c.risk_dims[dim];
|
||||
if (!lv) return null;
|
||||
return (
|
||||
<span key={dim} className={RISK_TONE[lv]}>
|
||||
{label} {lv}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{!c.mandated && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy === c.id}
|
||||
onClick={() => handleGrant(c)}
|
||||
>
|
||||
<KeyRoundIcon className="mr-1 size-3.5" />
|
||||
授行契
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* ═══ 授契与行契 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<KeyRoundIcon className="size-4 text-gold" />
|
||||
授契与行契
|
||||
</h3>
|
||||
<span className="set-card-eng">Mandates</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
行契在预算与时间窗内重复执行低风险动作;撤销立即生效。
|
||||
</p>
|
||||
{mandates.filter((m) => m.status === "active").length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||
暂无生效授契。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||
{mandates
|
||||
.filter((m) => m.status === "active")
|
||||
.map((m) => (
|
||||
<li key={m.id} className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="font-mono text-xs text-fg">
|
||||
{capName(m.capability_id)}
|
||||
</span>
|
||||
<span className="ml-2 rounded-full border border-rule px-2 py-0.5 font-mono text-[10px] text-muted">
|
||||
{m.kind === "charter" ? "行契" : "授契"}
|
||||
</span>
|
||||
<span className="ml-2 font-mono text-xs text-muted">
|
||||
{m.budget_total > 0
|
||||
? `${m.budget_used}/${m.budget_total} 次`
|
||||
: "不限次"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy === m.id}
|
||||
onClick={() => handleRevoke(m)}
|
||||
>
|
||||
<TrashIcon className="mr-1 size-3.5" />
|
||||
撤销
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ═══ 行动账 ═══ */}
|
||||
<div className="set-card">
|
||||
<div className="set-card-head">
|
||||
<h3 className="set-card-title flex items-center gap-2">
|
||||
<ScrollTextIcon className="size-4 text-patina" />
|
||||
行动账
|
||||
</h3>
|
||||
<span className="set-card-eng">Ledger</span>
|
||||
</div>
|
||||
<p className="set-card-desc">
|
||||
全部能力调用的净化摘要与观察标证——结果是观察,不是事实。
|
||||
</p>
|
||||
{calls.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-rule p-4 text-center text-sm text-muted">
|
||||
暂无调用记录。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-rule rounded-xl border border-rule">
|
||||
{calls.slice(0, 20).map((t) => (
|
||||
<li key={t.id} className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-fg">
|
||||
{capName(t.capability_id)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full border px-2 py-0.5 font-mono text-[10px]",
|
||||
t.decision.startsWith("denied")
|
||||
? "border-cinnabar/40 text-cinnabar"
|
||||
: t.decision === "ask"
|
||||
? "border-gold/40 text-gold"
|
||||
: "border-rule text-muted",
|
||||
)}
|
||||
>
|
||||
{t.decision}
|
||||
</span>
|
||||
{t.evidence.epistemic === "observation_not_fact" && (
|
||||
<span className="text-[10px] text-muted">
|
||||
观察(非事实)
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto font-mono text-[10px] text-muted">
|
||||
{t.created_at?.slice(11, 19)}
|
||||
</span>
|
||||
</div>
|
||||
{t.output_digest && (
|
||||
<p className="mt-1 line-clamp-2 font-mono text-xs text-muted">
|
||||
{t.output_digest}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { skillsApi } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
import type { Skill } from "@/types/api";
|
||||
import type { RiskLevel } from "@/types/domain";
|
||||
|
||||
const RISK_OPTIONS: RiskLevel[] = ["L0", "L1", "L2", "L3", "L4"];
|
||||
|
||||
type Draft = {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
prompt_fragment: string;
|
||||
tool_bindings: string;
|
||||
risk_level: RiskLevel;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const EMPTY: Draft = {
|
||||
name: "",
|
||||
description: "",
|
||||
trigger: "",
|
||||
prompt_fragment: "",
|
||||
tool_bindings: "",
|
||||
risk_level: "L1",
|
||||
status: "enabled",
|
||||
};
|
||||
|
||||
/**
|
||||
* Skill 能力包管理(百工枢,/tools/skills)。
|
||||
* 列表 + 创建/编辑(inline 表单)+ 启用/禁用 + 删除。乐观本地态,写后直接更新列表。
|
||||
*/
|
||||
export function SkillManager({ initial }: { initial: Skill[] }) {
|
||||
const [skills, setSkills] = useState(initial);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY);
|
||||
|
||||
function parseBindings(text: string): string[] {
|
||||
return text
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function startEdit(s: Skill) {
|
||||
setShowAdd(false);
|
||||
setEditingId(s.id);
|
||||
setDraft({
|
||||
name: s.name,
|
||||
description: s.description,
|
||||
trigger: s.trigger,
|
||||
prompt_fragment: s.prompt_fragment,
|
||||
tool_bindings: s.tool_bindings.join(", "),
|
||||
risk_level: s.risk_level,
|
||||
status: s.status,
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!draft.name.trim()) {
|
||||
setError("请填写能力包名称。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await skillsApi.createSkill({
|
||||
name: draft.name.trim(),
|
||||
description: draft.description,
|
||||
trigger: draft.trigger,
|
||||
prompt_fragment: draft.prompt_fragment,
|
||||
tool_bindings: parseBindings(draft.tool_bindings),
|
||||
risk_level: draft.risk_level,
|
||||
status: draft.status,
|
||||
});
|
||||
setSkills((p) => [...p, created]);
|
||||
setDraft(EMPTY);
|
||||
setShowAdd(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "创建失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save(id: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await skillsApi.updateSkill(id, {
|
||||
description: draft.description,
|
||||
trigger: draft.trigger,
|
||||
prompt_fragment: draft.prompt_fragment,
|
||||
tool_bindings: parseBindings(draft.tool_bindings),
|
||||
risk_level: draft.risk_level,
|
||||
status: draft.status,
|
||||
});
|
||||
setSkills((p) => p.map((s) => (s.id === id ? updated : s)));
|
||||
setEditingId(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(s: Skill) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await skillsApi.updateSkill(s.id, {
|
||||
status: s.status === "enabled" ? "disabled" : "enabled",
|
||||
});
|
||||
setSkills((p) => p.map((x) => (x.id === s.id ? updated : x)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "切换失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: Skill) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await skillsApi.deleteSkill(s.id);
|
||||
setSkills((p) => p.filter((x) => x.id !== s.id));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败。");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
|
||||
|
||||
function renderForm(
|
||||
onSave: () => void,
|
||||
onCancel: () => void,
|
||||
isNew: boolean,
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<input
|
||||
value={draft.name}
|
||||
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||
placeholder="名称(唯一)"
|
||||
className={inputCls}
|
||||
disabled={!isNew}
|
||||
/>
|
||||
<input
|
||||
value={draft.trigger}
|
||||
onChange={(e) => setDraft({ ...draft, trigger: e.target.value })}
|
||||
placeholder="触发条件"
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
value={draft.description}
|
||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||
placeholder="描述"
|
||||
className={inputCls}
|
||||
/>
|
||||
<textarea
|
||||
value={draft.prompt_fragment}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, prompt_fragment: e.target.value })
|
||||
}
|
||||
placeholder="提示词片段"
|
||||
rows={2}
|
||||
className={inputCls}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
value={draft.tool_bindings}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, tool_bindings: e.target.value })
|
||||
}
|
||||
placeholder="绑定工具(逗号分隔)"
|
||||
className={`${inputCls} min-w-[12rem] flex-1`}
|
||||
/>
|
||||
<select
|
||||
value={draft.risk_level}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, risk_level: e.target.value as RiskLevel })
|
||||
}
|
||||
className={`${inputCls} w-20`}
|
||||
aria-label="风险等级"
|
||||
>
|
||||
{RISK_OPTIONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={busy}
|
||||
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
|
||||
>
|
||||
{isNew ? "注册" : "保存"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted">共 {skills.length} 个能力包</p>
|
||||
{!showAdd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setDraft(EMPTY);
|
||||
setEditingId(null);
|
||||
setShowAdd(true);
|
||||
}}
|
||||
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina"
|
||||
>
|
||||
+ 注册能力包
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdd &&
|
||||
renderForm(
|
||||
() => add(),
|
||||
() => setShowAdd(false),
|
||||
true,
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{skills.map((s) => (
|
||||
<li key={s.id} className="glass rounded-xl px-4 py-3">
|
||||
{editingId === s.id ? (
|
||||
renderForm(
|
||||
() => save(s.id),
|
||||
() => setEditingId(null),
|
||||
false,
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-serif text-sm font-semibold text-fg">
|
||||
{s.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[10px]",
|
||||
s.status === "enabled"
|
||||
? "bg-patina/15 text-patina"
|
||||
: "bg-fg/10 text-muted",
|
||||
)}
|
||||
>
|
||||
{s.status}
|
||||
</span>
|
||||
<span className="rounded bg-cinnabar/10 px-1.5 py-0.5 text-[10px] text-cinnabar">
|
||||
{s.risk_level}
|
||||
</span>
|
||||
{s.tool_bindings.length > 0 && (
|
||||
<span className="text-[10px] text-muted">
|
||||
绑定:{s.tool_bindings.join("、")}
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleStatus(s)}
|
||||
disabled={busy}
|
||||
className="text-[11px] text-patina hover:underline disabled:opacity-50"
|
||||
>
|
||||
{s.status === "enabled" ? "禁用" : "启用"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(s)}
|
||||
className="text-[11px] text-patina hover:underline"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(s)}
|
||||
disabled={busy}
|
||||
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{s.trigger && (
|
||||
<p className="mt-1 text-xs text-muted">触发:{s.trigger}</p>
|
||||
)}
|
||||
{s.prompt_fragment && (
|
||||
<p className="mt-0.5 text-xs text-fg/80">
|
||||
{s.prompt_fragment}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{error && <p className="text-xs text-cinnabar">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
import { Badge, type BadgeTone } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { cn } from "@/lib/utils/cn";
|
||||
|
||||
const RISK_TONE: Record<string, BadgeTone> = {
|
||||
L0: "info",
|
||||
L1: "success",
|
||||
L2: "warning",
|
||||
L3: "warning",
|
||||
L4: "danger",
|
||||
};
|
||||
|
||||
/** 朱砂浓度档位(与设计稿 risk-l1/l3 同源):L0/L1 浅描 / L2 朱块(基类) / L3/L4 满朱晕染 */
|
||||
const RISK_CONCENTRATION: Record<string, string> = {
|
||||
L0: "vermilion-risk-l1",
|
||||
L1: "vermilion-risk-l1",
|
||||
L2: "",
|
||||
L3: "vermilion-risk-l3",
|
||||
L4: "vermilion-risk-l3",
|
||||
};
|
||||
|
||||
export interface ToolConfirmationCardProps {
|
||||
tool: string;
|
||||
riskLevel: string;
|
||||
reason: string;
|
||||
args?: string;
|
||||
onApprove: () => void;
|
||||
onDeny: () => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具确认卡片:高风险工具执行前的二次确认(琉璃镜浮层)。
|
||||
* L4 用朱砂主按钮,L2/L3 用青玉主按钮;弹簧入场,矿物色徽章标风险。
|
||||
*/
|
||||
export function ToolConfirmationCard({
|
||||
tool,
|
||||
riskLevel,
|
||||
reason,
|
||||
args,
|
||||
onApprove,
|
||||
onDeny,
|
||||
loading = false,
|
||||
}: ToolConfirmationCardProps) {
|
||||
const tone = RISK_TONE[riskLevel] ?? "warning";
|
||||
const dangerous = riskLevel === "L4";
|
||||
const concentration = RISK_CONCENTRATION[riskLevel] ?? "";
|
||||
// 尝试格式化 args 为美观的 JSON
|
||||
let formattedArgs = args;
|
||||
if (args) {
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
formattedArgs = JSON.stringify(parsed, null, 2);
|
||||
} catch {
|
||||
// 保持原样
|
||||
}
|
||||
}
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 24 }}
|
||||
className={cn(
|
||||
"vermilion rounded-2xl p-4 shadow-lg shadow-black/10",
|
||||
concentration,
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge tone={tone}>{riskLevel}</Badge>
|
||||
<span className="font-serif text-sm font-semibold text-fg">
|
||||
工具确认 · {tool}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mb-2 text-sm text-fg/80">{reason}</p>
|
||||
{formattedArgs && (
|
||||
<pre className="mb-3 max-h-40 overflow-auto rounded-lg bg-black/20 p-2 font-mono text-xs text-fg/70">
|
||||
{formattedArgs}
|
||||
</pre>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onDeny} disabled={loading}>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
variant={dangerous ? "danger" : "primary"}
|
||||
size="sm"
|
||||
onClick={onApprove}
|
||||
loading={loading}
|
||||
>
|
||||
允许执行
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
import { Badge, type BadgeTone } from "@/components/ui/Badge";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { createRunStream } from "@/lib/sse-client";
|
||||
import type { RunEvent } from "@/types/api";
|
||||
|
||||
const TONE: Record<string, BadgeTone> = {
|
||||
"tool.started": "info",
|
||||
"tool.completed": "success",
|
||||
"tool.failed": "danger",
|
||||
};
|
||||
|
||||
function summarize(event: RunEvent): string {
|
||||
const payload = event.payload;
|
||||
const tool = String(payload.tool ?? "");
|
||||
if (event.type === "tool.started") {
|
||||
const argc = Array.isArray(payload.argv) ? payload.argv.length : 0;
|
||||
return `${tool} · ${argc} 参数`;
|
||||
}
|
||||
if (event.type === "tool.completed") {
|
||||
return `${tool} · exit=${String(payload.exit_code ?? "?")} · ${String(payload.duration_ms ?? "?")}ms`;
|
||||
}
|
||||
if (event.type === "tool.failed") {
|
||||
return `${tool} · ${String(payload.error_code ?? "")}`;
|
||||
}
|
||||
return JSON.stringify(payload).slice(0, 80);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run 事件流面板:订阅 SSE /runs/{run_id}/stream,实时展示 tool.* 事件。
|
||||
* 非 Mock 模式下走真实 EventSource;带 run 内工具调用时可见 tool.started/completed 实时到达。
|
||||
*/
|
||||
export function ToolEventStream({ runId }: { runId: string }) {
|
||||
const [events, setEvents] = useState<RunEvent[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// SSE 订阅依赖回调更新连接与事件状态,此场景关闭 set-state-in-effect 检查
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
if (!runId) return;
|
||||
setEvents([]);
|
||||
const controller = createRunStream(runId, {
|
||||
onOpen: () => setConnected(true),
|
||||
onError: () => setConnected(false),
|
||||
onEvent: (event) => {
|
||||
if (event.type.startsWith("tool.")) {
|
||||
setEvents((prev) => [...prev.slice(-30), event]);
|
||||
}
|
||||
},
|
||||
});
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
return () => controller.close();
|
||||
}, [runId]);
|
||||
|
||||
return (
|
||||
<Card className="p-4">
|
||||
<CardHeader
|
||||
title="Run 事件流(SSE)"
|
||||
subtitle={`订阅 /runs/${runId}/stream · 实时推送 tool.* 事件`}
|
||||
action={
|
||||
<Badge tone={connected ? "success" : "neutral"}>
|
||||
{connected ? "已连接" : "连接中"}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
{events.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted">
|
||||
暂无工具事件。带上方 run_id 调用工具后,事件将实时出现在此。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{events.map((event) => (
|
||||
<motion.div
|
||||
key={event.event_id}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex items-center gap-2 rounded-lg bg-fg/5 px-2.5 py-1.5"
|
||||
>
|
||||
<Badge tone={TONE[event.type] ?? "neutral"}>{event.type}</Badge>
|
||||
<span className="truncate font-mono text-xs text-muted">
|
||||
{summarize(event)}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { PageContainer } from "@/components/layout/PageContainer";
|
||||
import { Badge, type BadgeTone } from "@/components/ui/Badge";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardHeader } from "@/components/ui/Card";
|
||||
import { ToolConfirmationCard } from "@/components/tools/ToolConfirmationCard";
|
||||
import { ToolEventStream } from "@/components/tools/ToolEventStream";
|
||||
import { useToolStream } from "@/lib/hooks/useToolStream";
|
||||
import type { ToolInfo } from "@/types/api";
|
||||
|
||||
type InputType = "text" | "textarea";
|
||||
|
||||
interface ToolInputDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: InputType;
|
||||
placeholder?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前端预置的输入表单定义(按工具名映射)。后端 ToolSpec 不暴露入参 schema,
|
||||
* 故表单结构由前端维护;后端新增且此处未映射的工具走 JSON 通用输入,
|
||||
* 保证任何后端注册的工具都能被调用。
|
||||
*/
|
||||
const INPUTS_BY_NAME: Record<string, ToolInputDef[]> = {
|
||||
echo: [{ key: "text", label: "文本", default: "琉璃之镜" }],
|
||||
datetime: [],
|
||||
calc: [{ key: "expr", label: "表达式", default: "6 * 7" }],
|
||||
read_file: [{ key: "path", label: "文件路径", default: "note.txt" }],
|
||||
list_dir: [{ key: "path", label: "目录路径", default: "." }],
|
||||
write_file: [
|
||||
{ key: "path", label: "文件路径", default: "note.txt" },
|
||||
{ key: "content", label: "内容", type: "textarea", default: "由百工写入" },
|
||||
],
|
||||
shell: [
|
||||
{
|
||||
key: "command",
|
||||
label: "命令",
|
||||
type: "textarea",
|
||||
default: "for i in 1 2 3; do echo line-$i; sleep 0.3; done",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const RISK_TONE: Record<string, BadgeTone> = {
|
||||
L0: "info",
|
||||
L1: "success",
|
||||
L2: "warning",
|
||||
L3: "warning",
|
||||
L4: "danger",
|
||||
};
|
||||
|
||||
/** 未预置表单的工具用此 key 承载 JSON 文本输入 */
|
||||
const JSON_FALLBACK_KEY = "__json__";
|
||||
|
||||
function defaultsForInputs(
|
||||
inputs: ToolInputDef[] | null,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
if (inputs) {
|
||||
for (const f of inputs) values[f.key] = f.default ?? "";
|
||||
} else {
|
||||
values[JSON_FALLBACK_KEY] = "{\n \n}\n";
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 百工工具控制台(Client)。工具清单来自后端 ``GET /tools``(由 Server Component
|
||||
* 预取注入,``force-dynamic`` 保证每次进入页面拉到最新列表,反映后端注册/注销变更);
|
||||
* 输入表单按工具名在前端映射,未映射工具提供 JSON 通用输入。
|
||||
*/
|
||||
export function ToolsConsole({ initialTools }: { initialTools: ToolInfo[] }) {
|
||||
const [toolName, setToolName] = useState<string>(initialTools[0]?.name ?? "");
|
||||
const tool = useMemo(
|
||||
() => initialTools.find((t) => t.name === toolName) ?? initialTools[0],
|
||||
[initialTools, toolName],
|
||||
);
|
||||
const inputs = tool ? (INPUTS_BY_NAME[tool.name] ?? null) : null;
|
||||
const [values, setValues] = useState<Record<string, string>>(
|
||||
defaultsForInputs(inputs),
|
||||
);
|
||||
const [runId, setRunId] = useState<string>("run_demo");
|
||||
const { state, stdout, stderr, busy, run, confirm, cancel, reset } =
|
||||
useToolStream();
|
||||
|
||||
const selectTool = (name: string) => {
|
||||
setToolName(name);
|
||||
setValues(defaultsForInputs(INPUTS_BY_NAME[name] ?? null));
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleInvoke = () => {
|
||||
if (!tool) return;
|
||||
let input: Record<string, unknown>;
|
||||
if (inputs) {
|
||||
input = values;
|
||||
} else {
|
||||
// 未预置表单的工具,由用户直接编辑 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(values[JSON_FALLBACK_KEY] || "{}");
|
||||
input =
|
||||
parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
input = {};
|
||||
}
|
||||
}
|
||||
void run(tool.name, input, { run_id: runId || undefined });
|
||||
};
|
||||
|
||||
const update = (key: string, value: string) =>
|
||||
setValues((prev) => ({ ...prev, [key]: value }));
|
||||
|
||||
const streaming = state.phase === "streaming";
|
||||
const completed = state.phase === "completed";
|
||||
|
||||
if (initialTools.length === 0) {
|
||||
return (
|
||||
<PageContainer
|
||||
title="百工工具"
|
||||
description="经司命审核与乾坤沙箱隔离执行;L0/L1 自动放行,L2/L3 需二次确认。"
|
||||
>
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-muted">
|
||||
后端暂无已注册工具,或工具列表加载失败。请确认后端已启动并完成工具注册。
|
||||
</p>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer
|
||||
title="百工工具"
|
||||
description="经司命审核与乾坤沙箱隔离执行;L0/L1 自动放行,L2/L3 需二次确认。stdout/stderr 流式实时回写。"
|
||||
>
|
||||
<Card className="p-4">
|
||||
<CardHeader
|
||||
title="调用工具"
|
||||
subtitle="输出实时流式返回;文件类工具共享沙箱内 workspace 目录(按 run 隔离)。"
|
||||
/>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{initialTools.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onClick={() => selectTool(t.name)}
|
||||
className={
|
||||
tool?.name === t.name
|
||||
? "rounded-xl border border-patina/30 bg-patina/15 px-3 py-1.5 font-mono text-sm text-patina"
|
||||
: "glass rounded-xl px-3 py-1.5 font-mono text-sm text-muted transition-colors hover:text-fg"
|
||||
}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
{tool && (
|
||||
<Badge tone={RISK_TONE[tool.risk_level] ?? "neutral"}>
|
||||
{tool.risk_level}
|
||||
</Badge>
|
||||
)}
|
||||
<span>{tool?.description}</span>
|
||||
</div>
|
||||
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-xs text-muted">
|
||||
Run ID(事件归属;留空则不写入 run 流)
|
||||
</span>
|
||||
<input
|
||||
value={runId}
|
||||
onChange={(e) => setRunId(e.target.value)}
|
||||
placeholder="例如:run_demo"
|
||||
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{inputs === null ? (
|
||||
// 后端工具未在前端预置表单
|
||||
<label className="mb-3 block">
|
||||
<span className="mb-1 block text-xs text-muted">
|
||||
输入参数(JSON 对象)
|
||||
</span>
|
||||
<textarea
|
||||
value={values[JSON_FALLBACK_KEY] ?? ""}
|
||||
onChange={(e) => update(JSON_FALLBACK_KEY, e.target.value)}
|
||||
rows={5}
|
||||
placeholder='{"key": "value"}'
|
||||
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
|
||||
/>
|
||||
</label>
|
||||
) : inputs.length === 0 ? (
|
||||
<p className="mb-3 text-sm text-muted">
|
||||
该工具无需输入,直接执行即可。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mb-3 flex flex-col gap-3">
|
||||
{inputs.map((field) => (
|
||||
<label key={field.key} className="block">
|
||||
<span className="mb-1 block text-xs text-muted">
|
||||
{field.label}
|
||||
</span>
|
||||
{field.type === "textarea" ? (
|
||||
<textarea
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(e) => update(field.key, e.target.value)}
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
value={values[field.key] ?? ""}
|
||||
onChange={(e) => update(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-xl border border-border-glass bg-glass px-3 py-2 font-mono text-sm text-fg outline-none focus:border-patina/40"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleInvoke}
|
||||
loading={busy && streaming}
|
||||
>
|
||||
执行
|
||||
</Button>
|
||||
{streaming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
className="glass rounded-xl px-4 py-2 text-sm text-cinnabar transition-colors hover:text-cinnabar/80"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{state.phase === "confirming" && (
|
||||
<div className="mt-4">
|
||||
<ToolConfirmationCard
|
||||
tool={state.tool}
|
||||
riskLevel={state.risk_level}
|
||||
reason={state.reason}
|
||||
onApprove={() => void confirm()}
|
||||
onDeny={() => reset()}
|
||||
loading={busy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(streaming || completed) && (
|
||||
<Card className="mt-4 p-4">
|
||||
<CardHeader
|
||||
title={streaming ? "流式输出" : "执行结果"}
|
||||
action={
|
||||
completed ? (
|
||||
<Badge
|
||||
tone={
|
||||
(state.done.exit_code ?? 1) === 0 ? "success" : "danger"
|
||||
}
|
||||
>
|
||||
{(state.done.exit_code ?? 1) === 0 ? "成功" : "失败"}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="info">流式中</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{stdout && (
|
||||
<pre className="mb-2 max-h-80 overflow-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-emerald-300">
|
||||
{stdout}
|
||||
</pre>
|
||||
)}
|
||||
{stderr && (
|
||||
<pre className="mb-2 max-h-80 overflow-auto rounded-lg bg-black/40 p-3 font-mono text-xs text-cinnabar">
|
||||
{stderr}
|
||||
</pre>
|
||||
)}
|
||||
{!stdout && !stderr && streaming && (
|
||||
<p className="py-2 text-xs text-muted">等待输出…</p>
|
||||
)}
|
||||
{completed && (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-muted">
|
||||
<span>exit: {state.done.exit_code ?? "null"}</span>
|
||||
<span>耗时: {state.done.duration_ms}ms</span>
|
||||
{state.done.timeout && <Badge tone="warning">超时</Badge>}
|
||||
{state.done.cancelled && <Badge tone="warning">已取消</Badge>}
|
||||
{state.done.truncated && <Badge tone="warning">截断</Badge>}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{state.phase === "error" && (
|
||||
<Card className="mt-4 border-cinnabar/30 p-4">
|
||||
<p className="text-sm text-cinnabar">{state.message}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{runId && (
|
||||
<div className="mt-4">
|
||||
<ToolEventStream runId={runId} />
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -843,3 +843,136 @@ export function CircleAlertIcon(props: IconProps) {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 能力盒(Lucide Boxes) */
|
||||
export function BoxesIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M3 4.5 7.5 2l4.5 2.5v5L7.5 12 3 9.5v-5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 4.5 16.5 2 21 4.5v5L16.5 12 12 9.5v-5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 12v5l4.5 2.5 4.5-2.5v-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 卷轴(Lucide ScrollText) */
|
||||
export function ScrollTextIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M15 12h-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M15 8h-5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M19 17V5a2 2 0 0 0-2-2H4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 请命(Lucide MessageSquareWarning) */
|
||||
export function PetitionIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 7v4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M12 14h.01"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 盾勾(Lucide ShieldCheck) */
|
||||
export function ShieldCheckIcon(props: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="m9 12 2 2 4-4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./client", () => ({
|
||||
apiDelete: vi.fn(),
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "./client";
|
||||
import {
|
||||
approvePetition,
|
||||
denyPetition,
|
||||
grantMandate,
|
||||
invokeCapability,
|
||||
listCapabilities,
|
||||
listMandates,
|
||||
listPetitions,
|
||||
listToolCalls,
|
||||
revokeMandate,
|
||||
} from "./baigong";
|
||||
|
||||
describe("baigong api(能力/授契/请命/行动账)", () => {
|
||||
it("listCapabilities GET /capabilities", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
await listCapabilities();
|
||||
expect(apiGet).toHaveBeenCalledWith("/capabilities");
|
||||
});
|
||||
|
||||
it("invokeCapability POST 带 URI 编码与 confirmed 回执", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({ status: "ok" });
|
||||
await invokeCapability("cap/a b", { params: { x: 1 }, confirmed: true });
|
||||
expect(apiPost).toHaveBeenCalledWith("/capabilities/cap%2Fa%20b/invoke", {
|
||||
params: { x: 1 },
|
||||
confirmed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("grantMandate POST /mandates", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({ id: "mdt_1" });
|
||||
await grantMandate({
|
||||
capability_id: "cap_1",
|
||||
kind: "charter",
|
||||
budget_total: 20,
|
||||
});
|
||||
expect(apiPost).toHaveBeenCalledWith("/mandates", {
|
||||
capability_id: "cap_1",
|
||||
kind: "charter",
|
||||
budget_total: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("revokeMandate DELETE 带 URI 编码", async () => {
|
||||
vi.mocked(apiDelete).mockResolvedValue(undefined);
|
||||
await revokeMandate("mdt/a");
|
||||
expect(apiDelete).toHaveBeenCalledWith("/mandates/mdt%2Fa");
|
||||
});
|
||||
|
||||
it("petition approve/deny 与清单", async () => {
|
||||
vi.mocked(apiPost).mockResolvedValue({
|
||||
status: "resolved",
|
||||
approved: true,
|
||||
});
|
||||
await approvePetition("c1");
|
||||
expect(apiPost).toHaveBeenCalledWith("/petitions/c1/approve", {});
|
||||
await denyPetition("c1");
|
||||
expect(apiPost).toHaveBeenCalledWith("/petitions/c1/deny", {});
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
await listPetitions();
|
||||
expect(apiGet).toHaveBeenCalledWith("/petitions");
|
||||
});
|
||||
|
||||
it("listToolCalls GET /tool-calls 带 limit", async () => {
|
||||
vi.mocked(apiGet).mockResolvedValue([]);
|
||||
await listToolCalls(50);
|
||||
expect(apiGet).toHaveBeenCalledWith("/tool-calls", { limit: 50 });
|
||||
await listMandates();
|
||||
expect(apiGet).toHaveBeenCalledWith("/mandates");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 百工 API Client:能力目录 / 授契与行契 / 请命 / 行动账。
|
||||
*
|
||||
* 未授契能力调用被拒(HJ-1303 留痕);高风险动作请命,超时不执行(HJ-1302);
|
||||
* 禁域由后端代码常量裁决,前端不可配置。
|
||||
*/
|
||||
|
||||
import { apiDelete, apiGet, apiPost } from "./client";
|
||||
import type {
|
||||
Capability,
|
||||
InvokeResult,
|
||||
Mandate,
|
||||
Petition,
|
||||
ToolCallRecord,
|
||||
} from "@/types/api";
|
||||
|
||||
/** 能力目录(含四维风险与授契状态) */
|
||||
export function listCapabilities() {
|
||||
return apiGet<Capability[]>("/capabilities");
|
||||
}
|
||||
|
||||
/** 调用能力(params 自由形;confirmed 为请命批准后的回执) */
|
||||
export function invokeCapability(
|
||||
capabilityId: string,
|
||||
body: {
|
||||
params: Record<string, unknown>;
|
||||
confirmed?: boolean;
|
||||
run_id?: string;
|
||||
character_id?: string;
|
||||
},
|
||||
) {
|
||||
return apiPost<InvokeResult>(
|
||||
`/capabilities/${encodeURIComponent(capabilityId)}/invoke`,
|
||||
body,
|
||||
);
|
||||
}
|
||||
|
||||
/** 授契/行契清单 */
|
||||
export function listMandates() {
|
||||
return apiGet<Mandate[]>("/mandates");
|
||||
}
|
||||
|
||||
/** 授予授契(grant)或行契(charter:预算/时间窗内重复执行) */
|
||||
export function grantMandate(body: {
|
||||
capability_id: string;
|
||||
kind: "grant" | "charter";
|
||||
budget_total?: number;
|
||||
}) {
|
||||
return apiPost<Mandate>("/mandates", body);
|
||||
}
|
||||
|
||||
/** 撤销授契(立即生效) */
|
||||
export function revokeMandate(mandateId: string) {
|
||||
return apiDelete<void>(`/mandates/${encodeURIComponent(mandateId)}`);
|
||||
}
|
||||
|
||||
/** 待决请命清单 */
|
||||
export function listPetitions() {
|
||||
return apiGet<Petition[]>("/petitions");
|
||||
}
|
||||
|
||||
/** 批准请命(当次执行;客户端随后以 confirmed 重调 invoke) */
|
||||
export function approvePetition(petitionId: string) {
|
||||
return apiPost<{ status: string; approved: boolean }>(
|
||||
`/petitions/${encodeURIComponent(petitionId)}/approve`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** 拒绝请命 */
|
||||
export function denyPetition(petitionId: string) {
|
||||
return apiPost<{ status: string; approved: boolean }>(
|
||||
`/petitions/${encodeURIComponent(petitionId)}/deny`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/** 行动账(净化摘要 + 观察标证) */
|
||||
export function listToolCalls(limit = 100) {
|
||||
return apiGet<ToolCallRecord[]>("/tool-calls", { limit });
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export * as safetyApi from "./safety";
|
||||
export * as adminApi from "./admin";
|
||||
export * as settingsApi from "./settings";
|
||||
export * as meApi from "./me";
|
||||
export * as baigongApi from "./baigong";
|
||||
export * as importsApi from "./imports";
|
||||
export * as extractionApi from "./extraction";
|
||||
export * as worldApi from "./world";
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* useToolStream:流式工具调用状态机。
|
||||
*
|
||||
* 通过 invokeToolStream(fetch + ReadableStream)订阅 SSE,实时把 stdout/stderr 增量
|
||||
* 解码后 append 到 stdout/stderr 状态,供组件即时渲染。高风险工具经
|
||||
* tool.requires_confirmation 进入 confirming 态,confirm 后以 confirmed=true 重跑。
|
||||
* 支持 AbortController 中止(cancel)。组件卸载时应调用 cancel/reset。
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
import { invokeToolStream } from "@/lib/api/tools";
|
||||
import { describeError } from "@/lib/utils/error";
|
||||
import type { ToolStreamDone } from "@/types/api";
|
||||
|
||||
export type ToolStreamState =
|
||||
| { phase: "idle" }
|
||||
| { phase: "streaming"; tool: string }
|
||||
| { phase: "completed"; done: ToolStreamDone }
|
||||
| {
|
||||
phase: "confirming";
|
||||
tool: string;
|
||||
risk_level: string;
|
||||
reason: string;
|
||||
}
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
export interface InvokeContext {
|
||||
run_id?: string;
|
||||
character_id?: string;
|
||||
trace_id?: string;
|
||||
}
|
||||
|
||||
export function useToolStream() {
|
||||
const [state, setState] = useState<ToolStreamState>({ phase: "idle" });
|
||||
const [stdout, setStdout] = useState("");
|
||||
const [stderr, setStderr] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
tool: string;
|
||||
input: Record<string, unknown>;
|
||||
ctx?: InvokeContext;
|
||||
} | null>(null);
|
||||
|
||||
const runOnce = useCallback(
|
||||
async (
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx: InvokeContext | undefined,
|
||||
confirmed: boolean,
|
||||
) => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setStdout("");
|
||||
setStderr("");
|
||||
setBusy(true);
|
||||
setState({ phase: "streaming", tool });
|
||||
try {
|
||||
await invokeToolStream(
|
||||
tool,
|
||||
{ input, confirmed, ...ctx },
|
||||
{
|
||||
onStdout: (data) =>
|
||||
setStdout((prev) => prev + decodeB64UrlNoPad(data)),
|
||||
onStderr: (data) =>
|
||||
setStderr((prev) => prev + decodeB64UrlNoPad(data)),
|
||||
onCompleted: (done) => setState({ phase: "completed", done }),
|
||||
onRequiresConfirmation: (risk_level, reason) =>
|
||||
setState({ phase: "confirming", tool, risk_level, reason }),
|
||||
onFailed: (code, message) =>
|
||||
setState({ phase: "error", message: `${code}: ${message}` }),
|
||||
},
|
||||
ctrl.signal,
|
||||
);
|
||||
} catch (e) {
|
||||
if (ctrl.signal.aborted) {
|
||||
setState({ phase: "idle" });
|
||||
} else {
|
||||
setState({ phase: "error", message: describeError(e) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
async (
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
ctx?: InvokeContext,
|
||||
) => {
|
||||
pendingRef.current = { tool, input, ctx };
|
||||
await runOnce(tool, input, ctx, false);
|
||||
},
|
||||
[runOnce],
|
||||
);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
const pending = pendingRef.current;
|
||||
if (!pending) return;
|
||||
await runOnce(pending.tool, pending.input, pending.ctx, true);
|
||||
}, [runOnce]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ phase: "idle" });
|
||||
setStdout("");
|
||||
setStderr("");
|
||||
}, []);
|
||||
|
||||
return { state, stdout, stderr, busy, run, confirm, cancel, reset };
|
||||
}
|
||||
|
||||
/** 解码 base64(urlsafe-no-pad) → UTF-8 字符串(命令输出可能含多字节)。 */
|
||||
function decodeB64UrlNoPad(b64: string): string {
|
||||
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const binary = atob(std + "=".repeat((4 - (std.length % 4)) % 4));
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
@@ -82,6 +82,151 @@ export function resolveMock(
|
||||
// —— 健康检查 ——
|
||||
if (m === "GET" && path === "/health") return ok(data.health);
|
||||
|
||||
// —— 百工:能力目录 / 授契 / 请命 / 行动账 ——
|
||||
if (m === "GET" && path === "/capabilities") {
|
||||
return ok([
|
||||
{
|
||||
id: "cap_mock_001",
|
||||
name: "builtin.echo",
|
||||
description: "回显输入(无副作用演示能力)",
|
||||
executor_type: "builtin",
|
||||
executor_ref: "echo",
|
||||
risk_dims: {
|
||||
capability: "L0",
|
||||
data: "L0",
|
||||
consequence: "L0",
|
||||
context: "L0",
|
||||
},
|
||||
data_scope: "",
|
||||
destinations: "",
|
||||
source: "builtin",
|
||||
version: "1",
|
||||
consent_mode: "allow",
|
||||
enabled: true,
|
||||
mandated: true,
|
||||
},
|
||||
{
|
||||
id: "cap_mock_002",
|
||||
name: "qiankun.shell",
|
||||
description: "在乾坤沙箱中执行命令(隔离执行,HMAC 授契)",
|
||||
executor_type: "qiankun",
|
||||
executor_ref: "",
|
||||
risk_dims: {
|
||||
capability: "L2",
|
||||
data: "L1",
|
||||
consequence: "L3",
|
||||
context: "L1",
|
||||
},
|
||||
data_scope: "沙箱工作目录",
|
||||
destinations: "本机沙箱进程",
|
||||
source: "builtin",
|
||||
version: "1",
|
||||
consent_mode: "ask",
|
||||
enabled: true,
|
||||
mandated: false,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (m === "GET" && path === "/mandates") {
|
||||
return ok([
|
||||
{
|
||||
id: "mdt_mock_001",
|
||||
capability_id: "cap_mock_001",
|
||||
character_id: null,
|
||||
kind: "charter",
|
||||
scope: {},
|
||||
budget_total: 20,
|
||||
budget_used: 3,
|
||||
status: "active",
|
||||
granted_at: "2026-08-27T10:00:00Z",
|
||||
expires_at: null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (m === "POST" && path === "/mandates") {
|
||||
return ok({ id: "mdt_mock_002", status: "active" }, 201);
|
||||
}
|
||||
if (m === "DELETE" && path.startsWith("/mandates/")) {
|
||||
return ok(null, 204);
|
||||
}
|
||||
if (m === "GET" && path === "/petitions") {
|
||||
return ok([
|
||||
{
|
||||
id: "confirm_mock_001",
|
||||
capability: "qiankun.shell",
|
||||
risk_level: "L3",
|
||||
reason: "请命:高风险动作需当次确认",
|
||||
brief: {
|
||||
who: "character:character_mock",
|
||||
what: "在乾坤沙箱中执行命令",
|
||||
capability: "qiankun.shell",
|
||||
executor: "qiankun",
|
||||
destinations: "本机沙箱进程",
|
||||
consequences: "风险等级 L3",
|
||||
alternatives: "拒绝后可调整参数或改用低风险能力",
|
||||
},
|
||||
run_id: "run_mock_001",
|
||||
character_id: "character_mock",
|
||||
created_at: "2026-08-27T10:05:00Z",
|
||||
expires_at: "2026-08-27T10:15:00Z",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (
|
||||
m === "POST" &&
|
||||
path.startsWith("/petitions/") &&
|
||||
path.endsWith("/approve")
|
||||
) {
|
||||
return ok({ status: "resolved", approved: true });
|
||||
}
|
||||
if (
|
||||
m === "POST" &&
|
||||
path.startsWith("/petitions/") &&
|
||||
path.endsWith("/deny")
|
||||
) {
|
||||
return ok({ status: "cancelled", approved: false });
|
||||
}
|
||||
if (m === "GET" && path === "/tool-calls") {
|
||||
return ok([
|
||||
{
|
||||
id: "tc_mock_001",
|
||||
capability_id: "cap_mock_001",
|
||||
run_id: "run_mock_001",
|
||||
character_id: "character_mock",
|
||||
trace_id: "trace_mock_001",
|
||||
decision: "allow",
|
||||
mandate_id: "mdt_mock_001",
|
||||
status: "ok",
|
||||
error_code: null,
|
||||
input_digest: '{"text":"琉璃之镜"}',
|
||||
output_digest: "[echo] 琉璃之镜",
|
||||
evidence: {
|
||||
kind: "observation",
|
||||
capability: "builtin.echo",
|
||||
epistemic: "observation_not_fact",
|
||||
},
|
||||
duration_ms: 3,
|
||||
created_at: "2026-08-27T10:06:00Z",
|
||||
},
|
||||
{
|
||||
id: "tc_mock_002",
|
||||
capability_id: "cap_mock_002",
|
||||
run_id: null,
|
||||
character_id: null,
|
||||
trace_id: null,
|
||||
decision: "denied_unmandated",
|
||||
mandate_id: null,
|
||||
status: "rejected",
|
||||
error_code: "HJ-1303",
|
||||
input_digest: '{"command":"ls"}',
|
||||
output_digest: "",
|
||||
evidence: { kind: "observation", capability: "qiankun.shell" },
|
||||
duration_ms: 0,
|
||||
created_at: "2026-08-27T10:07:00Z",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// —— 神识阁:BYOK 密钥(脱敏)——
|
||||
if (m === "GET" && path === "/me/byok-keys") {
|
||||
return ok({
|
||||
|
||||
@@ -700,6 +700,94 @@ export interface MyUsage {
|
||||
quota?: UsageQuota | null;
|
||||
}
|
||||
|
||||
/** 百工能力(GET /capabilities;四维风险标注 + 授契状态) */
|
||||
export interface Capability {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
executor_type: "builtin" | "qiankun" | "mcp" | "http" | string;
|
||||
executor_ref: string;
|
||||
risk_dims: Record<string, string>;
|
||||
data_scope: string;
|
||||
destinations: string;
|
||||
source: string;
|
||||
version: string;
|
||||
consent_mode: "allow" | "ask" | string;
|
||||
enabled: boolean;
|
||||
mandated: boolean;
|
||||
}
|
||||
|
||||
/** 授契/行契(GET/POST /mandates;撤销立即生效) */
|
||||
export interface Mandate {
|
||||
id: string;
|
||||
capability_id: string;
|
||||
character_id: string | null;
|
||||
kind: "grant" | "charter";
|
||||
scope: Record<string, unknown>;
|
||||
budget_total: number;
|
||||
budget_used: number;
|
||||
status: "active" | "revoked" | string;
|
||||
granted_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/** 待决请命(GET /petitions;超时=拒绝执行) */
|
||||
export interface Petition {
|
||||
id: string;
|
||||
capability: string;
|
||||
risk_level: string;
|
||||
reason: string;
|
||||
brief: {
|
||||
who?: string;
|
||||
what?: string;
|
||||
capability?: string;
|
||||
executor?: string;
|
||||
destinations?: string;
|
||||
consequences?: string;
|
||||
alternatives?: string;
|
||||
params?: string;
|
||||
};
|
||||
run_id: string | null;
|
||||
character_id: string | null;
|
||||
created_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/** 行动账条目(GET /tool-calls;净化摘要 + 观察标证) */
|
||||
export interface ToolCallRecord {
|
||||
id: string;
|
||||
capability_id: string;
|
||||
run_id: string | null;
|
||||
character_id: string | null;
|
||||
trace_id: string | null;
|
||||
decision: string;
|
||||
mandate_id: string | null;
|
||||
status: string;
|
||||
error_code: string | null;
|
||||
input_digest: string;
|
||||
output_digest: string;
|
||||
evidence: {
|
||||
kind?: string;
|
||||
capability?: string;
|
||||
executor?: string;
|
||||
epistemic?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
duration_ms: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** 能力调用响应:执行结果 / 请命 / 拒绝 */
|
||||
export interface InvokeResult {
|
||||
status: "ok" | "failed" | "requires_petition";
|
||||
call_id?: string;
|
||||
observation?: Capability["risk_dims"] & { output?: string };
|
||||
petition_id?: string;
|
||||
risk_level?: string;
|
||||
expires_at?: string;
|
||||
brief?: Petition["brief"];
|
||||
}
|
||||
|
||||
/** 工具权限策略(契约字典 §5.6) */
|
||||
export type PermissionPolicy = "allow" | "ask" | "always_ask" | "forbidden";
|
||||
|
||||
|
||||
Reference in new issue
Block a user