Add capability introspection REST endpoints

Adds operator-only endpoints for discovering organism capabilities:
- GET /api/v1/capabilities - list all listeners
- GET /api/v1/capabilities/{name} - detailed info with schema/example

These are REST-only for operators. Agents cannot access them -
they only know their declared peers (peer constraint isolation).

10 new tests for introspection functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dullfig 2026-01-27 20:51:17 -08:00
parent 3ff399e849
commit 4530c06835
4 changed files with 252 additions and 0 deletions

View file

@ -42,12 +42,15 @@ class MockListener:
peers: List[str] = None peers: List[str] = None
payload_class: type = None payload_class: type = None
schema: Any = None schema: Any = None
root_tag: str = ""
def __post_init__(self): def __post_init__(self):
if self.peers is None: if self.peers is None:
self.peers = [] self.peers = []
if self.payload_class is None: if self.payload_class is None:
self.payload_class = type("MockPayload", (), {"__module__": "test", "__name__": "MockPayload"}) self.payload_class = type("MockPayload", (), {"__module__": "test", "__name__": "MockPayload"})
if not self.root_tag:
self.root_tag = f"{self.name.lower()}.mockpayload"
@dataclass @dataclass
@ -508,3 +511,108 @@ class TestWebSocket:
data = websocket.receive_json() data = websocket.receive_json()
assert data["event"] == "subscribed" assert data["event"] == "subscribed"
assert "filter" in data assert "filter" in data
# ============================================================================
# Test Capability Introspection
# ============================================================================
class TestCapabilityIntrospection:
"""Test capability introspection endpoints."""
def test_list_capabilities(self, test_client):
"""Test GET /capabilities lists all registered listeners."""
response = test_client.get("/api/v1/capabilities")
assert response.status_code == 200
data = response.json()
assert "capabilities" in data
assert "count" in data
assert data["count"] == 2 # greeter and shouter
names = [c["name"] for c in data["capabilities"]]
assert "greeter" in names
assert "shouter" in names
def test_list_capabilities_includes_details(self, test_client):
"""Test capability listing includes required fields."""
response = test_client.get("/api/v1/capabilities")
data = response.json()
# Find greeter
greeter = next(c for c in data["capabilities"] if c["name"] == "greeter")
assert "description" in greeter
assert "isAgent" in greeter
assert greeter["isAgent"] is True
assert "peers" in greeter
assert "shouter" in greeter["peers"]
assert "rootTag" in greeter
def test_list_capabilities_sorted(self, test_client):
"""Test capabilities are sorted by name."""
response = test_client.get("/api/v1/capabilities")
data = response.json()
names = [c["name"] for c in data["capabilities"]]
assert names == sorted(names)
def test_get_capability_detail(self, test_client):
"""Test GET /capabilities/{name} returns detailed info."""
response = test_client.get("/api/v1/capabilities/greeter")
assert response.status_code == 200
data = response.json()
assert data["name"] == "greeter"
assert data["description"] == "Greeting agent"
assert data["isAgent"] is True
assert "shouter" in data["peers"]
assert "payloadClass" in data
assert "rootTag" in data
def test_get_capability_includes_example(self, test_client):
"""Test capability detail includes example XML."""
response = test_client.get("/api/v1/capabilities/greeter")
data = response.json()
# Example XML may or may not be present depending on payload class
assert "exampleXml" in data
def test_get_capability_not_found(self, test_client):
"""Test GET /capabilities/{name} returns 404 for unknown capability."""
response = test_client.get("/api/v1/capabilities/nonexistent")
assert response.status_code == 404
def test_get_capability_shouter(self, test_client):
"""Test non-agent capability details."""
response = test_client.get("/api/v1/capabilities/shouter")
assert response.status_code == 200
data = response.json()
assert data["name"] == "shouter"
assert data["isAgent"] is False
assert data["peers"] == []
class TestCapabilityIntrospectionState:
"""Test capability introspection via ServerState."""
def test_get_capabilities_returns_list(self, server_state):
"""Test get_capabilities returns CapabilityInfo list."""
capabilities = server_state.get_capabilities()
assert len(capabilities) == 2
assert all(hasattr(c, "name") for c in capabilities)
assert all(hasattr(c, "root_tag") for c in capabilities)
def test_get_capability_returns_detail(self, server_state):
"""Test get_capability returns CapabilityDetail."""
detail = server_state.get_capability("greeter")
assert detail is not None
assert detail.name == "greeter"
assert detail.is_agent is True
assert "shouter" in detail.peers
def test_get_capability_not_found_returns_none(self, server_state):
"""Test get_capability returns None for unknown."""
detail = server_state.get_capability("nonexistent")
assert detail is None

View file

