feat(api): enhance error handling and add structured hints for agent operations

This commit is contained in:
Abhimanyu Saharan
2026-02-15 02:00:54 +05:30
parent ccdff4835d
commit ee1cf05d5d
6 changed files with 1708 additions and 72 deletions

View File

@@ -7,7 +7,7 @@ from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import field_validator
from pydantic import ConfigDict, Field, field_validator
from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr
@@ -42,13 +42,64 @@ def _normalize_identity_profile(
class AgentBase(SQLModel):
"""Common fields shared by agent create/read/update payloads."""
board_id: UUID | None = None
name: NonEmptyStr
status: str = "provisioning"
heartbeat_config: dict[str, Any] | None = None
identity_profile: dict[str, Any] | None = None
identity_template: str | None = None
soul_template: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_profile",
"x-when-to-use": [
"Create or update canonical agent metadata",
"Inspect agent attributes for governance or delegation",
],
"x-when-not-to-use": [
"Task lifecycle operations (use task endpoints)",
"User-facing conversation content (not modeled here)",
],
"x-required-actor": "lead_or_worker_agent",
"x-prerequisites": [
"board_id if required by your board policy",
"identity templates should be valid JSON or text with expected markers",
],
"x-response-shape": "AgentRead",
"x-side-effects": [
"Reads or writes core agent profile fields",
"May impact routing or assignment decisions when persisted",
],
},
)
board_id: UUID | None = Field(
default=None,
description="Board id that scopes this agent. Omit only when policy allows global agents.",
examples=["11111111-1111-1111-1111-111111111111"],
)
name: NonEmptyStr = Field(
description="Human-readable agent display name.",
examples=["Ops triage lead"],
)
status: str = Field(
default="provisioning",
description="Current lifecycle state used by coordinator logic.",
examples=["provisioning", "active", "paused", "retired"],
)
heartbeat_config: dict[str, Any] | None = Field(
default=None,
description="Runtime heartbeat behavior overrides for this agent.",
examples=[{"interval_seconds": 30, "missing_tolerance": 120}],
)
identity_profile: dict[str, Any] | None = Field(
default=None,
description="Optional profile hints used by routing and policy checks.",
examples=[{"role": "incident_lead", "skill": "triage"}],
)
identity_template: str | None = Field(
default=None,
description="Template that helps define initial intent and behavior.",
examples=["You are a senior incident response lead."],
)
soul_template: str | None = Field(
default=None,
description="Template representing deeper agent instructions.",
examples=["When critical blockers appear, escalate in plain language."],
)
@field_validator("identity_template", "soul_template", mode="before")
@classmethod
@@ -78,14 +129,66 @@ class AgentCreate(AgentBase):
class AgentUpdate(SQLModel):
"""Payload for patching an existing agent."""
board_id: UUID | None = None
is_gateway_main: bool | None = None
name: NonEmptyStr | None = None
status: str | None = None
heartbeat_config: dict[str, Any] | None = None
identity_profile: dict[str, Any] | None = None
identity_template: str | None = None
soul_template: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_profile_update",
"x-when-to-use": [
"Patch mutable agent metadata without replacing the full payload",
"Update status, templates, or heartbeat policy",
],
"x-when-not-to-use": [
"Creating an agent (use AgentCreate)",
"Hard deletes or archive actions (use lifecycle endpoints)",
],
"x-required-actor": "board_lead",
"x-prerequisites": [
"Target agent id must exist and be visible to actor context",
],
"x-side-effects": [
"Mutates agent profile state",
],
},
)
board_id: UUID | None = Field(
default=None,
description="Optional new board assignment.",
examples=["22222222-2222-2222-2222-222222222222"],
)
is_gateway_main: bool | None = Field(
default=None,
description="Whether this agent is treated as the board gateway main.",
)
name: NonEmptyStr | None = Field(
default=None,
description="Optional replacement display name.",
examples=["Ops triage lead"],
)
status: str | None = Field(
default=None,
description="Optional replacement lifecycle status.",
examples=["active", "paused"],
)
heartbeat_config: dict[str, Any] | None = Field(
default=None,
description="Optional heartbeat policy override.",
examples=[{"interval_seconds": 45}],
)
identity_profile: dict[str, Any] | None = Field(
default=None,
description="Optional identity profile update values.",
examples=[{"role": "coordinator"}],
)
identity_template: str | None = Field(
default=None,
description="Optional replacement identity template.",
examples=["Focus on root cause analysis first."],
)
soul_template: str | None = Field(
default=None,
description="Optional replacement soul template.",
examples=["Escalate only after checking all known mitigations."],
)
@field_validator("identity_template", "soul_template", mode="before")
@classmethod
@@ -111,30 +214,102 @@ class AgentUpdate(SQLModel):
class AgentRead(AgentBase):
"""Public agent representation returned by the API."""
id: UUID
gateway_id: UUID
is_board_lead: bool = False
is_gateway_main: bool = False
openclaw_session_id: str | None = None
last_seen_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_profile_lookup",
"x-when-to-use": [
"Inspect live agent state for routing and ownership decisions",
],
"x-required-actor": "board_lead_or_worker",
"x-interpretation": "This is a read model; changes here should use update/lifecycle endpoints.",
},
)
id: UUID = Field(description="Agent UUID.")
gateway_id: UUID = Field(description="Gateway UUID that manages this agent.")
is_board_lead: bool = Field(
default=False,
description="Whether this agent is the board lead.",
)
is_gateway_main: bool = Field(
default=False,
description="Whether this agent is the primary gateway agent.",
)
openclaw_session_id: str | None = Field(
default=None,
description="Optional openclaw session token.",
examples=["sess_01J..."],
)
last_seen_at: datetime | None = Field(
default=None,
description="Last heartbeat timestamp.",
)
created_at: datetime = Field(description="Creation timestamp.")
updated_at: datetime = Field(description="Last update timestamp.")
class AgentHeartbeat(SQLModel):
"""Heartbeat status payload sent by agents."""
status: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_health_signal",
"x-when-to-use": [
"Send periodic heartbeat to indicate liveness",
],
"x-required-actor": "any_agent",
"x-response-shape": "AgentRead",
},
)
status: str | None = Field(
default=None,
description="Agent health status string.",
examples=["healthy", "offline", "degraded"],
)
class AgentHeartbeatCreate(AgentHeartbeat):
"""Heartbeat payload used to create an agent lazily."""
name: NonEmptyStr
board_id: UUID | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_bootstrap",
"x-when-to-use": [
"First heartbeat from a non-provisioned worker should bootstrap identity.",
],
"x-required-actor": "agent",
"x-prerequisites": ["Agent auth token already validated"],
"x-response-shape": "AgentRead",
},
)
name: NonEmptyStr = Field(
description="Display name assigned during first heartbeat bootstrap.",
examples=["Ops triage lead"],
)
board_id: UUID | None = Field(
default=None,
description="Optional board context for bootstrap.",
examples=["33333333-3333-3333-3333-333333333333"],
)
class AgentNudge(SQLModel):
"""Nudge message payload for pinging an agent."""
message: NonEmptyStr
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "agent_nudge",
"x-when-to-use": [
"Prompt a specific agent to revisit or reprioritize work.",
],
"x-required-actor": "board_lead",
"x-response-shape": "AgentRead",
},
)
message: NonEmptyStr = Field(
description="Short message to direct an agent toward immediate attention.",
examples=["Please update the incident triage status for task T-001."],
)

