268 lines
8.3 KiB
Python
268 lines
8.3 KiB
Python
# ============================================================
|
||
# Pi Agent 外向接入 API(moduleId: gateway-agent-api, 可重生 ✅)
|
||
# 方向 A · P1:/api/agent/* 薄适配层。
|
||
# - 目录派生自 tool_runtime 只读白名单 + harness _POWER_MAP P0;
|
||
# - invoke 只直通目录内意图;P1/P2 返回 CONFIRM_REQUIRED,P3 返回 POWER_DENIED;
|
||
# - 所有业务委托 run_tool_async / handle_intent,绝不复制或旁路写路径。
|
||
# ============================================================
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import uuid
|
||
from typing import Any
|
||
|
||
from fastapi import APIRouter
|
||
from fastapi.responses import JSONResponse
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
|
||
from server.contracts import INTERFACE_VERSION
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/api/agent", tags=["pi-agent"])
|
||
|
||
|
||
# P1 agent catalog is an explicit allowlist, not an automatic projection of P0
|
||
# intents: tool_runtime/readonly naming has historically mixed in handlers that
|
||
# write the world, knowledge base, or report files (data.analyze/folder.analyze/
|
||
# report.generate). Keeping the list explicit means a future P0 registration
|
||
# does not silently become reachable by an external read-only agent token.
|
||
_AGENT_CATALOG_INTENTS = frozenset(
|
||
{
|
||
"help",
|
||
"guidance.next",
|
||
"master.query",
|
||
"plan.buckets",
|
||
"plan.rccp",
|
||
"plan.feasibility",
|
||
"plan.inventory",
|
||
"plan.leveling",
|
||
"plan.supply",
|
||
"plan.trace",
|
||
"knowledge.query",
|
||
"scenario.compare",
|
||
"order.pool",
|
||
"conflict.list",
|
||
"flex.capacity",
|
||
"flex.compare",
|
||
"flex.simulate_due",
|
||
"skill.list",
|
||
"query.kpi",
|
||
}
|
||
)
|
||
|
||
|
||
class AgentInvokeRequest(BaseModel):
|
||
"""Pi Agent 只读意图调用请求(模块级模型,防止 OpenAPI 前向引用失败)。"""
|
||
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
intent: str = Field(min_length=1, max_length=128)
|
||
params: dict[str, Any] = Field(default_factory=dict)
|
||
requestId: str | None = Field(default=None, max_length=128)
|
||
sessionId: str | None = Field(default=None, max_length=128)
|
||
|
||
|
||
def _parameter_schema() -> dict[str, Any]:
|
||
"""保守意图参数槽:不臆造各意图业务字段,未知键由服务端登记语义处置。"""
|
||
return {
|
||
"type": "object",
|
||
"title": "意图参数",
|
||
"description": "参数透传槽位;多数只读查询可传空对象。服务端对未登记/越权行为 fail closed。",
|
||
"properties": {},
|
||
"additionalProperties": True,
|
||
}
|
||
|
||
|
||
def _catalog_items() -> list[dict[str, Any]]:
|
||
from server.agent_core import harness
|
||
|
||
entries: list[dict[str, Any]] = []
|
||
for name in sorted(_AGENT_CATALOG_INTENTS):
|
||
power = harness.power_of(name)
|
||
entries.append(
|
||
{
|
||
"name": name,
|
||
"power": power,
|
||
"description": harness._POLICY_DESC.get(
|
||
name, "Pi Agent 只读 APS 查询/分析意图"
|
||
),
|
||
"readOnly": True,
|
||
"parameters": _parameter_schema(),
|
||
}
|
||
)
|
||
return entries
|
||
|
||
|
||
def _catalog_map() -> dict[str, dict[str, Any]]:
|
||
return {item["name"]: item for item in _catalog_items()}
|
||
|
||
|
||
def _not_found(message: str) -> JSONResponse:
|
||
return JSONResponse(
|
||
status_code=404,
|
||
content={
|
||
"ok": False,
|
||
"error": {"code": "INTENT_NOT_FOUND", "message": message},
|
||
},
|
||
)
|
||
|
||
|
||
def _deny(
|
||
store: Any,
|
||
*,
|
||
intent: str,
|
||
power: str,
|
||
actor: str,
|
||
code: str,
|
||
message: str,
|
||
request_id: str,
|
||
status_code: int,
|
||
) -> JSONResponse:
|
||
try:
|
||
from server.agent_core.audit import write_audit
|
||
|
||
write_audit(
|
||
store.data,
|
||
store.next_id,
|
||
actor=actor,
|
||
category="AGENT",
|
||
action="agent.invoke.denied",
|
||
target={"type": "INTENT", "id": intent},
|
||
power=power,
|
||
rationale={
|
||
"reason": code,
|
||
"requestId": request_id,
|
||
"source": "pi-agent-gateway",
|
||
},
|
||
result="DENIED",
|
||
)
|
||
store.save()
|
||
except Exception:
|
||
logger.warning("agent invoke denied audit failed for %s", intent, exc_info=True)
|
||
return JSONResponse(
|
||
status_code=status_code,
|
||
content={"ok": False, "error": {"code": code, "message": message}},
|
||
)
|
||
|
||
|
||
@router.get("/intents", response_model=None)
|
||
def list_intents() -> dict[str, Any]:
|
||
"""返回当前只读目录,供 Pi 动态生成工具清单。"""
|
||
return {
|
||
"ok": True,
|
||
"scope": ["read"],
|
||
"intents": _catalog_items(),
|
||
"interfaceVersion": INTERFACE_VERSION,
|
||
}
|
||
|
||
|
||
@router.get("/intents/{name}", response_model=None)
|
||
def get_intent(name: str) -> dict[str, Any] | JSONResponse:
|
||
"""返回单个意图的参数目录项。"""
|
||
key = str(name or "").strip()
|
||
item = _catalog_map().get(key)
|
||
if item is None:
|
||
return _not_found(f"意图「{key}」不在只读目录")
|
||
return item
|
||
|
||
|
||
@router.post("/invoke", response_model=None)
|
||
async def invoke(req: AgentInvokeRequest) -> dict[str, Any] | JSONResponse:
|
||
"""执行目录内只读意图,业务委托统一工具运行时。"""
|
||
from server.agent_core import harness
|
||
from server.agent_core.tool_runtime import is_registered, run_tool_async
|
||
from server.auth.context import get_identity
|
||
from server.contracts import IntentResult
|
||
from server.state.store import get_store
|
||
|
||
identity = get_identity(required=True)
|
||
actor = (
|
||
f"pi-agent:{identity.username}"
|
||
if identity.auth_kind == "agent"
|
||
else identity.username
|
||
)
|
||
request_id = (req.requestId or "").strip() or uuid.uuid4().hex
|
||
name = (req.intent or "").strip()
|
||
store = get_store()
|
||
|
||
if not is_registered(name):
|
||
return _deny(
|
||
store,
|
||
intent=name,
|
||
power=harness.power_of(name),
|
||
actor=actor,
|
||
code="INTENT_NOT_REGISTERED",
|
||
message=f"意图「{name}」未登记,拒绝执行",
|
||
request_id=request_id,
|
||
status_code=404,
|
||
)
|
||
|
||
power = harness.power_of(name)
|
||
if name not in _catalog_map():
|
||
if power == "P3":
|
||
return _deny(
|
||
store,
|
||
intent=name,
|
||
power=power,
|
||
actor=actor,
|
||
code="POWER_DENIED",
|
||
message="该意图属于 P3 外部副作用,Pi Agent 只读接入无权执行",
|
||
request_id=request_id,
|
||
status_code=403,
|
||
)
|
||
return _deny(
|
||
store,
|
||
intent=name,
|
||
power=power,
|
||
actor=actor,
|
||
code="CONFIRM_REQUIRED",
|
||
message="该意图尚未开放给 Pi Agent 直通,需人工确认后才能执行",
|
||
request_id=request_id,
|
||
status_code=409,
|
||
)
|
||
|
||
session_id = (req.sessionId or "").strip() or f"agent:{actor}"
|
||
intent = IntentResult.model_construct(
|
||
intent=name,
|
||
params=dict(req.params or {}),
|
||
confidence=1.0,
|
||
source="RULE_FAST",
|
||
)
|
||
try:
|
||
reply = await run_tool_async(store, session_id, intent, actor=actor)
|
||
except Exception as exc:
|
||
logger.exception("agent invoke upstream failed for %s", name, exc_info=exc)
|
||
return JSONResponse(
|
||
status_code=502,
|
||
content={
|
||
"ok": False,
|
||
"error": {
|
||
"code": "UPSTREAM_FAILED",
|
||
"message": "上游业务处理失败,本次调用未完成",
|
||
"requestId": request_id,
|
||
},
|
||
},
|
||
)
|
||
|
||
dumped = reply.model_dump()
|
||
blocks = list(dumped.get("blocks") or [])
|
||
evidence_refs: list[str] = []
|
||
for block in blocks:
|
||
for ref in block.get("evidenceRefs") or []:
|
||
if ref not in evidence_refs:
|
||
evidence_refs.append(str(ref))
|
||
return {
|
||
"ok": True,
|
||
"intent": name,
|
||
"power": power,
|
||
"requestId": request_id,
|
||
"actor": actor,
|
||
"data": {
|
||
"text": reply.text,
|
||
"blocks": blocks,
|
||
"commands": list(dumped.get("commands") or []),
|
||
"evidenceRefs": evidence_refs,
|
||
},
|
||
"interfaceVersion": INTERFACE_VERSION,
|
||
}
|