diff --git a/backend/alembic/versions/006_百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表.py b/backend/alembic/versions/006_百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表.py new file mode 100644 index 0000000..be60a43 --- /dev/null +++ b/backend/alembic/versions/006_百工域_能力注册表_授契行契_调用记录_请命超时改造_退役旧策略与端点覆盖表.py @@ -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 ### diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 1809763..8fd3274 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/deps.py b/backend/app/api/v1/deps.py index 8c25038..f6a584e 100644 --- a/backend/app/api/v1/deps.py +++ b/backend/app/api/v1/deps.py @@ -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: diff --git a/backend/app/api/v1/endpoints/admin.py b/backend/app/api/v1/endpoints/admin.py index 4136e85..b237c29 100644 --- a/backend/app/api/v1/endpoints/admin.py +++ b/backend/app/api/v1/endpoints/admin.py @@ -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)) diff --git a/backend/app/api/v1/endpoints/baigong.py b/backend/app/api/v1/endpoints/baigong.py new file mode 100644 index 0000000..f9ae810 --- /dev/null +++ b/backend/app/api/v1/endpoints/baigong.py @@ -0,0 +1,5 @@ +"""百工与司命路由(域实现见 app/domains/baigong/api.py)。""" + +from app.domains.baigong.api import routers + +__all__ = ["routers"] diff --git a/backend/app/api/v1/endpoints/mcp.py b/backend/app/api/v1/endpoints/mcp.py index 701bb35..6b9d06f 100644 --- a/backend/app/api/v1/endpoints/mcp.py +++ b/backend/app/api/v1/endpoints/mcp.py @@ -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(), ) diff --git a/backend/app/api/v1/endpoints/safety.py b/backend/app/api/v1/endpoints/safety.py index b3beda1..0720bb3 100644 --- a/backend/app/api/v1/endpoints/safety.py +++ b/backend/app/api/v1/endpoints/safety.py @@ -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 diff --git a/backend/app/api/v1/endpoints/sandbox_files.py b/backend/app/api/v1/endpoints/sandbox_files.py index 89ca2e5..5ac1019 100644 --- a/backend/app/api/v1/endpoints/sandbox_files.py +++ b/backend/app/api/v1/endpoints/sandbox_files.py @@ -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// 下," "传递 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, diff --git a/backend/app/api/v1/endpoints/skills.py b/backend/app/api/v1/endpoints/skills.py deleted file mode 100644 index 6591977..0000000 --- a/backend/app/api/v1/endpoints/skills.py +++ /dev/null @@ -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) diff --git a/backend/app/api/v1/endpoints/tools.py b/backend/app/api/v1/endpoints/tools.py index e377777..41431e6 100644 --- a/backend/app/api/v1/endpoints/tools.py +++ b/backend/app/api/v1/endpoints/tools.py @@ -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":""}``,前端解码)、 - ``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) diff --git a/backend/app/application/tool_runtime/__init__.py b/backend/app/application/tool_runtime/__init__.py deleted file mode 100644 index 896e5ea..0000000 --- a/backend/app/application/tool_runtime/__init__.py +++ /dev/null @@ -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"] diff --git a/backend/app/application/tool_runtime/builtins.py b/backend/app/application/tool_runtime/builtins.py deleted file mode 100644 index d8ae4a5..0000000 --- a/backend/app/application/tool_runtime/builtins.py +++ /dev/null @@ -1,174 +0,0 @@ -"""内置工具集合。覆盖 L0–L3 风险等级,供编排闭环、司命分级演示与前端调用。 - -文件类工具(read_file / list_dir / write_file)使用沙箱 base 下的持久子目录 -``workspace``,并通过 ``isolated_workspace=True`` 由 runtime 改写为按角色隔离的 -``workspace/``,使同一角色跨会话/跨 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/) -_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] - } diff --git a/backend/app/application/tool_runtime/mcp.py b/backend/app/application/tool_runtime/mcp.py deleted file mode 100644 index 1ef8dcf..0000000 --- a/backend/app/application/tool_runtime/mcp.py +++ /dev/null @@ -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 diff --git a/backend/app/application/tool_runtime/runtime.py b/backend/app/application/tool_runtime/runtime.py deleted file mode 100644 index 586a12a..0000000 --- a/backend/app/application/tool_runtime/runtime.py +++ /dev/null @@ -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//`` 隔离前缀; - 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, - }, - ) - ) diff --git a/backend/app/application/tool_runtime/view.py b/backend/app/application/tool_runtime/view.py deleted file mode 100644 index a40db42..0000000 --- a/backend/app/application/tool_runtime/view.py +++ /dev/null @@ -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"] diff --git a/backend/app/application/tool_runtime/web_tools.py b/backend/app/application/tool_runtime/web_tools.py deleted file mode 100644 index f6b4057..0000000 --- a/backend/app/application/tool_runtime/web_tools.py +++ /dev/null @@ -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) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 697ca63..3ab4795 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -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) diff --git a/backend/app/core/ids.py b/backend/app/core/ids.py index 8627a26..ee92425 100644 --- a/backend/app/core/ids.py +++ b/backend/app/core/ids.py @@ -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_") diff --git a/backend/app/db/governance_seed.py b/backend/app/db/governance_seed.py index eaa6c84..9141568 100644 --- a/backend/app/db/governance_seed.py +++ b/backend/app/db/governance_seed.py @@ -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() diff --git a/backend/app/domains/baigong/__init__.py b/backend/app/domains/baigong/__init__.py new file mode 100644 index 0000000..2223031 --- /dev/null +++ b/backend/app/domains/baigong/__init__.py @@ -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"] diff --git a/backend/app/domains/baigong/api.py b/backend/app/domains/baigong/api.py new file mode 100644 index 0000000..0c8435f --- /dev/null +++ b/backend/app/domains/baigong/api.py @@ -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] diff --git a/backend/app/domains/baigong/executor.py b/backend/app/domains/baigong/executor.py new file mode 100644 index 0000000..61f2f68 --- /dev/null +++ b/backend/app/domains/baigong/executor.py @@ -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}", + ) diff --git a/backend/app/domains/baigong/files.py b/backend/app/domains/baigong/files.py new file mode 100644 index 0000000..a7e15ee --- /dev/null +++ b/backend/app/domains/baigong/files.py @@ -0,0 +1,86 @@ +"""乾坤沙箱工作区文件读取(百工执行产物预览)。 + +隔离前缀语义:workspace//(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 diff --git a/backend/app/domains/baigong/models.py b/backend/app/domains/baigong/models.py new file mode 100644 index 0000000..6223355 --- /dev/null +++ b/backend/app/domains/baigong/models.py @@ -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) diff --git a/backend/app/domains/baigong/sanitize.py b/backend/app/domains/baigong/sanitize.py new file mode 100644 index 0000000..8216d2d --- /dev/null +++ b/backend/app/domains/baigong/sanitize.py @@ -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", + } diff --git a/backend/app/domains/baigong/service.py b/backend/app/domains/baigong/service.py new file mode 100644 index 0000000..1a4c69c --- /dev/null +++ b/backend/app/domains/baigong/service.py @@ -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 + ] diff --git a/backend/app/domains/baigong/siming.py b/backend/app/domains/baigong/siming.py new file mode 100644 index 0000000..1274c6c --- /dev/null +++ b/backend/app/domains/baigong/siming.py @@ -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 diff --git a/backend/app/integrations/mcp/client.py b/backend/app/integrations/mcp/client.py index f08f399..7521389 100644 --- a/backend/app/integrations/mcp/client.py +++ b/backend/app/integrations/mcp/client.py @@ -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]: diff --git a/backend/app/integrations/mcp/models.py b/backend/app/integrations/mcp/models.py new file mode 100644 index 0000000..c9ff81b --- /dev/null +++ b/backend/app/integrations/mcp/models.py @@ -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 diff --git a/backend/app/integrations/mcp/transports/base.py b/backend/app/integrations/mcp/transports/base.py index fd4109a..b70df2c 100644 --- a/backend/app/integrations/mcp/transports/base.py +++ b/backend/app/integrations/mcp/transports/base.py @@ -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): diff --git a/backend/app/integrations/mcp/transports/http.py b/backend/app/integrations/mcp/transports/http.py index 3cce9d0..100421b 100644 --- a/backend/app/integrations/mcp/transports/http.py +++ b/backend/app/integrations/mcp/transports/http.py @@ -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): diff --git a/backend/app/integrations/mcp/transports/stdio.py b/backend/app/integrations/mcp/transports/stdio.py index b645751..1688f12 100644 --- a/backend/app/integrations/mcp/transports/stdio.py +++ b/backend/app/integrations/mcp/transports/stdio.py @@ -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__) diff --git a/backend/app/main.py b/backend/app/main.py index c341a6f..380fbae 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index a14e3d2..96f5688 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", diff --git a/backend/app/models/governance.py b/backend/app/models/governance.py index 42760cd..e0c9a67 100644 --- a/backend/app/models/governance.py +++ b/backend/app/models/governance.py @@ -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 - ) diff --git a/backend/app/models/safety.py b/backend/app/models/safety.py index 0076b5e..025dbae 100644 --- a/backend/app/models/safety.py +++ b/backend/app/models/safety.py @@ -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)) diff --git a/backend/app/models/skills.py b/backend/app/models/skills.py deleted file mode 100644 index ee60823..0000000 --- a/backend/app/models/skills.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/modules/agent/__init__.py b/backend/app/modules/agent/__init__.py deleted file mode 100644 index 826a22a..0000000 --- a/backend/app/modules/agent/__init__.py +++ /dev/null @@ -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", -] diff --git a/backend/app/modules/agent/definition.py b/backend/app/modules/agent/definition.py deleted file mode 100644 index 40c1adb..0000000 --- a/backend/app/modules/agent/definition.py +++ /dev/null @@ -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="你是魂镜当前默认人物。请使用中文,保持诚实、克制、清晰。", -) diff --git a/backend/app/modules/agent/task.py b/backend/app/modules/agent/task.py deleted file mode 100644 index 2fb9292..0000000 --- a/backend/app/modules/agent/task.py +++ /dev/null @@ -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, ...] = () diff --git a/backend/app/modules/runtime/__init__.py b/backend/app/modules/runtime/__init__.py index b4ab0ab..8959c69 100644 --- a/backend/app/modules/runtime/__init__.py +++ b/backend/app/modules/runtime/__init__.py @@ -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"] diff --git a/backend/app/modules/runtime/contract.py b/backend/app/modules/runtime/contract.py deleted file mode 100644 index 2418333..0000000 --- a/backend/app/modules/runtime/contract.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/runtime/goals.py b/backend/app/modules/runtime/goals.py deleted file mode 100644 index db0c435..0000000 --- a/backend/app/modules/runtime/goals.py +++ /dev/null @@ -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) diff --git a/backend/app/modules/runtime/integrity.py b/backend/app/modules/runtime/integrity.py deleted file mode 100644 index 16f6321..0000000 --- a/backend/app/modules/runtime/integrity.py +++ /dev/null @@ -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 恒定层不一致") diff --git a/backend/app/modules/safety/__init__.py b/backend/app/modules/safety/__init__.py deleted file mode 100644 index 391e4fb..0000000 --- a/backend/app/modules/safety/__init__.py +++ /dev/null @@ -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", -] diff --git a/backend/app/modules/safety/confirmation.py b/backend/app/modules/safety/confirmation.py deleted file mode 100644 index 53827c9..0000000 --- a/backend/app/modules/safety/confirmation.py +++ /dev/null @@ -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(), - ) diff --git a/backend/app/modules/safety/exceptions.py b/backend/app/modules/safety/exceptions.py deleted file mode 100644 index 849b2e0..0000000 --- a/backend/app/modules/safety/exceptions.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/safety/governor.py b/backend/app/modules/safety/governor.py deleted file mode 100644 index 7c61203..0000000 --- a/backend/app/modules/safety/governor.py +++ /dev/null @@ -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 - ) diff --git a/backend/app/modules/skills/__init__.py b/backend/app/modules/skills/__init__.py deleted file mode 100644 index 41745eb..0000000 --- a/backend/app/modules/skills/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Skill 能力包领域纯逻辑(关键词触发匹配,禁止 IO)。""" - -from app.modules.skills.manifest import SkillManifest, parse_skill_manifest - -__all__ = ["SkillManifest", "parse_skill_manifest"] diff --git a/backend/app/modules/skills/manifest.py b/backend/app/modules/skills/manifest.py deleted file mode 100644 index d1eb7f6..0000000 --- a/backend/app/modules/skills/manifest.py +++ /dev/null @@ -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, - ) diff --git a/backend/app/modules/skills/matcher.py b/backend/app/modules/skills/matcher.py deleted file mode 100644 index b054865..0000000 --- a/backend/app/modules/skills/matcher.py +++ /dev/null @@ -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 diff --git a/backend/app/modules/tool_runtime/__init__.py b/backend/app/modules/tool_runtime/__init__.py deleted file mode 100644 index 13139a0..0000000 --- a/backend/app/modules/tool_runtime/__init__.py +++ /dev/null @@ -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"] diff --git a/backend/app/modules/tool_runtime/definition.py b/backend/app/modules/tool_runtime/definition.py deleted file mode 100644 index 4b2dd16..0000000 --- a/backend/app/modules/tool_runtime/definition.py +++ /dev/null @@ -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 改写为 - # ``/``,防跨 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") diff --git a/backend/app/repositories/governance.py b/backend/app/repositories/governance.py index 6048266..7e8a0ef 100644 --- a/backend/app/repositories/governance.py +++ b/backend/app/repositories/governance.py @@ -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 # —— 计数(供九枢健康度聚合)—— diff --git a/backend/app/repositories/model_endpoints.py b/backend/app/repositories/model_endpoints.py deleted file mode 100644 index 35af19e..0000000 --- a/backend/app/repositories/model_endpoints.py +++ /dev/null @@ -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 diff --git a/backend/app/schemas/safety.py b/backend/app/schemas/safety.py index f5c37d6..e702bb8 100644 --- a/backend/app/schemas/safety.py +++ b/backend/app/schemas/safety.py @@ -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) diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index dd454fa..f4da61c 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -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, diff --git a/backend/app/services/governance.py b/backend/app/services/governance.py index 43f094b..3853f5d 100644 --- a/backend/app/services/governance.py +++ b/backend/app/services/governance.py @@ -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.* 事件详情。 diff --git a/backend/app/services/mcp.py b/backend/app/services/mcp.py index 819f68c..59787ef 100644 --- a/backend/app/services/mcp.py +++ b/backend/app/services/mcp.py @@ -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) diff --git a/backend/app/services/safety.py b/backend/app/services/safety.py deleted file mode 100644 index b733674..0000000 --- a/backend/app/services/safety.py +++ /dev/null @@ -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) diff --git a/backend/app/services/serializers.py b/backend/app/services/serializers.py index a4ad58e..de80a65 100644 --- a/backend/app/services/serializers.py +++ b/backend/app/services/serializers.py @@ -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, diff --git a/backend/app/services/skills.py b/backend/app/services/skills.py deleted file mode 100644 index 9cac8db..0000000 --- a/backend/app/services/skills.py +++ /dev/null @@ -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, - ) diff --git a/backend/tests/api/test_skills_endpoints.py b/backend/tests/api/test_skills_endpoints.py deleted file mode 100644 index 8353080..0000000 --- a/backend/tests/api/test_skills_endpoints.py +++ /dev/null @@ -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" diff --git a/backend/tests/api/test_tools.py b/backend/tests/api/test_tools.py deleted file mode 100644 index e1b6256..0000000 --- a/backend/tests/api/test_tools.py +++ /dev/null @@ -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() diff --git a/backend/tests/api/test_tools_capabilities.py b/backend/tests/api/test_tools_capabilities.py deleted file mode 100644 index 60a2867..0000000 --- a/backend/tests/api/test_tools_capabilities.py +++ /dev/null @@ -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 diff --git a/backend/tests/application/tool_runtime/test_builtins.py b/backend/tests/application/tool_runtime/test_builtins.py deleted file mode 100644 index 917dc88..0000000 --- a/backend/tests/application/tool_runtime/test_builtins.py +++ /dev/null @@ -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 diff --git a/backend/tests/application/tool_runtime/test_mcp.py b/backend/tests/application/tool_runtime/test_mcp.py deleted file mode 100644 index a1dc285..0000000 --- a/backend/tests/application/tool_runtime/test_mcp.py +++ /dev/null @@ -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", - } diff --git a/backend/tests/application/tool_runtime/test_runtime.py b/backend/tests/application/tool_runtime/test_runtime.py deleted file mode 100644 index 4d4f5ef..0000000 --- a/backend/tests/application/tool_runtime/test_runtime.py +++ /dev/null @@ -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="Hello") - ) - 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"]) diff --git a/backend/tests/application/tool_runtime/test_web_tools.py b/backend/tests/application/tool_runtime/test_web_tools.py deleted file mode 100644 index 8fdd271..0000000 --- a/backend/tests/application/tool_runtime/test_web_tools.py +++ /dev/null @@ -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 = ( - "T" - "

