Skip to main content

salamander/agent/
session.rs

1//! DESIGN.md §5 — SessionProjection: per-namespace agent session view. The
2//! branching-session demo workhorse. Namespace filtering happens in `apply` —
3//! the projection ignores events from other namespaces.
4//!
5//! A projection over `EventBody`: its `Projection::Body` associated type
6//! pins it to an `AgentDb`, so it can only be built from a log carrying the
7//! agent vocabulary.
8
9use std::collections::HashMap;
10
11use super::{EventBody, Role};
12use crate::event::Event;
13use crate::projection::{NamespaceScoped, Projection};
14
15/// One entry in a session's reconstructed transcript, in application
16/// order.
17#[derive(Debug, Clone)]
18pub enum TranscriptEntry {
19    /// A conversational turn.
20    ModelTurn {
21        /// Who produced the turn.
22        role: Role,
23        /// The turn's text content.
24        content: String,
25        /// The model that produced it.
26        model: String,
27    },
28    /// A tool invocation.
29    ToolCall {
30        /// Correlates this call with its result.
31        call_id: String,
32        /// Name of the tool invoked.
33        tool: String,
34        /// JSON-encoded arguments.
35        args_json: String,
36    },
37    /// The result of a tool invocation.
38    ToolResult {
39        /// The `call_id` of the originating call.
40        call_id: String,
41        /// Whether the call succeeded.
42        ok: bool,
43        /// The result content.
44        content: String,
45    },
46    /// A recorded decision.
47    Decision {
48        /// One-line summary.
49        summary: String,
50        /// Why it was made.
51        rationale: String,
52    },
53}
54
55/// The lifecycle status of a session.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum SessionStatus {
58    /// No `SessionStarted` event has been seen for this namespace.
59    NotStarted,
60    /// The session has started and not yet ended.
61    Active,
62    /// The session has ended.
63    Ended {
64        /// Why the session ended.
65        reason: String,
66    },
67}
68
69/// A tool call awaiting its result, keyed by `call_id` in
70/// [`SessionState::pending`].
71#[derive(Debug, Clone)]
72pub struct PendingToolCall {
73    /// Name of the tool invoked.
74    pub tool: String,
75    /// JSON-encoded arguments.
76    pub args_json: String,
77}
78
79/// The derived state of one agent session: its transcript, outstanding
80/// tool calls, and status.
81#[derive(Debug)]
82pub struct SessionState {
83    /// Set from this session's own `SessionStarted` event, if it's been
84    /// applied yet. `None` until then (or for a namespace that never
85    /// started a session at all).
86    pub agent_id: Option<String>,
87    /// The reconstructed transcript in application order.
88    pub transcript: Vec<TranscriptEntry>,
89    /// Tool calls seen without a matching result yet, keyed by `call_id`.
90    pub pending: HashMap<String, PendingToolCall>,
91    /// The session's lifecycle status.
92    pub status: SessionStatus,
93}
94
95impl Default for SessionState {
96    fn default() -> Self {
97        Self {
98            agent_id: None,
99            transcript: Vec::new(),
100            pending: HashMap::new(),
101            status: SessionStatus::NotStarted,
102        }
103    }
104}
105
106/// A [`Projection`] that folds the agent vocabulary for a single namespace
107/// into a [`SessionState`] — the session transcript and status. Events from
108/// other namespaces are skipped.
109pub struct SessionProjection {
110    namespace: String,
111    state: SessionState,
112    cursor: u64,
113}
114
115impl SessionProjection {
116    /// Creates an empty projection scoped to `namespace`.
117    pub fn new(namespace: impl Into<String>) -> Self {
118        Self {
119            namespace: namespace.into(),
120            state: SessionState::default(),
121            cursor: 0,
122        }
123    }
124}
125
126impl Projection for SessionProjection {
127    type Body = EventBody;
128    type State = SessionState;
129
130    fn apply(&mut self, event: &Event<EventBody>) {
131        if event.namespace != self.namespace {
132            self.cursor = event.offset + 1;
133            return;
134        }
135
136        match &event.body {
137            EventBody::SessionStarted { agent_id, .. } => {
138                self.state.status = SessionStatus::Active;
139                self.state.agent_id = Some(agent_id.clone());
140            }
141            EventBody::ModelTurn {
142                role,
143                content,
144                model,
145            } => {
146                self.state.transcript.push(TranscriptEntry::ModelTurn {
147                    role: *role,
148                    content: content.clone(),
149                    model: model.clone(),
150                });
151            }
152            EventBody::ToolCall {
153                call_id,
154                tool,
155                args_json,
156            } => {
157                self.state.pending.insert(
158                    call_id.clone(),
159                    PendingToolCall {
160                        tool: tool.clone(),
161                        args_json: args_json.clone(),
162                    },
163                );
164                self.state.transcript.push(TranscriptEntry::ToolCall {
165                    call_id: call_id.clone(),
166                    tool: tool.clone(),
167                    args_json: args_json.clone(),
168                });
169            }
170            EventBody::ToolResult {
171                call_id,
172                ok,
173                content,
174            } => {
175                self.state.pending.remove(call_id);
176                self.state.transcript.push(TranscriptEntry::ToolResult {
177                    call_id: call_id.clone(),
178                    ok: *ok,
179                    content: content.clone(),
180                });
181            }
182            EventBody::Decision { summary, rationale } => {
183                self.state.transcript.push(TranscriptEntry::Decision {
184                    summary: summary.clone(),
185                    rationale: rationale.clone(),
186                });
187            }
188            EventBody::SessionEnded { reason } => {
189                self.state.status = SessionStatus::Ended {
190                    reason: reason.clone(),
191                };
192            }
193            EventBody::Put { .. } | EventBody::Delete { .. } => {}
194        }
195
196        self.cursor = event.offset + 1;
197    }
198
199    fn cursor(&self) -> u64 {
200        self.cursor
201    }
202
203    fn state(&self) -> &Self::State {
204        &self.state
205    }
206}
207
208impl NamespaceScoped for SessionProjection {
209    fn new_for(namespace: &str) -> Self {
210        SessionProjection::new(namespace)
211    }
212}