@ -18,6 +18,9 @@ from fastapi import APIRouter, HTTPException, Query
from xml_pipeline.server.models import ( from xml_pipeline.server.models import (
AgentInfo, AgentInfo,
AgentListResponse, AgentListResponse,
CapabilityDetail,
CapabilityInfo,
CapabilityListResponse,
ErrorResponse, ErrorResponse,
InjectRequest, InjectRequest,
InjectResponse, InjectResponse,
@ -50,6 +53,39 @@ def create_router(state: "ServerState") -> APIRouter:
"""Get sanitized organism configuration (no secrets).""" """Get sanitized organism configuration (no secrets)."""
return state.get_organism_config() return state.get_organism_config()
# =========================================================================
# Capability Introspection Endpoints (for operators, not agents)
# =========================================================================
@router.get("/capabilities", response_model=CapabilityListResponse)
async def list_capabilities() -> CapabilityListResponse:
"""
List all registered capabilities in the organism.
This endpoint is for operator introspection only.
Agents cannot access this - they only know their declared peers.
"""
capabilities = state.get_capabilities()
return CapabilityListResponse(
capabilities=capabilities,
count=len(capabilities),
)
@router.get("/capabilities/{name}", response_model=CapabilityDetail)
async def get_capability(name: str) -> CapabilityDetail:
"""
Get detailed capability info including schema and example.
This endpoint is for operator introspection only.
"""
capability = state.get_capability(name)
if capability is None:
raise HTTPException(
status_code=404,
detail=f"Capability not found: {name}",
)
return capability
# ========================================================================= # =========================================================================
# Agent Endpoints # Agent Endpoints
# ========================================================================= # =========================================================================

View file

@ -259,3 +259,38 @@ class ErrorResponse(CamelModel):
error: str error: str
detail: Optional[str] = None detail: Optional[str] = None
# =============================================================================
# Capability/Introspection Models
# =============================================================================
class CapabilityInfo(CamelModel):
"""Basic capability info for listing."""
name: str
description: str
is_agent: bool = Field(alias="isAgent")
peers: List[str] = Field(default_factory=list)
root_tag: str = Field(alias="rootTag")
class CapabilityDetail(CamelModel):
"""Detailed capability info including schema."""
name: str
description: str
is_agent: bool = Field(alias="isAgent")
peers: List[str] = Field(default_factory=list)
root_tag: str = Field(alias="rootTag")
payload_class: str = Field(alias="payloadClass")
schema_xsd: Optional[str] = Field(None, alias="schemaXsd")
example_xml: Optional[str] = Field(None, alias="exampleXml")
class CapabilityListResponse(CamelModel):
"""Response for GET /capabilities."""
capabilities: List[CapabilityInfo]
count: int

View file

@ -21,6 +21,8 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set
from xml_pipeline.server.models import ( from xml_pipeline.server.models import (
AgentInfo, AgentInfo,
AgentState, AgentState,
CapabilityDetail,
CapabilityInfo,
MessageInfo, MessageInfo,
OrganismInfo, OrganismInfo,
OrganismStatus, OrganismStatus,
@ -415,6 +417,77 @@ class ServerState:
return etree.tostring(listener.schema, encoding="unicode", pretty_print=True) return etree.tostring(listener.schema, encoding="unicode", pretty_print=True)
return None return None
# =========================================================================
# Capability Introspection (for operators, not agents)
# =========================================================================
def get_capabilities(self) -> List[CapabilityInfo]:
"""
List all registered capabilities.
This is for operator introspection via REST API.
Agents cannot access this - they only know their peers.
"""
capabilities = []
for name, listener in self.pump.listeners.items():
capabilities.append(
CapabilityInfo(
name=name,
description=listener.description,
is_agent=listener.is_agent,
peers=list(listener.peers),
root_tag=listener.root_tag,
)
)
# Sort by name for consistent ordering
capabilities.sort(key=lambda c: c.name)
return capabilities
def get_capability(self, name: str) -> Optional[CapabilityDetail]:
"""
Get detailed capability info including schema.
This is for operator introspection via REST API.
"""
listener = self.pump.listeners.get(name)
if listener is None:
return None
# Get XSD schema
schema_xsd = None
if listener.schema is not None:
from lxml import etree
try:
# Get the schema document from the XMLSchema object
# XMLSchema wraps an Element, we need to serialize it
schema_xsd = etree.tostring(
listener.schema, encoding="unicode", pretty_print=True
)
except Exception:
pass
# Generate example XML
example_xml = None
if hasattr(listener.payload_class, '__dataclass_fields__'):
fields = listener.payload_class.__dataclass_fields__
class_name = listener.payload_class.__name__
lines = [f"<{class_name}>"]
for fname in fields:
lines.append(f" <{fname}>...</{fname}>")
lines.append(f"</{class_name}>")
example_xml = "\n".join(lines)
return CapabilityDetail(
name=name,
description=listener.description,
is_agent=listener.is_agent,
peers=list(listener.peers),
root_tag=listener.root_tag,
payload_class=f"{listener.payload_class.__module__}.{listener.payload_class.__name__}",
schema_xsd=schema_xsd,
example_xml=example_xml,
)
def get_threads( def get_threads(
self, self,
status: Optional[ThreadStatus] = None, status: Optional[ThreadStatus] = None,