Skip to content

Research: Agent Local Server

Feature: 017-agent-local-server Date: 2025-12-29

Executive Summary

This research addresses the technical decisions required to implement a local agent server with two protocol options: AG-UI (default) and FastAPI REST. The findings are based on analysis of the AG-UI protocol specification, sample implementations, and the existing HoloDeck codebase.


1. AG-UI Protocol Integration

1.1 Protocol Overview

The Agent User Interaction Protocol (AG-UI) is a lightweight, event-driven framework for seamless communication between frontend applications and AI agents. Key design principles:

  • Transport Agnostic: Supports SSE, WebSockets, webhooks, and other mechanisms
  • Bidirectional: Enables collaborative human-AI workflows
  • Minimal Opinions: Requires only 16 standardized event types
  • Middleware Layer: Events need only be AG-UI-compatible, enabling flexibility

Core Abstraction:

run(input: RunAgentInput) -> Observable<BaseEvent>

1.2 Decision: Use ag-ui-protocol Python SDK

Rationale: The official ag-ui-protocol package (v0.1.10) provides: - Strongly-typed Pydantic models for all 16+ event types - Built-in EventEncoder for SSE streaming with content negotiation - Format negotiation based on HTTP Accept headers - Two transport options: HTTP SSE (text) and HTTP Binary Protocol (high-performance) - Full Python 3.9+ compatibility (HoloDeck requires 3.10+)

Alternatives Considered: 1. Implement protocol from scratch - Rejected: Unnecessary duplication, higher maintenance burden 2. Use raw JSON SSE - Rejected: Loses type safety and format negotiation benefits

1.3 Complete Event Type Reference

AG-UI organizes events into distinct categories:

Lifecycle Events

Event Type Fields Purpose
RunStartedEvent thread_id, run_id, parent_run_id?, input? Initiates execution
RunFinishedEvent thread_id, run_id, result? Signals successful completion
RunErrorEvent message, code? Indicates failure
StepStartedEvent step_name Brackets discrete work unit start
StepFinishedEvent step_name Brackets discrete work unit end

Text Message Events (Start-Content-End Pattern)

Event Type Fields Purpose
TextMessageStartEvent message_id, role Opens message stream
TextMessageContentEvent message_id, delta Streams incremental text chunks
TextMessageEndEvent message_id Closes message stream
TextMessageChunkEvent (convenience) Auto-expands to Start→Content→End

Tool Call Events

Event Type Fields Purpose
ToolCallStartEvent tool_call_id, tool_call_name, parent_message_id? Initiates tool execution
ToolCallArgsEvent tool_call_id, delta Streams argument JSON fragments
ToolCallEndEvent tool_call_id Completes argument transmission
ToolCallResultEvent tool_call_id, content Delivers tool output

State Management Events (Snapshot-Delta Pattern)

Event Type Fields Purpose
StateSnapshotEvent snapshot Complete state baseline
StateDeltaEvent delta RFC 6902 JSON Patch operations
MessagesSnapshotEvent messages Full conversation history

Special Events

Event Type Fields Purpose
CustomEvent name, value Application-defined events
RawEvent event, source? Wraps external system events
ActivitySnapshotEvent message_id, activity_type, content Frontend-only UI updates
ActivityDeltaEvent delta Incremental activity patches

1.4 RunAgentInput Structure

The input to every AG-UI agent run:

class RunAgentInput(BaseModel):
    """Input parameters for agent execution."""
    thread_id: str           # Conversation thread identifier
    run_id: str              # Unique run execution identifier
    messages: list[Message]  # Conversation history
    tools: list[Tool] | None # Available tools (frontend-defined)
    context: dict | None     # Optional contextual data

Key Insight: Tools are frontend-defined and passed to the agent during execution. This enables: - Frontend control over available capabilities - Dynamic tool addition/removal based on context - Security via application-controlled sensitive operations

1.5 Message Types

AG-UI supports multiple participant roles:

# User message (supports multimodal)
UserMessage(
    id="msg-123",
    role="user",
    content="What's in this image?",  # Can be str or list[InputContent]
)

# Assistant message (may include tool calls)
AssistantMessage(
    id="msg-124",
    role="assistant",
    content="I'll analyze that for you.",
    tool_calls=[ToolCall(...)]  # Optional
)

