xml-pipeline/docs/handler-contract-v2.1.md
dullfig a5e2ab22da Add thread registry, LLM router, console handler, and docs updates
Thread Registry:
- Root thread initialization at boot
- Thread chain tracking for message flow
- register_thread() for external message UUIDs

LLM Router:
- Multi-backend support with failover strategy
- Token bucket rate limiting per backend
- Async completion API with retries

Console Handler:
- Message-driven REPL (not separate async loop)
- ConsolePrompt/ConsoleInput payloads
- Handler returns None to disconnect

Boot System:
- System primitives module
- Boot message injected at startup
- Initializes root thread context

Documentation:
- Updated v2.1 docs for new architecture
- LLM router documentation
- Gap analysis cross-check

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:53:38 -08:00

6 KiB

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:

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.

@dataclass
class HandlerResponse:
    payload: Any    # @xmlify dataclass instance
    to: str         # Target listener name (or use .respond() for caller)

Forward to Named Target

return HandlerResponse(
    payload=GreetingResponse(message="Hello!"),
    to="shouter",
)

Respond to Caller (Prunes Call Chain)

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

return None  # Chain ends here, no message emitted

HandlerMetadata

@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

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.

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:

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
<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)

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

async def research_handler(payload: ResearchPayload, metadata: HandlerMetadata) -> HandlerResponse:
    from agentserver.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)

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:

# 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