diff --git a/research/notifications-primitive.md b/research/notifications-primitive.md new file mode 100644 index 0000000..2e1702a --- /dev/null +++ b/research/notifications-primitive.md @@ -0,0 +1,353 @@ +# Notifications Primitive + +## Problem Statement + +The current message flow is rigid: +1. User sends a message +2. Agent responds (streaming + tool calls) +3. Compaction can inject summaries (special case) + +External events (file changes, background task completion, IDE diagnostics) have no way to enter the agent's context mid-turn. The message queue is blocked while the agent is working: + +```rust +Some(request) = async { self.message_queue.pop_front() }, + if self.input_mode == InputMode::Normal => { // BLOCKED during agent turn + self.handle_message(request).await?; +} +``` + +We need a way to inject **Notifications** into the agent's context without waiting for the turn to complete. + +--- + +## Decisions + +### Approach: Tool Result Augmentation + +We will use **Tool Result Augmentation** (Approach A below). This is the pattern Anthropic uses in Claude Code with `` tags. It's proven, simple, and requires no API changes. + +### Unified Notification Flow + +All external events (user messages, file changes, background tasks, IDE diagnostics) use the same flow based on agent state: + +``` +External Event + │ + ▼ +┌─────────────────┐ +│ Agent streaming? │ +└─────────────────┘ + │ + ┌───┴───┐ + ▼ ▼ + NO YES + │ │ + ▼ ▼ +Queue Inject into next +as new tool result as +message +``` + +No special cases - user messages during a turn are handled the same as file watcher events or background task completions. + +### Simplifications + +- **No count**: Don't include notification counts +- **No cap**: Include all pending notifications +- **No coalescing**: Defer to the future if it becomes a problem +- **Simple XML**: Just wrap in `` and append + +### Transcript Representation + +`NotificationBlock` is **ephemeral** - rendered for display but not persisted: + +- User sees the interruption happened in the UI +- Content actually lives in the tool result (which is persisted) +- No need to reconstruct notifications when loading a saved conversation +- Same pattern as sub-agent tool blocks (rendered but not saved) + +Example rendering: +``` +┌─ shell ───────────────────────────────── +│ cargo build +│ ✓ Compiled successfully +└───────────────────────────────────────── + +┌─ notification (user) ──────────────────── +│ actually wait, try a different approach +└───────────────────────────────────────── + +┌─ edit_file ───────────────────────────── +│ ... +└───────────────────────────────────────── +``` + +--- + +## Key Insight: Tool Results Are Unstructured Text + +Tool definitions in the Anthropic API use JSON schemas for structured input: +```json +{ + "name": "Edit", + "parameters": { "type": "object", "properties": { ... } } +} +``` + +But tool **results** are just text content: +```json +{ + "type": "tool_result", + "tool_use_id": "toolu_abc123", + "content": "File updated successfully." +} +``` + +This means we can append arbitrary content to tool results. The model interprets it semantically based on formatting (like XML tags). + +--- + +## Approach A: Tool Result Augmentation (Recommended) + +**Observed in**: Claude Code's `` pattern for mid-turn user messages. + +Append notification content directly to tool results using XML delimiters. Same `call_id`, just richer content. + +### How It Works + +``` +Assistant: [tool_call id="edit_1" name="Edit"] +ToolResult(id="edit_1"): "File updated successfully + + +src/lib.rs was modified externally +" +``` + +The agent sees this as a single tool result and interprets the XML naturally. + +### Observed Example (Claude Code) + +When a user sends a message while the agent is mid-turn executing tools: + +``` +ToolResult(id="edit_1"): "String not found... + + +The user sent the following message: +what about this other approach? + +Please address this message and continue with your tasks. +" +``` + +The notification is concatenated to the tool result. The agent incorporates it without needing a separate turn. + +### Implementation + +```rust +fn submit_tool_result_with_notifications( + &mut self, + call_id: &str, + result: &str, + notifications: &[Notification], +) { + let content = if notifications.is_empty() { + result.to_string() + } else { + let notif_xml = notifications.iter() + .map(|n| format!( + "\n{}\n", + n.source, n.message + )) + .collect::>() + .join("\n\n"); + + format!("{result}\n\n{notif_xml}") + }; + + self.messages.push(ChatMessage::tool_response(call_id, &content)); +} +``` + +### System Prompt Addition + +``` +You may see tags in tool results. These are external events +(file changes, background task completions, etc.) that occurred while you +were working. Consider them when deciding your next action. +``` + +### Why This Works + +1. **API compatible**: Tool results are unstructured text +2. **No ID management**: Reuses existing tool call ID +3. **Proven**: Claude Code uses this pattern in production +4. **Clear boundaries**: XML delimits tool output vs notification +5. **Zero overhead**: Just string formatting + +--- + +## Approach B: Synthetic Tool Injection (Alternative) + +For cases where notifications should appear as distinct events in the message history rather than embedded in tool results. + +### How It Works + +Inject a synthetic tool call + result pair: + +``` +Assistant: [tool_call id="edit_1" name="Edit"] +ToolResult(id="edit_1"): "Success" +Assistant: [tool_call id="notif_1" name="_system_notification"] ← Synthetic +ToolResult(id="notif_1"): "File src/lib.rs modified externally" ← Synthetic +``` + +### Implementation + +```rust +pub const NOTIFICATION_TOOL: &str = "_system_notification"; + +fn inject_notification(&mut self, notification: Notification) { + let call_id = format!("notif_{}", Uuid::new_v4()); + + // Synthetic tool call + self.messages.push(ChatMessage { + role: ChatRole::Assistant, + content: MessageContent::default() + .append(ContentPart::ToolCall(GenaiToolCall { + id: call_id.clone(), + name: NOTIFICATION_TOOL.to_string(), + arguments: "{}".to_string(), + })), + options: None, + }); + + // Synthetic tool result + self.messages.push(ChatMessage::tool_response( + &call_id, + ¬ification.to_message(), + )); +} +``` + +Would also need a tool definition: +```rust +Tool { + name: "_system_notification", + description: "System-generated notifications. You do not call this tool; + the system uses it to inform you of external events.", +} +``` + +--- + +## Comparison + +| Aspect | Tool Result Augmentation | Synthetic Tool Injection | +|--------|-------------------------|-------------------------| +| Complexity | Low | Medium | +| API changes | None | Tool definition needed | +| Message count | Same | +2 per notification | +| Transcript clarity | Embedded in tool | Distinct events | +| Proven | Yes (Claude Code) | Theoretical | +| Token overhead | Minimal | Higher | + +--- + +## Recommendation + +**Decision: Use Approach A** (Tool Result Augmentation): +- Proven in production (Claude Code uses this) +- Simplest implementation +- No schema changes + +--- + +## Injection Timing + +The natural injection point is **after any tool completes**: + +```rust +ToolEvent::Completed { agent_id, call_id, content } => { + // Drain pending notifications + let notifications = self.notification_manager.drain(); + + // Submit result with notifications appended + agent.submit_tool_result_with_notifications( + &call_id, + &content, + ¬ifications, + ); +} +``` + +This ensures: +- Notifications arrive between tool calls (natural pause point) +- Agent sees them before deciding next action +- No interruption of streaming or tool execution + +--- + +## Handling Multiple Queued Notifications + +**Decision**: Keep it simple - append all notifications as separate XML blocks: + +``` +ToolResult(id="edit_1"): "File updated successfully + + +src/lib.rs modified externally + + + +src/main.rs modified externally + + + +Build completed: 2 warnings +" +``` + +No counting, no capping, no coalescing. If this becomes a problem (e.g., file watcher storms), we can add coalescing later. + +--- + +## Implementation Progress + +### ✅ Completed + +**Data Structures** (`src/app.rs` lines 65-111): +- `NotificationSource` enum: User, FileWatcher, BackgroundTask, Ide +- `Notification` struct with `to_xml()` method for injection format + +**Ephemeral Block Support** (`src/transcript.rs`): +- Added `is_ephemeral()` method to `Block` trait (default `false`) +- `NotificationBlock` struct that returns `is_ephemeral() -> true` +- Custom `Serialize` for `Turn` that filters out ephemeral blocks +- Notification rendering with yellow styling and ⚡ icon + +### 🔲 Remaining + +**Wiring** (in `src/app.rs`): +1. Add `pending_notifications: VecDeque` to `App` struct +2. Add `drain_notifications()` method to `App` +3. Modify `queue_message()` (line ~529) - if `input_mode != Normal`, create notification instead of queuing message +4. Modify `ToolEvent::Completed` handler (line ~1015) - drain notifications and append XML to content before calling `submit_tool_result` + +**System Prompt**: +- Add explanation of `` tags (see "System Prompt Addition" section above) + +**Testing**: +- Test notification injection into tool results +- Test ephemeral block filtering during serialization +- Test `queue_message()` behavior when streaming vs idle + +--- + +## Open Questions + +1. ~~**Activation modes**~~: Decided - unified flow based on agent state (see Decisions above) +2. **Coalescing**: Deferred - solve if it becomes a problem +3. ~~**Transcript representation**~~: Decided - ephemeral `NotificationBlock` (see Decisions above) +4. **Rate limiting**: Deferred - solve if it becomes a problem diff --git a/src/app.rs b/src/app.rs index aa2bfeb..db97b16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -62,6 +62,54 @@ enum MessageRequest { Command(String, usize), } +/// Notification sources for mid-turn injections +#[derive(Debug, Clone)] +pub enum NotificationSource { + /// User sent a message while agent was streaming + User, + /// File was modified externally + FileWatcher, + /// Background task completed + BackgroundTask, + /// IDE event (diagnostics, etc.) + Ide, +} + +impl std::fmt::Display for NotificationSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + NotificationSource::User => write!(f, "user"), + NotificationSource::FileWatcher => write!(f, "file_watcher"), + NotificationSource::BackgroundTask => write!(f, "background_task"), + NotificationSource::Ide => write!(f, "ide"), + } + } +} + +/// A notification to be injected into tool results +#[derive(Debug, Clone)] +pub struct Notification { + pub source: NotificationSource, + pub message: String, +} + +impl Notification { + pub fn new(source: NotificationSource, message: impl Into) -> Self { + Self { + source, + message: message.into(), + } + } + + /// Format as XML for injection into tool results + pub fn to_xml(&self) -> String { + format!( + "\n{}\n", + self.source, self.message + ) + } +} + /// Actions that can be triggered by terminal events #[derive(Debug, Clone, PartialEq, Eq)] enum Action { diff --git a/src/transcript.rs b/src/transcript.rs index 8fc2576..54adbd7 100644 --- a/src/transcript.rs +++ b/src/transcript.rs @@ -11,7 +11,7 @@ use ratatui::{ style::{Color, Modifier, Style}, text::{Line, Span}, }; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, ser::SerializeStruct}; #[cfg(feature = "cli")] use crate::compaction::CompactionBlock; @@ -96,6 +96,10 @@ pub trait Block: Send + Sync { /// Set the status of this block fn set_status(&mut self, status: Status); + /// Whether this block is ephemeral (rendered but not persisted) + /// Ephemeral blocks are filtered out during serialization. + fn is_ephemeral(&self) -> bool { false } + /// Render status icon with appropriate color (CLI only) #[cfg(feature = "cli")] fn render_status(&self) -> Span<'static> { @@ -345,6 +349,80 @@ impl Block for ToolBlock { } } +/// Notification block for mid-turn injected messages +/// These are ephemeral - rendered but not persisted to the transcript. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationBlock { + pub source: String, + pub message: String, + #[serde(skip_deserializing, default = "NotificationBlock::default_status")] + status: Status, +} + +impl NotificationBlock { + pub fn new(source: impl Into, message: impl Into) -> Self { + Self { + source: source.into(), + message: message.into(), + status: Status::Complete, + } + } + + fn default_status() -> Status { + Status::Complete + } +} + +#[typetag::serde] +impl Block for NotificationBlock { + fn kind(&self) -> BlockType { + BlockType::Text // Treat as text for streaming purposes + } + + fn status(&self) -> Status { + self.status + } + + fn set_status(&mut self, status: Status) { + self.status = status; + } + + fn is_ephemeral(&self) -> bool { + true // Key difference - not persisted + } + + #[cfg(feature = "cli")] + fn render(&self, width: u16) -> Vec> { + let mut lines = Vec::new(); + + // Header: "⚡ notification (source)" + lines.push(Line::from(vec![ + Span::styled("⚡ ", Style::default().fg(Color::Yellow)), + Span::styled( + format!("notification ({})", self.source), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ])); + + // Message content (wrapped) + let wrapped = textwrap::wrap(&self.message, width.saturating_sub(2) as usize); + for line in wrapped { + lines.push(Line::from(Span::styled( + format!(" {}", line), + Style::default().fg(Color::Yellow), + ))); + } + + lines + } + + fn text(&self) -> Option<&str> { + Some(&self.message) + } +} + /// Helper: render prefix for background tools - "[bg] " if true, empty otherwise #[cfg(feature = "cli")] pub fn render_prefix(background: bool) -> Span<'static> { @@ -407,7 +485,7 @@ pub fn render_result(result: &str, max_lines: usize) -> Vec> { } /// A turn in the conversation - one user or assistant response -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] pub struct Turn { pub id: usize, pub role: Role, @@ -418,6 +496,27 @@ pub struct Turn { pub active_block_idx: Option, } +/// Custom serialization that filters out ephemeral blocks +impl Serialize for Turn { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + // Filter out ephemeral blocks before serializing + let persistent_content: Vec<&Box> = self.content + .iter() + .filter(|b| !b.is_ephemeral()) + .collect(); + + let mut state = serializer.serialize_struct("Turn", 4)?; + state.serialize_field("id", &self.id)?; + state.serialize_field("role", &self.role)?; + state.serialize_field("content", &persistent_content)?; + state.serialize_field("timestamp", &self.timestamp)?; + state.end() + } +} + impl Turn { pub fn new(id: usize, role: Role, content: Vec>) -> Self { Self {