View File

@@ -2,7 +2,49 @@
from __future__ import annotations
from sqlmodel import Field, SQLModel
from pydantic import ConfigDict, Field
from sqlmodel import SQLModel
class LLMErrorResponse(SQLModel):
"""Standardized LLM-facing error payload used by API contracts."""
model_config = ConfigDict(
json_schema_extra={
"title": "LLMErrorResponse",
"x-llm-intent": "llm_error_handling",
"x-when-to-use": [
"Structured, tool-facing API errors for agent workflows",
"Gateway handoff and delegated-task operations",
],
"x-required-actor": "agent",
"x-side-effects": [
"Returns explicit machine-readable error context",
"Includes request_id for end-to-end traceability",
],
},
)
detail: str | dict[str, object] | list[object] = Field(
description=(
"Error payload. Agents should rely on `code` when present and default "
"to `message` for fallback display."
),
examples=["Invalid payload for lead escalation.", {"code": "not_found", "message": "Agent not found."}],
)
request_id: str | None = Field(
default=None,
description="Request correlation identifier injected by middleware.",
)
code: str | None = Field(
default=None,
description="Optional machine-readable error code.",
examples=["gateway_unavailable", "dependency_validation_failed"],
)
retryable: bool | None = Field(
default=None,
description="Whether a client should retry the call after remediating transient conditions.",
)
class BlockedTaskDetail(SQLModel):

