9.0 KiB
dirigent_protocol
Version: 0.2.0 Status: Active Development
ACP/MCP-aligned protocol library for agent-client interactions.
Quick Links
- Package README: README.md - Main documentation
- Streaming Model: docs/streaming_model.md - Detailed SessionUpdate guide
- Migration Guide: docs/migration_from_0.1.md - Upgrading from 0.1.x
- Architecture Doc: ../../docs/architecture/protocol.md - System design
Purpose
This package provides the core event protocol for Dirigent, enabling:
- Real-time streaming of agent interactions
- Provider-agnostic event representation
- Tool lifecycle management
- Structured content representation
Key Types
use dirigent_protocol::{
Event, // Top-level event enum
SessionUpdate, // Streaming content updates
ContentBlock, // Structured content (Text, ResourceLink)
ToolCall, // Tool execution state
ToolCallStatus, // Pending → Running → Completed/Error
TurnCompleteTrigger, // How turn completion was detected
};
Event Semantics: MessageCompleted vs TurnComplete vs SessionIdle
Understanding the distinction between these three events is critical for correct system behavior:
MessageCompleted - "Metadata is ready"
- Purpose: Informational - signals that message metadata exists
- Timing: Emitted when message record is created, content may still be streaming
- Consumer action: Update UI status indicators ("Assistant is typing" → "Complete")
- Example: Show message timestamp, update message count
TurnComplete - "All content received" (ACTIONABLE)
- Purpose: Primary finalization signal - all content for this turn is complete
- Timing: Emitted AFTER all content chunks, tool calls, and metadata updates
- Consumer action: Finalize storage, lock state, trigger post-processing
- Example: Write message to disk, mark as immutable, generate summaries
SessionIdle - "No recent activity"
- Purpose: Informational - indicates session is quiet
- Timing: Emitted AFTER TurnComplete as final safety signal
- Consumer action: Hide spinners, update activity indicators
- Example: Remove "typing" animation, update last activity timestamp
Event Ordering Guarantee
1. MessageStarted (message created)
2. SessionUpdate::*Chunk (content streaming)
3. SessionUpdate::ToolCall* (tool execution)
4. MessageCompleted (metadata ready) ← UI: "Complete"
5. TurnComplete ← FINALIZE HERE!
6. SessionIdle ← UI: hide spinner
Consumer Behavior Table
| Consumer | MessageCompleted | TurnComplete | SessionIdle |
|---|---|---|---|
| Archivist | Ignore | Finalize and write | Safety net |
| UI Cache | Update status | Lock state | Hide spinner |
| Conductor Bridge | - | Flush response | Fallback flush |
TurnCompleteTrigger Variants
The TurnCompleteTrigger enum indicates how the system determined completion:
ExplicitSignal: Upstream provider sent explicit completion (e.g., OpenCode session.idle)ResponseReceived: JSON-RPC response received (ACP stdio - response is last message)OperationsComplete: All tracked operations finished (e.g., pending tool calls resolved)IdleTimeout { duration_ms }: Timeout-based detection (fallback mechanism)
For most consumers, treat all triggers the same - the turn is complete. The trigger type is primarily for debugging and observability.
Usage Pattern
use dirigent_protocol::{Event, SessionUpdate, ContentBlock};
fn handle_event(event: Event) {
match event {
Event::SessionUpdate { session_id, update } => {
match update {
SessionUpdate::AgentMessageChunk { message_id, content, .. } => {
if let ContentBlock::Text { text } = content {
println!("Agent: {}", text);
}
}
SessionUpdate::ToolCall { tool_call, .. } => {
println!("Tool: {}", tool_call.tool_name);
}
_ => {}
}
}
_ => {}
}
}
Architecture
dirigent_protocol/
├── src/
│ ├── types/ # Core types
│ │ ├── content.rs # ContentBlock definitions
│ │ ├── updates.rs # SessionUpdate variants
│ │ ├── tool.rs # ToolCall, ToolCallStatus
│ │ └── meta.rs # Provider metadata
│ ├── session.rs # Session types
│ ├── conversation.rs # Message types
│ ├── events.rs # Event enum
│ └── adapters/ # Provider adapters
│ ├── opencode.rs # OpenCode translation
│ └── rest.rs # REST translation
├── docs/ # Detailed documentation
├── examples/ # Usage examples
└── tests/ # Integration tests
Version 0.2.0 Changes
Breaking:
- Removed
Event::MessagePartAdded
New:
SessionUpdateevent system (ACP-style)ContentBlocktypes (MCP-compatible)ToolCallwith lifecycle tracking- Provider metadata via
_meta
See docs/migration_from_0.1.md for migration guide.
Development
Running Tests
cargo test --package dirigent_protocol
Checking Code
cargo check --package dirigent_protocol
Running Examples
cargo run --package dirigent_protocol --example session_metadata_demo
Integration
This package is used by:
- api package: Server functions consume protocol events
- web package: UI renders protocol events
- dirigent_core (future): Runtime emits protocol events
Adapters
The adapter system translates provider-specific events to Dirigent Protocol:
- OpenCodeAdapter: Translates OpenCode.ai events
- RESTAdapter: Converts REST API responses
Adapters preserve provider metadata in the _meta field for debugging and traceability.
Current Scope
Phase 1 (Implemented):
- User/Agent/Thought message streaming
- Tool lifecycle (Pending → Running → Completed/Error)
- Text and ResourceLink content types
- Provider metadata support
Deferred to Future Phases:
- Plans and mode switching
- Permission system
- Embedded resources (full content)
- Rich media (images, audio)
- Multi-agent communication
See ../../docs/building/03_acp_prep/04_first_order_refactor.md for the full plan.
Standards Alignment
Agent-Client Protocol (ACP):
- Session-centric streaming
- Separate content types (user/agent/thought)
- Tool status tracking
Model Context Protocol (MCP):
- ContentBlock structure
- Resource links
- Extensible content types
Differences from standards are documented in ../../docs/architecture/protocol.md.
Anti-Patterns
Timeout-Based Event Waiting (FORBIDDEN)
Never use timeout-based waiting to receive events that should be available immediately.
// ❌ BAD - Race condition waiting for event
async fn wait_for_metadata_event(
events: &mut broadcast::Receiver<Event>,
timeout: Duration,
) -> Option<SessionMetadataReceived> {
let start = Instant::now();
while start.elapsed() < timeout {
match tokio::time::timeout(Duration::from_millis(100), events.recv()).await {
Ok(Ok(Event::SessionMetadataReceived { .. })) => return Some(...),
_ => continue,
}
}
None
}
Why this is wrong:
- Race condition: The event may have been emitted before the receiver subscribed
- Arbitrary delays: 500ms waits add latency for no good reason
- Silent failures: Timeout expiring doesn't indicate the real problem
- Fragile: Works "most of the time" but fails under load or timing variations
Instead, pass data directly:
// ✅ GOOD - Extract data from existing events
async fn create_session_in_connector(...) -> Result<(String, Option<Models>, Option<Modes>), String> {
// The SessionCreated event already contains models/modes
match event {
Event::SessionCreated { session, .. } => {
Ok((session.id, session.models, session.modes))
}
}
}
Rule: If you find yourself writing timeout(Duration::from_millis(N), events.recv()) to wait for an event that "should" arrive, the architecture is wrong. Refactor to pass data directly through return values or existing event payloads.
Contributing
When adding features:
- Update type definitions in
src/types/ - Add comprehensive tests
- Update documentation in
docs/ - Add examples if applicable
- Update CHANGELOG.md
See Also
- Main project: ../../CLAUDE.md
- Architecture docs: ../../docs/architecture/
- Building docs: ../../docs/building/