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,
|
|||
|
|
}
|