Files
hunjing/backend/app/services/governance.py
T
chenxin 684261f4b4 M5:百工与司命——能力注册表、授契行契请命、禁域裁决与观察标证
- 百工域 domains/baigong:capabilities(统一能力契约:执行类型 builtin/qiankun/mcp/http + 四维风险标注 + 来源版本)、mandates(授契 grant / 行契 charter 预算时间窗,撤销立即生效)、tool_calls(净化摘要 + 观察标证);请命复用 pending_confirmations 加 expires_at/brief_json(超时=拒绝执行 HJ-1302,六要素说明)
- 司命裁决器 siming.py:四维取最高档(能力/数据/后果/情境);禁域为代码常量不可配置(递归删根/格式化/写裸设备/fork 炸弹、file:// 协议、密钥材料外发)——专项测试断言恶意标注与篡改对象仍拒 HJ-1301
- 执行链:禁域→裁决(HJ-1303 未授契拒且双留痕)→请命→执行(乾坤经 SandboxClient HMAC;MCP 经 call_tool;内置无副作用)→净化标证(观察非事实,敏感遮蔽+体量截断)→行动账
- MCP 登记/启停/刷新自动派生与撤下 capabilities(博闻登记接入点);内置能力种子(echo/datetime/qiankun.shell/web.fetch)
- 端点:/capabilities /mandates /petitions /tool-calls + /tools 兼容投影;前端百工页四区(能力目录四维标注/授契行契/请命批准拒绝/行动账观察标证)
- 退役:modules/safety|skills|tool_runtime|agent、application/tool_runtime、services/skills|safety、tool_policies/skills/model_endpoint_overrides 表(迁移 006)、前端 SkillManager/ToolsConsole/旧 tools 页
- 验收(test_baigong_siming 四用例):未授契被拒留痕、请命超时不执行、禁域不可关闭、结果为观察不入忆;后端 454、前端 220、BFF 4、神识 5 包全绿(2 个预存环境失败不变)
2026-08-27 19:17:21 +08:00

527 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""司命台治理对象 CRUD 服务(api-spec §19.4)。
五组开放集合(目标 / 感知通道 / 世界实体 / 计划 / 天工阁角色)的增删改查;
治理写操作一律记审计事件(audit_),并入起居注(§19.3)。pivot 由 action 前缀推断。
所有函数自管 ``AsyncSessionLocal``(路由直接调用,不传入 session)。
"""
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.models.governance import (
AgentRosterEntry,
Goal,
PerceptionChannel,
Plan,
WorldEntity,
)
from app.modules.runtime.events import Event
from app.repositories import governance as governance_repo
from app.repositories import runtime as runtime_repo
from app.repositories.json_utils import dumps, loads_dict
from app.schemas.admin import (
AgentRosterResponse,
CreateAgentRequest,
CreateGoalRequest,
CreatePerceptionChannelRequest,
CreatePlanRequest,
CreateWorldEntityRequest,
GoalNode,
PerceptionChannelResponse,
PlanSummaryResponse,
UpdateAgentRequest,
UpdateGoalRequest,
UpdatePerceptionChannelRequest,
UpdatePlanRequest,
UpdateWorldEntityRequest,
WorldEntityResponse,
)
from app.schemas.safety import (
ToolCallDetail,
)
from app.services.audit import record_audit_event
from app.services.serializers import (
agent_to_response,
goal_to_response,
perception_channel_to_response,
plan_to_response,
world_entity_to_response,
)
# —— 太一枢 · 目标 ——
async def list_goals() -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals(session)
return [goal_to_response(goal) for goal in goals]
async def list_goals_by_character(
character_id: str,
status_filter: str | None = None,
) -> list[GoalNode]:
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter=status_filter
)
return [goal_to_response(goal) for goal in goals]
async def load_active_goals_context(character_id: str) -> str | None:
"""加载活跃目标并格式化为 system prompt 片段,供运行时注入。
目标按优先级排序(``prioritize_active_goals``),最高优先级在前。
"""
async with AsyncSessionLocal() as session:
goals = await governance_repo.list_goals_by_character(
session, character_id, status_filter="active"
)
if not goals:
return None
lines = [
f"- [{g.level}] {g.title}(优先级 {g.priority},进度 {g.progress}%)"
for g in sorted(goals, key=lambda g: -(g.priority or 0))
]
return "【当前目标】\n" + "\n".join(lines)
async def emit_goal_event(
goal: Goal,
event_type: str,
extra_payload: dict | None = None,
) -> None:
"""向事件存储写入 goal.* 事件。"""
from app.main import app # 惰性 import 避免启动循环
event_store = getattr(app.state, "event_store", None)
if event_store is None:
return
payload = {
"goal_id": goal.id,
"character_id": goal.character_id,
"title": goal.title,
"level": goal.level,
"type": goal.type,
"status": goal.status,
"priority": goal.priority,
"progress": goal.progress,
}
if extra_payload:
payload.update(extra_payload)
event = Event(
type=f"goal.{event_type}",
source="goal_orchestrator",
payload=payload,
character_id=goal.character_id,
visibility="audit",
)
await event_store.append(event)
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = Goal(
title=payload.title,
description=payload.description,
level=payload.level,
type=payload.type,
character_id=payload.character_id,
status="pending",
priority=payload.priority,
constraints_json=dumps(payload.constraints),
parent_id=payload.parent_id,
success_criteria_json=dumps(payload.success_criteria),
)
await governance_repo.add_goal(session, goal)
await record_audit_event(
session,
action="goal.created",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
await emit_goal_event(goal, "created")
return goal_to_response(goal)
async def update_goal(goal_id: str, payload: UpdateGoalRequest) -> GoalNode:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
old_status = goal.status
updates = payload.model_dump(exclude_unset=True)
constraints = updates.pop("constraints", None)
for key, value in updates.items():
setattr(goal, key, value)
if constraints is not None:
goal.constraints_json = dumps(constraints)
goal.updated_at = utc_now()
await record_audit_event(
session,
action="goal.updated",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
await session.refresh(goal)
if old_status != goal.status:
if goal.status == "completed":
await emit_goal_event(goal, "completed", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
else:
await emit_goal_event(goal, "updated", {"previous_status": old_status})
return goal_to_response(goal)
async def delete_goal(goal_id: str) -> None:
async with AsyncSessionLocal() as session:
goal = await governance_repo.get_goal(session, goal_id)
if goal is None:
raise AppError("not_found", "目标不存在。")
if goal.level == "highest":
raise AppError("invalid_request", "最高目标(使命级)不可删除。")
await session.delete(goal)
await record_audit_event(
session,
action="goal.deleted",
actor="user",
input_summary=goal.title,
resource_type="goal",
resource_id=goal.id,
)
await session.commit()
# —— 烛照枢 · 感知通道 ——
async def list_perception_channels() -> list[PerceptionChannelResponse]:
async with AsyncSessionLocal() as session:
channels = await governance_repo.list_perception_channels(session)
return [perception_channel_to_response(channel) for channel in channels]
async def create_perception_channel(
payload: CreatePerceptionChannelRequest,
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_perception_channel(session, payload.channel) is not None:
raise AppError("conflict", "感知通道已存在。")
channel = PerceptionChannel(
channel=payload.channel,
name=payload.name,
description=payload.description,
risk_level=payload.risk_level,
)
await governance_repo.add_perception_channel(session, channel)
await record_audit_event(
session,
action="perception.created",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def update_perception_channel(
channel_id: str, payload: UpdatePerceptionChannelRequest
) -> PerceptionChannelResponse:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(channel, key, value)
channel.updated_at = utc_now()
await record_audit_event(
session,
action="perception.updated",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
await session.refresh(channel)
return perception_channel_to_response(channel)
async def delete_perception_channel(channel_id: str) -> None:
async with AsyncSessionLocal() as session:
channel = await governance_repo.get_perception_channel(session, channel_id)
if channel is None:
raise AppError("not_found", "感知通道不存在。")
await session.delete(channel)
await record_audit_event(
session,
action="perception.deleted",
actor="user",
input_summary=channel.name,
resource_type="perception_channel",
resource_id=channel.channel,
)
await session.commit()
# —— 河图枢 · 世界实体 ——
async def list_world_entities() -> list[WorldEntityResponse]:
async with AsyncSessionLocal() as session:
entities = await governance_repo.list_world_entities(session)
return [world_entity_to_response(entity) for entity in entities]
async def create_world_entity(
payload: CreateWorldEntityRequest,
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = WorldEntity(
type=payload.type,
name=payload.name,
attributes_json=dumps(payload.attributes),
state_type=payload.state_type,
confidence=payload.confidence,
sources_json=dumps(payload.sources),
)
await governance_repo.add_world_entity(session, entity)
await record_audit_event(
session,
action="world.created",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return world_entity_to_response(entity)
async def update_world_entity(
entity_id: str, payload: UpdateWorldEntityRequest
) -> WorldEntityResponse:
async with AsyncSessionLocal() as session:
entity = await governance_repo.get_world_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(entity, key, value)
entity.updated_at = utc_now()
await record_audit_event(
session,
action="world.updated",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
await session.refresh(entity)
return world_entity_to_response(entity)
async def delete_world_entity(entity_id: str) -> None:
async with AsyncSessionLocal() as session:
entity = await governance_repo.get_world_entity(session, entity_id)
if entity is None:
raise AppError("not_found", "世界实体不存在。")
await session.delete(entity)
await record_audit_event(
session,
action="world.deleted",
actor="user",
input_summary=entity.name,
resource_type="world_entity",
resource_id=entity.id,
)
await session.commit()
# —— 洛书枢 · 计划 ——
async def list_plans() -> list[PlanSummaryResponse]:
async with AsyncSessionLocal() as session:
plans = await governance_repo.list_plans(session)
return [plan_to_response(plan) for plan in plans]
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = Plan(
goal_id=payload.goal_id,
title=payload.title,
assumptions_json=dumps(payload.assumptions),
risks_json=dumps(payload.risks),
)
await governance_repo.add_plan(session, plan)
await record_audit_event(
session,
action="plan.created",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def update_plan(plan_id: str, payload: UpdatePlanRequest) -> PlanSummaryResponse:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
updates = payload.model_dump(exclude_unset=True)
assumptions = updates.pop("assumptions", None)
risks = updates.pop("risks", None)
for key, value in updates.items():
setattr(plan, key, value)
if assumptions is not None:
plan.assumptions_json = dumps(assumptions)
if risks is not None:
plan.risks_json = dumps(risks)
plan.updated_at = utc_now()
await record_audit_event(
session,
action="plan.updated",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
await session.refresh(plan)
return plan_to_response(plan)
async def delete_plan(plan_id: str) -> None:
async with AsyncSessionLocal() as session:
plan = await governance_repo.get_plan(session, plan_id)
if plan is None:
raise AppError("not_found", "计划不存在。")
await session.delete(plan)
await record_audit_event(
session,
action="plan.deleted",
actor="user",
input_summary=plan.title,
resource_type="plan",
resource_id=plan.id,
)
await session.commit()
# —— 天工阁枢 · 角色在岗 ——
async def list_agent_roster() -> list[AgentRosterResponse]:
async with AsyncSessionLocal() as session:
agents = await governance_repo.list_agent_roster(session)
return [agent_to_response(agent) for agent in agents]
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
if await governance_repo.get_agent(session, payload.role) is not None:
raise AppError("conflict", "角色已存在。")
agent = AgentRosterEntry(
role=payload.role,
name=payload.name,
responsibility=payload.responsibility,
)
await governance_repo.add_agent(session, agent)
await record_audit_event(
session,
action="agent.created",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def update_agent(role: str, payload: UpdateAgentRequest) -> AgentRosterResponse:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
for key, value in payload.model_dump(exclude_unset=True).items():
setattr(agent, key, value)
agent.updated_at = utc_now()
await record_audit_event(
session,
action="agent.updated",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
await session.refresh(agent)
return agent_to_response(agent)
async def delete_agent(role: str) -> None:
async with AsyncSessionLocal() as session:
agent = await governance_repo.get_agent(session, role)
if agent is None:
raise AppError("not_found", "角色不存在。")
await session.delete(agent)
await record_audit_event(
session,
action="agent.deleted",
actor="user",
input_summary=agent.name,
resource_type="agent",
resource_id=agent.role,
)
await session.commit()
# —— 百工枢 · 工具策略(§15 / §16 共享)——
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
"""GET /tools/calls/{tool_call_id}:单条 tool.* 事件详情。
tool_call_id 对应一炁事件存储的事件主键;仅返回 tool.* 类型事件,否则 404。
"""
from app.core.time import to_iso
async with AsyncSessionLocal() as session:
event = await runtime_repo.get_event(session, tool_call_id)
if event is None or not event.type.startswith("tool."):
raise AppError("not_found", f"工具调用 {tool_call_id} 不存在。")
payload = loads_dict(event.payload_json)
return ToolCallDetail(
event_id=event.id,
type=event.type,
tool=payload.get("tool"),
run_id=event.run_id,
character_id=event.character_id,
trace_id=event.trace_id,
payload=payload,
created_at=to_iso(event.created_at),
)