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

7.5 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
    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:

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:

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

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