xml-pipeline/docs/handler-contract-v2.1.md
dullfig e653d63bc1 Rename agentserver to xml_pipeline, add console example
OSS restructuring for open-core model:
- Rename package from agentserver/ to xml_pipeline/
- Update all imports (44 Python files, 31 docs/configs)
- Update pyproject.toml for OSS distribution (v0.3.0)
- Move prompt_toolkit from core to optional [console] extra
- Remove auth/server/lsp from core optional deps (-> Nextra)

New console example in examples/console/:
- Self-contained demo with handlers and config
- Uses prompt_toolkit (optional, falls back to input())
- No password auth, no TUI, no LSP — just the basics
- Shows how to use xml-pipeline as a library

Import changes:
- from agentserver.* -> from xml_pipeline.*
- CLI entry points updated: xml_pipeline.cli:main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:41:19 -08:00

241 lines
7.5 KiB
Markdown

# AgentServer v2.1 — Handler Contract
**January 10, 2026** (Updated)
This document is the single canonical specification for all capability handlers in AgentServer v2.1.
All examples, documentation, and implementation must conform to this contract.
## Handler Signature
Every handler **must** be declared with the following signature:
```python
async def handler(
payload: PayloadDataclass, # XSD-validated, deserialized @xmlify dataclass instance
metadata: HandlerMetadata # Trustworthy context provided by the message pump
) -> HandlerResponse | None:
...
```
- Handlers **must** be asynchronous (`async def`).
- Synchronous functions are not permitted and will not be auto-wrapped.
- The `metadata` parameter is mandatory.
- Return `HandlerResponse` to send a message, or `None` for no response.
## HandlerResponse
Handlers return a clean dataclass + target. The pump handles envelope wrapping.
```python
@dataclass
class HandlerResponse:
payload: Any # @xmlify dataclass instance
to: str # Target listener name (or use .respond() for caller)
```
### Forward to Named Target
```python
return HandlerResponse(
payload=GreetingResponse(message="Hello!"),
to="shouter",
)
```
### Respond to Caller (Prunes Call Chain)
```python
return HandlerResponse.respond(
payload=ResultPayload(value=42)
)
```
When using `.respond()`, the pump:
1. Looks up the call chain from thread registry
2. Prunes the last segment (the responder)
3. Routes back to the caller
4. Sub-threads are terminated (see Response Semantics below)
### No Response
```python
return None # Chain ends here, no message emitted
```
## HandlerMetadata
```python
@dataclass
class HandlerMetadata:
thread_id: str # Opaque thread UUID — maps to hidden call chain
from_id: str # Who sent this message (previous hop)
own_name: str | None = None # This listener's name (only if agent: true)
is_self_call: bool = False # True if message is from self
usage_instructions: str = "" # Auto-generated peer schemas for LLM prompts
todo_nudge: str = "" # System note about pending/raised todos
```
### Field Rationale
| Field | Purpose |
|-------|---------|
| `thread_id` | Opaque UUID for thread-scoped storage. Maps internally to call chain (hidden from handler). |
| `from_id` | Previous hop in call chain. Useful for context but not for routing (use `.respond()`). |
| `own_name` | Enables self-referential reasoning. Only populated for `agent: true` listeners. |
| `is_self_call` | Detect self-messages (e.g., `<todo-until>` loops). |
| `usage_instructions` | Auto-generated from peer schemas. Inject into LLM system prompt. |
| `todo_nudge` | System-generated reminder about pending todos. See Todo Registry below. |
### Todo Nudge (for LLM Agents)
The `todo_nudge` field is populated by the pump when an agent has raised "eyebrows" —
registered watchers from `TodoUntil` that have received matching responses.
**How it works:**
1. Agent registers a todo watcher via `TodoUntil` primitive
2. When expected response arrives, the watcher is "raised" (condition met)
3. On next handler call to that agent, `todo_nudge` contains a reminder
4. Agent should check `todo_nudge` and close completed todos
**Example nudge content:**
```
SYSTEM NOTE: The following todos appear complete and should be closed:
- watcher_id: abc123 (registered for: calculator.add response)
Call todo_registry.close(watcher_id) to acknowledge.
```
**Usage in handler:**
```python
async def agent_handler(payload, metadata: HandlerMetadata) -> HandlerResponse:
# Check for completed todos
if metadata.todo_nudge:
# Parse and close completed watchers
todo_registry = get_todo_registry()
raised = todo_registry.get_raised_for(metadata.thread_id, metadata.own_name)
for watcher in raised:
todo_registry.close(watcher.watcher_id)
# Continue with normal handler logic...
```
**Note:** This is an internal mechanism for LLM agent task tracking. Most handlers
can ignore this field. If empty, there are no pending todo notifications.
## Security Model
The message pump enforces security boundaries. Handlers are **untrusted code**.
### Envelope Control (Pump Enforced)
| Field | Handler Control | Pump Override |
|-------|-----------------|---------------|
| `from` | None | Always set to `listener.name` |
| `to` | Requests target | Validated against `peers` list |
| `thread` | None | Managed by thread registry |
| `payload` | Full control | — |
### Peer Constraint Enforcement
Agents can only send to listeners declared in their `peers` list:
```yaml
listeners:
- name: greeter
agent: true
peers: [shouter, logger] # Can only send to these
```
If an agent tries to send to an undeclared peer:
1. Message is **blocked** (never routed)
2. Details logged internally (for debugging)
3. Generic `SystemError` sent back to agent (no topology leak)
4. Thread stays alive — agent can retry
```xml
<SystemError>
<code>routing</code>
<message>Message could not be delivered. Please verify your target and try again.</message>
<retry-allowed>true</retry-allowed>
</SystemError>
```
### Thread Privacy
- Handlers see opaque UUIDs, never actual call chains
- Call chain `console.router.greeter.shouter` → appears as `uuid-xyz`
- Even `from_id` only reveals immediate caller, not full path
## Response Semantics
**Critical for LLM agents to understand:**
When you **respond** (return to caller via `.respond()`), your call chain is pruned:
- Any sub-agents you called are effectively terminated
- Their state/context is lost (calculator memory, scratch space, etc.)
- You cannot call them again in the same context after responding
**Therefore:** Complete ALL sub-tasks before responding. If you need results from a peer, wait for their response first.
This is injected into `usage_instructions` automatically.
## Example Handlers
### Pure Tool (No Agent Flag)
```python
async def add_handler(payload: AddPayload, metadata: HandlerMetadata) -> HandlerResponse:
result = payload.a + payload.b
return HandlerResponse(
payload=ResultPayload(value=result),
to=metadata.from_id, # Return to whoever called
)
```
### LLM Agent
```python
async def research_handler(payload: ResearchPayload, metadata: HandlerMetadata) -> HandlerResponse:
from xml_pipeline.llm import complete
# Build prompt with peer awareness
system_prompt = metadata.usage_instructions + "\n\nYou are a research agent."
response = await complete(
model="grok-4.1",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": payload.query},
],
agent_id=metadata.own_name,
)
return HandlerResponse(
payload=ResearchResult(answer=response.content),
to="summarizer", # Forward to next agent
)
```
### Terminal Handler (Display Only)
```python
async def console_display(payload: ConsoleOutput, metadata: HandlerMetadata) -> None:
print(f"[{payload.source}] {payload.text}")
return None # End of chain
```
## Backwards Compatibility
Legacy handlers returning `bytes` are still supported but deprecated:
```python
# DEPRECATED - still works but not recommended
async def old_handler(payload, metadata) -> bytes:
return b"<result>...</result>"
```
New code should use `HandlerResponse` for:
- Type safety
- Automatic envelope wrapping
- Peer constraint enforcement
- Thread chain management
---
**v2.1 Contract** — Updated January 10, 2026