# Tool result message
ToolMessage(
    id="msg-125",
    role="tool",
    tool_call_id="tc-123",
    content="Tool execution result"
)

# System instruction
SystemMessage(
    id="msg-001",
    role="system",
    content="You are a helpful assistant."
)

1.6 State Management

AG-UI provides efficient bidirectional state synchronization:

State Snapshots - Complete state representations:

yield StateSnapshotEvent(
    type=EventType.STATE_SNAPSHOT,
    snapshot={"steps": [...], "status": "in_progress"}
)

State Deltas - Incremental updates via JSON Patch (RFC 6902):

import jsonpatch

previous_state = {"count": 1}
current_state = {"count": 2}
patch = jsonpatch.make_patch(previous_state, current_state)

yield StateDeltaEvent(
    type=EventType.STATE_DELTA,
    delta=patch.patch  # [{"op": "replace", "path": "/count", "value": 2}]
)

Best Practices: - Deploy snapshots at run start and after error recovery - Use deltas for incremental changes (bandwidth efficient) - Apply patches atomically with error fallback to snapshot

1.7 Serialization and EventEncoder

The EventEncoder handles format negotiation based on HTTP Accept headers:

from ag_ui.encoder import EventEncoder

# Create encoder from Accept header
accept_header = request.headers.get("accept")
encoder = EventEncoder(accept=accept_header)

# Encode events for streaming
async def event_generator():
    yield encoder.encode(RunStartedEvent(...))
    yield encoder.encode(TextMessageContentEvent(...))
    yield encoder.encode(RunFinishedEvent(...))

# Return streaming response
return StreamingResponse(
    event_generator(),
    media_type=encoder.get_content_type()
)

Transport Options: - text/event-stream: SSE text format (default, debugging friendly) - application/octet-stream: Binary protocol (high performance)

1.8 Implementation Patterns from Examples

Pattern 1: Agentic Chat (Text Streaming)

async def agentic_chat_endpoint(input_data: RunAgentInput, request: Request):
    encoder = EventEncoder(accept=request.headers.get("accept"))

    async def event_generator():
        # 1. Emit run started
        yield encoder.encode(RunStartedEvent(
            type=EventType.RUN_STARTED,
            thread_id=input_data.thread_id,
            run_id=input_data.run_id
        ))

        # 2. Stream text response
        message_id = str(uuid.uuid4())
        yield encoder.encode(TextMessageStartEvent(
            type=EventType.TEXT_MESSAGE_START,
            message_id=message_id,
            role="assistant"
        ))

        # Stream chunks from agent
        async for chunk in agent.execute(input_data.messages):
            yield encoder.encode(TextMessageContentEvent(
                type=EventType.TEXT_MESSAGE_CONTENT,
                message_id=message_id,
                delta=chunk
            ))

        yield encoder.encode(TextMessageEndEvent(
            type=EventType.TEXT_MESSAGE_END,
            message_id=message_id
        ))

        # 3. Emit run finished
        yield encoder.encode(RunFinishedEvent(
            type=EventType.RUN_FINISHED,
            thread_id=input_data.thread_id,
            run_id=input_data.run_id
        ))

    return StreamingResponse(event_generator(), media_type=encoder.get_content_type())

Pattern 2: Tool Calls with Incremental Arguments

async def send_tool_call_events():
    tool_call_id = str(uuid.uuid4())
    tool_call_name = "search_knowledge_base"
    args = {"query": "return policy", "limit": 10}

    # 1. Start tool call
    yield ToolCallStartEvent(
        type=EventType.TOOL_CALL_START,
        tool_call_id=tool_call_id,
        tool_call_name=tool_call_name
    )

    # 2. Stream arguments (can be chunked for large args)
    yield ToolCallArgsEvent(
        type=EventType.TOOL_CALL_ARGS,
        tool_call_id=tool_call_id,
        delta=json.dumps(args)
    )

    # 3. End tool call
    yield ToolCallEndEvent(
        type=EventType.TOOL_CALL_END,
        tool_call_id=tool_call_id
    )

Pattern 3: Generative UI with State Updates