标题

这是一段正文。

" - '链接' - ) - 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"

{long_text}

" - 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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 0abd4d8..2a7c6fd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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:///"): diff --git a/backend/tests/modules/runtime/test_goals_context.py b/backend/tests/modules/runtime/test_goals_context.py deleted file mode 100644 index 402a905..0000000 --- a/backend/tests/modules/runtime/test_goals_context.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/safety/test_db_confirmation_store.py b/backend/tests/modules/safety/test_db_confirmation_store.py deleted file mode 100644 index cfd37b8..0000000 --- a/backend/tests/modules/safety/test_db_confirmation_store.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/safety/test_governor.py b/backend/tests/modules/safety/test_governor.py deleted file mode 100644 index 4e84958..0000000 --- a/backend/tests/modules/safety/test_governor.py +++ /dev/null @@ -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 diff --git a/backend/tests/modules/skills/test_matcher.py b/backend/tests/modules/skills/test_matcher.py deleted file mode 100644 index a6b9be0..0000000 --- a/backend/tests/modules/skills/test_matcher.py +++ /dev/null @@ -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) == [] diff --git a/backend/tests/repositories/test_skills_repo.py b/backend/tests/repositories/test_skills_repo.py deleted file mode 100644 index 3956a3c..0000000 --- a/backend/tests/repositories/test_skills_repo.py +++ /dev/null @@ -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() diff --git a/backend/tests/services/test_skills_service.py b/backend/tests/services/test_skills_service.py deleted file mode 100644 index 5947500..0000000 --- a/backend/tests/services/test_skills_service.py +++ /dev/null @@ -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 diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 0ac1bcb..8351f1b 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -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 路径。""" diff --git a/backend/tests/test_baigong_siming.py b/backend/tests/test_baigong_siming.py new file mode 100644 index 0000000..4fc27ea --- /dev/null +++ b/backend/tests/test_baigong_siming.py @@ -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" diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 3eabad2..ea1655c 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -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(符文引擎,无独立表,组装期展开) | diff --git a/docs/operations/capability-matrix.md b/docs/operations/capability-matrix.md index 582dd75..eb0ac89 100644 --- a/docs/operations/capability-matrix.md +++ b/docs/operations/capability-matrix.md @@ -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 快照 | diff --git a/docs/operations/rewrite-plan.md b/docs/operations/rewrite-plan.md index 8530323..d46861e 100644 --- a/docs/operations/rewrite-plan.md +++ b/docs/operations/rewrite-plan.md @@ -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 缘契、人格与世界 diff --git a/frontend/src/app/(app)/tools/page.tsx b/frontend/src/app/(app)/tools/page.tsx index cb35aa2..b1bb8c3 100644 --- a/frontend/src/app/(app)/tools/page.tsx +++ b/frontend/src/app/(app)/tools/page.tsx @@ -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("/tools"); - } catch { - tools = []; + [capabilities, mandates, petitions, calls] = await Promise.all([ + serverApi.get("/capabilities"), + serverApi.get("/mandates"), + serverApi.get("/petitions"), + serverApi.get("/tool-calls", { limit: 100 }), + ]); + } catch (e) { + error = describeError(e); } - return ; + + return ( + + ); } diff --git a/frontend/src/app/admin/pivots/[pivot]/page.tsx b/frontend/src/app/admin/pivots/[pivot]/page.tsx index e3e9a68..5ffed0c 100644 --- a/frontend/src/app/admin/pivots/[pivot]/page.tsx +++ b/frontend/src/app/admin/pivots/[pivot]/page.tsx @@ -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("/tools"), - serverApi.get("/tools/skills"), serverApi.get("/tools/mcp-servers"), ]); } catch (e) { @@ -173,7 +169,7 @@ async function ToolRuntimeDetail() { return ( {error ? ( @@ -209,19 +205,6 @@ async function ToolRuntimeDetail() { ), }, - { - key: "skills", - label: "Skill 能力包", - badge: skills.length, - content: ( -
-

- Skill 能力包 -

- -
- ), - }, ]} defaultTab="tools" /> diff --git a/frontend/src/app/admin/tools/page.tsx b/frontend/src/app/admin/tools/page.tsx index d8651eb..fa6e100 100644 --- a/frontend/src/app/admin/tools/page.tsx +++ b/frontend/src/app/admin/tools/page.tsx @@ -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("/tools"), - serverApi.get("/tools/skills"), serverApi.get("/tools/mcp-servers"), ]); } catch (e) { @@ -38,7 +30,7 @@ export default async function AdminToolsPage() { return ( {error ? ( @@ -80,26 +72,6 @@ export default async function AdminToolsPage() { ), }, - { - key: "skills", - label: "Skill 能力包", - badge: skills.length, - content: ( -
-

- Skill 能力包 -

- {skills.length === 0 ? ( - - ) : ( - - )} -
- ), - }, ]} defaultTab="tools" /> diff --git a/frontend/src/components/baigong/BaigongWorkshop.test.tsx b/frontend/src/components/baigong/BaigongWorkshop.test.tsx new file mode 100644 index 0000000..90e9777 --- /dev/null +++ b/frontend/src/components/baigong/BaigongWorkshop.test.tsx @@ -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[0]> = {}, +) { + return render( + , + ); +} + +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()); + }); +}); diff --git a/frontend/src/components/baigong/BaigongWorkshop.tsx b/frontend/src/components/baigong/BaigongWorkshop.tsx new file mode 100644 index 0000000..b53d16e --- /dev/null +++ b/frontend/src/components/baigong/BaigongWorkshop.tsx @@ -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 = { + 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 = { + 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(null); + const [notice, setNotice] = useState(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 ( +
{error}
+ ); + } + + return ( +
+ {notice && ( +
+ {notice} +
+ )} + + {/* ═══ 请命(最前:有待决先处理) ═══ */} +
+
+

+ + 请命 +

+ Petitions +
+

+ 高风险动作需当次同意;拒绝或超时不执行(HJ-1302)。 +

+ {petitions.length === 0 ? ( +

+ 暂无待决请命。 +

+ ) : ( +
    + {petitions.map((p) => ( +
  • +
    +
    +
    + + {p.capability} + + + {p.risk_level} + +
    +

    + {p.brief.what} · {p.brief.consequences} · 去向: + {p.brief.destinations || "无"} +

    +
    +
    + + +
    +
    +
  • + ))} +
+ )} +
+ + {/* ═══ 能力目录 ═══ */} +
+
+

+ + 能力目录 +

+ Capabilities +
+

+ 统一能力契约:四维风险标注(能力/数据/后果/情境);未授契调用被拒并留痕。 +

+
    + {caps.map((c) => ( +
  • +
    +
    + {c.name} + + {EXECUTOR_LABEL[c.executor_type] ?? c.executor_type} + + {c.mandated ? ( + + 已授契 + + ) : ( + 未授契 + )} +
    +

    + {c.description || "(无描述)"} +

    +
    + {DIM_LABELS.map(([dim, label]) => { + const lv = c.risk_dims[dim]; + if (!lv) return null; + return ( + + {label} {lv} + + ); + })} +
    +
    + {!c.mandated && ( + + )} +
  • + ))} +
+
+ + {/* ═══ 授契与行契 ═══ */} +
+
+

+ + 授契与行契 +

+ Mandates +
+

+ 行契在预算与时间窗内重复执行低风险动作;撤销立即生效。 +

+ {mandates.filter((m) => m.status === "active").length === 0 ? ( +

+ 暂无生效授契。 +

+ ) : ( +
    + {mandates + .filter((m) => m.status === "active") + .map((m) => ( +
  • +
    + + {capName(m.capability_id)} + + + {m.kind === "charter" ? "行契" : "授契"} + + + {m.budget_total > 0 + ? `${m.budget_used}/${m.budget_total} 次` + : "不限次"} + +
    + +
  • + ))} +
+ )} +
+ + {/* ═══ 行动账 ═══ */} +
+
+

+ + 行动账 +

+ Ledger +
+

+ 全部能力调用的净化摘要与观察标证——结果是观察,不是事实。 +

+ {calls.length === 0 ? ( +

+ 暂无调用记录。 +

+ ) : ( +
    + {calls.slice(0, 20).map((t) => ( +
  • +
    + + {capName(t.capability_id)} + + + {t.decision} + + {t.evidence.epistemic === "observation_not_fact" && ( + + 观察(非事实) + + )} + + {t.created_at?.slice(11, 19)} + +
    + {t.output_digest && ( +

    + {t.output_digest} +

    + )} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/tools/SkillManager.tsx b/frontend/src/components/tools/SkillManager.tsx deleted file mode 100644 index 5d1ca2e..0000000 --- a/frontend/src/components/tools/SkillManager.tsx +++ /dev/null @@ -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(null); - const [showAdd, setShowAdd] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [draft, setDraft] = useState(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 ( -
-
- setDraft({ ...draft, name: e.target.value })} - placeholder="名称(唯一)" - className={inputCls} - disabled={!isNew} - /> - setDraft({ ...draft, trigger: e.target.value })} - placeholder="触发条件" - className={inputCls} - /> -
- setDraft({ ...draft, description: e.target.value })} - placeholder="描述" - className={inputCls} - /> -