View File

@@ -5,7 +5,8 @@ from __future__ import annotations
from typing import Literal
from uuid import UUID
from sqlmodel import Field, SQLModel
from pydantic import ConfigDict, Field
from sqlmodel import SQLModel
from app.schemas.common import NonEmptyStr
@@ -23,72 +24,255 @@ def _user_reply_tags() -> list[str]:
class GatewayLeadMessageRequest(SQLModel):
"""Request payload for sending a message to a board lead agent."""
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "lead_direct_message",
"x-when-to-use": [
"A board has an urgent tactical request that needs direct lead routing",
"You need a specific lead response before delegating work",
],
"x-when-not-to-use": [
"Broadcasting to many leads (use broadcast request)",
"Requesting end-user decisions (use ask-user request)",
],
"x-required-actor": "main_agent",
"x-response-shape": "GatewayLeadMessageResponse",
},
)
kind: Literal["question", "handoff"] = Field(
default="question",
description="Routing mode for lead messages.",
examples=["question", "handoff"],
)
correlation_id: str | None = Field(
default=None,
description="Optional correlation token shared across upstream and downstream systems.",
examples=["lead-msg-1234"],
)
content: NonEmptyStr = Field(
description="Human-readable body sent to lead agents.",
examples=["Please triage the highest-priority blocker on board X."],
)
# How the lead should reply (defaults are interpreted by templates).
reply_tags: list[str] = Field(default_factory=_lead_reply_tags)
reply_source: str | None = "lead_to_gateway_main"
reply_tags: list[str] = Field(
default_factory=_lead_reply_tags,
description="Tags required by reply templates when the lead responds.",
examples=[["gateway_main", "lead_reply"]],
)
reply_source: str | None = Field(
default="lead_to_gateway_main",
description="Reply destination key for the orchestrator.",
examples=["lead_to_gateway_main"],
)
class GatewayLeadMessageResponse(SQLModel):
"""Response payload for a lead-message dispatch attempt."""
ok: bool = True
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
lead_created: bool = False
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "lead_direct_message_result",
"x-when-to-use": [
"Confirm lead routing outcome for a direct message request.",
],
"x-when-not-to-use": [
"Broadcast outcomes (use GatewayLeadBroadcastResponse)",
],
"x-required-actor": "gateway_main",
"x-interpretation": "Use to confirm handoff path and recipient lead context.",
},
)
ok: bool = Field(default=True, description="Whether dispatch was accepted.")
board_id: UUID = Field(description="Board receiving the message.")
lead_agent_id: UUID | None = Field(
default=None,
description="Resolved lead agent id when present.",
)
lead_agent_name: str | None = Field(
default=None,
description="Resolved lead agent display name.",
)
lead_created: bool = Field(
default=False,
description="Whether a lead fallback actor was created during routing.",
)
class GatewayLeadBroadcastRequest(SQLModel):
"""Request payload for broadcasting a message to multiple board leads."""
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
content: NonEmptyStr
board_ids: list[UUID] | None = None
reply_tags: list[str] = Field(default_factory=_lead_reply_tags)
reply_source: str | None = "lead_to_gateway_main"
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "lead_broadcast_message",
"x-when-to-use": [
"Multiple board leads need the same message",
"Coordinating cross-board operational alerts",
],
"x-when-not-to-use": [
"Single lead response required (use direct message)",
"Personalized board-level instruction from agent context",
],
"x-required-actor": "main_agent",
"x-response-shape": "GatewayLeadBroadcastResponse",
},
)
kind: Literal["question", "handoff"] = Field(
default="question",
description="Broadcast intent. `question` asks for responses; `handoff` requests transfer.",
examples=["question", "handoff"],
)
correlation_id: str | None = Field(
default=None,
description="Optional correlation token shared with downstream handlers.",
examples=["broadcast-2026-02-14"],
)
content: NonEmptyStr = Field(
description="Message content distributed to selected board leads.",
examples=["Board-wide incident: prioritize risk triage on task set 14."],
)
board_ids: list[UUID] | None = Field(
default=None,
description="Optional explicit list of board IDs; omit for lead-scoped defaults.",
examples=[[ "11111111-1111-1111-1111-111111111111" ]],
)
reply_tags: list[str] = Field(
default_factory=_lead_reply_tags,
description="Tags required by reply templates when each lead responds.",
examples=[["gateway_main", "lead_reply"]],
)
reply_source: str | None = Field(
default="lead_to_gateway_main",
description="Reply destination key for broadcast responses.",
examples=["lead_to_gateway_main"],
)
class GatewayLeadBroadcastBoardResult(SQLModel):
"""Per-board result entry for a lead broadcast operation."""
board_id: UUID
lead_agent_id: UUID | None = None
lead_agent_name: str | None = None
ok: bool = False
error: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "lead_broadcast_status",
"x-when-to-use": [
"Reading per-board outcomes for retries/follow-up workflows",
],
"x-when-not-to-use": ["Global summary checks should use parent broadcast response"],
"x-interpretation": "Use this result object as a transport status for one board.",
},
)
board_id: UUID = Field(description="Target board id for this result.")
lead_agent_id: UUID | None = Field(
default=None,
description="Resolved lead agent id for the target board.",
)
lead_agent_name: str | None = Field(
default=None,
description="Resolved lead agent display name.",
)
ok: bool = Field(default=False, description="Whether this board delivery succeeded.")
error: str | None = Field(
default=None,
description="Failure reason if this board failed.",
)
class GatewayLeadBroadcastResponse(SQLModel):
"""Aggregate response for a lead broadcast operation."""
ok: bool = True
sent: int = 0
failed: int = 0
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "lead_broadcast_summary",
"x-when-to-use": [
"Inspect final counters after attempting a multi-board send.",
],
"x-when-not-to-use": [
"Single-board directed lead message (use GatewayLeadMessageResponse)",
],
"x-required-actor": "lead_agent_or_router",
"x-interpretation": "Use sent/failed counters before considering retry logic.",
"x-response-shape": "List of GatewayLeadBroadcastBoardResult",
},
)
ok: bool = Field(default=True, description="Whether broadcast execution succeeded.")
sent: int = Field(default=0, description="Number of boards successfully messaged.")
failed: int = Field(default=0, description="Number of boards that failed messaging.")
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list)
class GatewayMainAskUserRequest(SQLModel):
"""Request payload for asking the end user via a main gateway agent."""
correlation_id: str | None = None
content: NonEmptyStr
preferred_channel: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "human_escalation_request",
"x-when-to-use": [
"Blocking decision requires explicit user input",
"Task flow requires preference confirmation or permission",
],
"x-required-actor": "lead_agent",
"x-response-shape": "GatewayMainAskUserResponse",
},
)
correlation_id: str | None = Field(
default=None,
description="Optional correlation token for tracing request/response flow.",
examples=["ask-user-001"],
)
content: NonEmptyStr = Field(
description="Prompt that should be asked to the human.",
examples=["Can we proceed with the proposed vendor budget increase?"],
)
preferred_channel: str | None = Field(
default=None,
description="Optional preferred messaging channel.",
examples=["chat", "email"],
)
# How the main agent should reply back into Mission Control
# (defaults interpreted by templates).
reply_tags: list[str] = Field(default_factory=_user_reply_tags)
reply_source: str | None = "user_via_gateway_main"
reply_tags: list[str] = Field(
default_factory=_user_reply_tags,
description="Tags required for routing the user response.",
examples=[["gateway_main", "user_reply"]],
)
reply_source: str | None = Field(
default="user_via_gateway_main",
description="Reply destination key for user confirmation loops.",
examples=["user_via_gateway_main"],
)
class GatewayMainAskUserResponse(SQLModel):
"""Response payload for user-question dispatch via gateway main agent."""
ok: bool = True
board_id: UUID
main_agent_id: UUID | None = None
main_agent_name: str | None = None
model_config = ConfigDict(
json_schema_extra={
"x-llm-intent": "human_escalation_result",
"x-when-to-use": [
"Track completion and main-agent handoff after human escalation request.",
],
"x-when-not-to-use": [
"Regular lead routing outcomes (use lead message/broadcast responses)",
],
"x-required-actor": "lead_agent",
"x-interpretation": "Track whether ask was accepted and which main agent handled it.",
},
)
ok: bool = Field(default=True, description="Whether ask-user dispatch was accepted.")
board_id: UUID = Field(description="Board context used for the request.")
main_agent_id: UUID | None = Field(
default=None,
description="Resolved main agent id handling the ask.",
)
main_agent_name: str | None = Field(
default=None,
description="Resolved main agent display name.",
)