async def generative_ui_endpoint(input_data: RunAgentInput, request: Request):
    encoder = EventEncoder(accept=request.headers.get("accept"))

    async def event_generator():
        yield encoder.encode(RunStartedEvent(...))

        # Initial state snapshot
        state = {"steps": [{"name": f"Step {i}", "status": "pending"} for i in range(5)]}
        yield encoder.encode(StateSnapshotEvent(
            type=EventType.STATE_SNAPSHOT,
            snapshot=state
        ))

        # Update steps progressively
        previous_state = copy.deepcopy(state)
        for i, step in enumerate(state["steps"]):
            step["status"] = "completed"
            patch = jsonpatch.make_patch(previous_state, state)
            yield encoder.encode(StateDeltaEvent(
                type=EventType.STATE_DELTA,
                delta=patch.patch
            ))
            previous_state = copy.deepcopy(state)
            await asyncio.sleep(0.5)

        yield encoder.encode(RunFinishedEvent(...))

    return StreamingResponse(event_generator(), media_type=encoder.get_content_type())

Pattern 4: Backend Tool Rendering (Messages Snapshot)

async def send_backend_tool_call_events(messages: list):
    tool_call_id = str(uuid.uuid4())

    # Create assistant message with tool call
    new_message = AssistantMessage(
        id=str(uuid.uuid4()),
        role="assistant",
        tool_calls=[
            ToolCall(
                id=tool_call_id,
                type="function",
                function={
                    "name": "get_weather",
                    "arguments": json.dumps({"city": "San Francisco"})
                }
            )
        ]
    )

    # Create tool result message
    result_message = ToolMessage(
        id=str(uuid.uuid4()),
        role="tool",
        content=json.dumps({"temperature": "72F", "conditions": "sunny"}),
        tool_call_id=tool_call_id
    )

    # Emit complete messages snapshot
    all_messages = list(messages) + [new_message, result_message]
    yield MessagesSnapshotEvent(
        type=EventType.MESSAGES_SNAPSHOT,
        messages=all_messages
    )

Pattern 5: Custom Events (Predictive State)

async def predictive_state_events():
    # Emit custom event for frontend state prediction
    yield CustomEvent(
        type=EventType.CUSTOM,
        name="PredictState",
        value=[
            {
                "state_key": "document",
                "tool": "write_document",
                "tool_argument": "content"
            }
        ]
    )

    # Then emit tool call that will update predicted state
    yield ToolCallStartEvent(...)

1.9 HoloDeck Integration Mapping

AG-UI Concept HoloDeck Component Implementation
RunAgentInput ChatRequest + session Map session_id to thread_id, generate run_id
messages ChatSession.history Convert to AG-UI message format
tools Agent tools from YAML Expose via AG-UI tool definitions
Text streaming AgentExecutor.execute_turn() Wrap response stream in AG-UI events
Tool calls Semantic Kernel tools Map SK tool invocations to AG-UI events
State Session metadata Expose session state via StateSnapshotEvent

1.10 Middleware Considerations

AG-UI middleware can transform, filter, and augment event streams:

# Example: Logging middleware
class LoggingMiddleware:
    def process(self, event: BaseEvent) -> BaseEvent:
        logger.info(f"Event: {event.type}")
        return event

# Example: Tool filtering
class ToolFilterMiddleware:
    def __init__(self, allowed_tools: list[str]):
        self.allowed_tools = allowed_tools

    def process(self, event: BaseEvent) -> BaseEvent | None:
        if event.type == EventType.TOOL_CALL_START:
            if event.tool_call_name not in self.allowed_tools:
                return None  # Block this tool
        return event

HoloDeck Application: Use middleware for: - Debug logging of all events - Rate limiting event emission - Filtering sensitive tool calls - Adding observability metadata


2. FastAPI REST Protocol Design

Decision: Follow HoloDeck API patterns from VISION.md

Rationale: VISION.md defines the expected REST API contract: - Endpoint: /agent/<agent-name>/chat (sync) and /agent/<agent-name>/chat/stream (SSE) - Request body: {"message": "...", "session_id": "..."} - Response: JSON or SSE stream

Alternatives Considered: 1. OpenAI-compatible API - Rejected: Not aligned with HoloDeck's simpler model 2. Custom protocol - Rejected: Increases integration complexity

REST Endpoint Structure

POST /agent/{agent_name}/chat
POST /agent/{agent_name}/chat/stream
GET  /health
GET  /ready
GET  /health/agent
DELETE /sessions/{session_id}

Error Response Format

Per clarification: RFC 7807 Problem Details

{
  "type": "https://holodeck.dev/errors/agent-not-found",
  "title": "Agent Not Found",
  "status": 404,
  "detail": "Agent 'xyz' is not loaded on this server"
}


3. Multimodal Input Support

Decision: Support binary content in both AG-UI and REST protocols

Rationale: - HoloDeck's Test-First principle (Constitution III) requires multimodal support - AG-UI protocol natively supports BinaryInputContent for images, PDFs, documents - REST protocol should provide equivalent functionality via multipart/form-data or base64

AG-UI Multimodal Support

AG-UI supports multimodal inputs through BinaryInputContent:

# AG-UI BinaryInputContent structure
{
    "type": "binary",
    "mimeType": "image/png",  # Required
    # One of the following (required):
    "data": "base64-encoded-content",  # Inline base64
    "url": "https://example.com/image.png",  # Remote URL
    "id": "file-123",  # Reference to uploaded file
    # Optional:
    "filename": "screenshot.png"
}

Supported MIME Types (aligned with HoloDeck file processor): - Images: image/png, image/jpeg, image/gif, image/webp - Documents: application/pdf, application/vnd.openxmlformats-officedocument.* - Text: text/plain, text/csv, text/markdown

REST Multimodal Support

For REST protocol, two approaches are supported:

Option 1: Base64 in JSON (simple, small files)

{
    "message": "What's in this image?",
    "files": [
        {
            "content": "base64-encoded-data",
            "mime_type": "image/png",
            "filename": "screenshot.png"
        }
    ]
}

Option 2: Multipart Form Data (recommended for large files)

POST /agent/{agent_name}/chat
Content-Type: multipart/form-data

--boundary
Content-Disposition: form-data; name="message"

What's in this image?
--boundary
Content-Disposition: form-data; name="files"; filename="image.png"
Content-Type: image/png

<binary data>
--boundary--

Integration with Existing File Processor

HoloDeck's lib/file_processor.py already handles multimodal processing: - Images → OCR via markitdown - PDFs → Text extraction via pypdf - Office documents → markitdown conversion

The server will leverage this existing infrastructure:

from holodeck.lib.file_processor import FileProcessor

async def process_multimodal_request(message: str, files: list[FileContent]):
    processor = FileProcessor()
    processed_content = []

    for file in files:
        result = await processor.process(file.content, file.mime_type)
        processed_content.append(result)

    # Combine message with processed file content
    full_message = f"{message}\n\n{chr(10).join(processed_content)}"
    return full_message

File Size Limits

Context Limit Rationale
Base64 in JSON 10 MB Prevent JSON parsing issues
Multipart upload 50 MB Reasonable for local dev
Per-request total 100 MB Memory constraints

Implementation: Use FastAPI's UploadFile with size validation middleware.


4. Session Management Architecture

Decision: In-memory session store with 30-minute TTL

Rationale: - Per clarification: Sessions expire after 30 minutes of inactivity - Existing ChatSessionManager provides session lifecycle patterns - In-memory is sufficient for local development (per spec assumptions)

Implementation Approach: 1. Extend or reuse ChatSessionManager for HTTP context 2. Use Python dict with session_id → SessionData mapping 3. Background task for TTL cleanup (asyncio periodic task)

Alternatives Considered: 1. Redis sessions - Deferred: Out of scope for local server spec 2. File-based persistence - Rejected: Unnecessary complexity for dev use case

Session Data Structure

@dataclass
class ServerSession:
    session_id: str
    agent_executor: AgentExecutor
    created_at: datetime
    last_activity: datetime
    message_count: int

5. Server Framework Selection

Decision: FastAPI with Uvicorn

Rationale: - FastAPI is already a HoloDeck architecture component (see VISION.md Deployment Engine) - Native async support aligns with existing AgentExecutor - Built-in OpenAPI documentation (FR-015) - SSE streaming support via StreamingResponse

Alternatives Considered: 1. Starlette only - Rejected: Loses OpenAPI auto-generation 2. Flask - Rejected: No native async, poor streaming support 3. aiohttp - Rejected: Less ecosystem integration

Dependency Addition

# pyproject.toml additions
"fastapi>=0.115.0,<1.0.0",
"uvicorn[standard]>=0.34.0,<1.0.0",
"ag-ui-protocol>=0.1.10,<1.0.0",

6. Protocol Adapter Pattern

Decision: Use adapter pattern for protocol abstraction

Rationale: Clean separation between agent execution and protocol encoding enables: - Single agent execution path regardless of protocol - Easy addition of future protocols - Testable in isolation

Architecture

CLI (holodeck serve)
    ↓
ServerFactory (creates appropriate server)
    ↓
┌─────────────────────────────────────────┐
│         AgentServer (FastAPI app)        │
│  ┌─────────────────┬─────────────────┐  │
│  │  AGUIProtocol   │  RESTProtocol   │  │
│  │  (EventEncoder) │  (JSON/SSE)     │  │
│  └────────┬────────┴────────┬────────┘  │
│           ↓                  ↓           │
│     AgentEndpointHandler                 │
│           ↓                              │
│     SessionManager                       │
│           ↓                              │
│     AgentExecutor (existing)             │
└─────────────────────────────────────────┘

7. Logging and Observability

Decision: Structured logging with request metadata

Rationale: Per clarification: - Default: Log timestamp, session_id, endpoint, latency - Debug mode: Full request/response content

Implementation: - Use existing holodeck.lib.logging_config infrastructure - Add request/response middleware for metadata capture - Use structured JSON logging format


8. CORS Configuration

Decision: FastAPI CORSMiddleware with configurable origins

Rationale: Per clarification: - Default: Allow all origins (*) for local development - Configurable via --cors-origins flag

Implementation:

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=cors_origins or ["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)


9. Graceful Shutdown

Decision: Signal handlers + asyncio cleanup

Rationale: FR-013 requires graceful shutdown on SIGINT/SIGTERM

Implementation: 1. Register signal handlers in Uvicorn server 2. On signal: stop accepting new requests 3. Wait for in-flight requests (with timeout) 4. Cleanup all sessions (call AgentExecutor.shutdown()) 5. Exit cleanly


10. CLI Command Design

Decision: holodeck serve with protocol option

Rationale: Follows existing CLI patterns (Click-based)

Command Signature:

holodeck serve <agent.yaml> [OPTIONS]

Options:
  --port INTEGER          Server port (default: 8000)
  --protocol [ag-ui|rest] Protocol to use (default: ag-ui)
  --cors-origins TEXT     CORS allowed origins (default: *)
  --debug                 Enable debug logging
  --open                  Open browser on startup


11. File Structure

New Files Required

src/holodeck/
├── serve/                      # New module
│   ├── __init__.py
│   ├── server.py               # AgentServer class
│   ├── protocols/
│   │   ├── __init__.py
│   │   ├── base.py             # Protocol ABC
│   │   ├── agui.py             # AG-UI protocol adapter
│   │   └── rest.py             # REST protocol adapter
│   ├── session_store.py        # In-memory session management
│   ├── middleware.py           # Logging, CORS, error handling
│   └── models.py               # Request/Response Pydantic models
├── cli/
│   └── commands/
│       └── serve.py            # New CLI command

Summary of Decisions

Topic Decision Key Dependency
AG-UI SDK ag-ui-protocol>=0.1.10 PyPI package
AG-UI Events 16+ event types in 5 categories See Section 1.3
AG-UI Patterns Start-Content-End, Snapshot-Delta See Section 1.8
Web Framework FastAPI + Uvicorn fastapi, uvicorn
Multimodal Input Base64 JSON + multipart form-data Existing FileProcessor
Session Storage In-memory with 30-min TTL None (stdlib)
Error Format RFC 7807 Problem Details Built-in
Logging Structured metadata + debug mode Existing logging
Protocol Pattern Adapter for AG-UI and REST Custom code
CORS Configurable, default * FastAPI middleware

Key AG-UI Implementation References

Pattern Example File HoloDeck Use Case
Agentic Chat agentic_chat.py Basic agent responses
Tool Calls human_in_the_loop.py HoloDeck tool execution
State Updates agentic_generative_ui.py Session metadata sync
Backend Tools backend_tool_rendering.py Server-side tool results
Custom Events predictive_state_updates.py Extended functionality
Shared State shared_state.py Complex state snapshots