Skip to main content

laser_sdk/agent/
session_ops.rs

1use crate::agent::Session;
2use crate::agent::agdx::AgdxReceipt;
3use crate::context::{ContextMessage, ContextPolicy};
4use crate::error::LaserError;
5use crate::provenance::AgentTopic;
6use crate::types::MintUlid;
7use iggy::prelude::{Identifier, StreamClient, TopicClient};
8use laser_wire::agent::{
9    AgentErrorBody, ContextManifest, CorrelationId, Fragment, METADATA_DURATION_MICROS,
10    METADATA_PROVIDER_NAME, METADATA_REQUEST_MODEL, METADATA_RESPONSE_MODEL, OPERATION_CHAT,
11    OPERATION_STATE_DELTA, OPERATION_STATE_SNAPSHOT, SessionRef, StateDelta, StateSnapshot,
12    TokenUsage, apply_json_patch, estimate_tokens,
13};
14use laser_wire::content::ContentType;
15use laser_wire::dispatch::{OPERATION_CONTEXT_ASSEMBLED, OPERATION_EXECUTE_TOOL};
16use laser_wire::framing::{decode_named, encode_named};
17use laser_wire::graph::{ProducerInfo, SourceRef};
18use laser_wire::query::Value;
19use laser_wire::session::SessionStateView;
20use std::collections::BTreeMap;
21use std::time::{Duration, Instant};
22
23// Keys whose values the default redaction drops from tool arguments and
24// model request bodies before they are published.
25const REDACTED_KEYS: [&str; 6] = [
26    "authorization",
27    "api_key",
28    "token",
29    "password",
30    "secret",
31    "cookie",
32];
33
34// Automatic state snapshots are written after this many deltas.
35const SNAPSHOT_EVERY_DELTAS: u32 = 64;
36
37/// Drop the values of keys such as `authorization`, `api_key`, `token`,
38/// `password`, `secret`, and `cookie`, at any depth. A convenience, not a
39/// guarantee: a secret under another key is published as it is.
40pub fn default_redact(value: &mut serde_json::Value) {
41    match value {
42        serde_json::Value::Object(fields) => {
43            for (key, field) in fields.iter_mut() {
44                if REDACTED_KEYS.contains(&key.to_ascii_lowercase().as_str()) {
45                    *field = serde_json::Value::String("[redacted]".to_owned());
46                } else {
47                    default_redact(field);
48                }
49            }
50        }
51        serde_json::Value::Array(items) => items.iter_mut().for_each(default_redact),
52        _ => {}
53    }
54}
55
56// The state this handle has written: the revision it expects next, the
57// document after its own deltas, and how many deltas since the last snapshot.
58#[derive(Default)]
59pub(crate) struct StateCursor {
60    revision: u64,
61    document: Option<serde_json::Value>,
62    since_snapshot: u32,
63}
64
65/// One model call request. The SDK never calls a model: the application
66/// calls its provider and records the call through the session.
67#[derive(Debug, Clone)]
68pub struct ModelRequest {
69    pub model: String,
70    pub provider: Option<String>,
71    pub operation: String,
72    pub body: Vec<u8>,
73}
74
75impl ModelRequest {
76    /// A `chat` request to `model` with `body` as the prompt.
77    pub fn new(model: impl Into<String>, body: impl Into<Vec<u8>>) -> Self {
78        Self {
79            model: model.into(),
80            provider: None,
81            operation: OPERATION_CHAT.to_owned(),
82            body: body.into(),
83        }
84    }
85
86    /// The provider that serves the call.
87    #[must_use]
88    pub fn provider(mut self, provider: impl Into<String>) -> Self {
89        self.provider = Some(provider.into());
90        self
91    }
92
93    /// Another model operation, such as `text_completion`.
94    #[must_use]
95    pub fn operation(mut self, operation: impl Into<String>) -> Self {
96        self.operation = operation.into();
97        self
98    }
99}
100
101/// One model call result.
102#[derive(Debug, Clone, Default)]
103pub struct ModelResponse {
104    pub body: Vec<u8>,
105    pub model: Option<String>,
106    pub finish_reason: Option<String>,
107    pub usage: Option<TokenUsage>,
108    /// The call's duration, when the application measured it itself. Left
109    /// unset, [`Session::record_model_call`] records a duration of 0.
110    pub duration: Option<Duration>,
111}
112
113/// The context a model call received, assembled without writing anything.
114#[derive(Debug, Clone)]
115pub struct AssembledContext {
116    pub fragments: Vec<ContextMessage>,
117    pub manifest: ContextManifest,
118}
119
120impl AssembledContext {
121    /// The fragments' payloads as UTF-8, one per line.
122    pub fn text(&self) -> String {
123        self.fragments
124            .iter()
125            .map(|fragment| String::from_utf8_lossy(&fragment.payload).into_owned())
126            .collect::<Vec<_>>()
127            .join("\n")
128    }
129}
130
131/// A model call in flight. Finish it with [`complete`](Self::complete) or
132/// [`fail`](Self::fail).
133pub struct ModelCall {
134    session: Session,
135    correlation: CorrelationId,
136    operation: String,
137    started: Instant,
138}
139
140impl ModelCall {
141    /// The call's correlation, shared by its request, manifest, and result.
142    pub fn correlation(&self) -> CorrelationId {
143        self.correlation
144    }
145
146    /// Record the model's response, with its usage, the answering model, the
147    /// finish reason, and the call duration.
148    pub async fn complete(self, response: ModelResponse) -> Result<AgdxReceipt, LaserError> {
149        let duration = response.duration.unwrap_or_else(|| self.started.elapsed());
150        self.session
151            .write_result(
152                self.correlation,
153                &self.operation,
154                None,
155                response.body,
156                response.usage,
157                duration,
158                response.model,
159                response.finish_reason,
160            )
161            .await
162    }
163
164    /// Record that the call failed.
165    pub async fn fail(self, error: AgentErrorBody) -> Result<AgdxReceipt, LaserError> {
166        self.session
167            .write_failure(self.correlation, &self.operation, None, &error)
168            .await
169    }
170}
171
172/// A tool call in flight. Finish it with [`complete`](Self::complete) or
173/// [`fail`](Self::fail).
174pub struct ToolCall {
175    session: Session,
176    correlation: CorrelationId,
177    tool: String,
178    started: Instant,
179}
180
181impl ToolCall {
182    /// The call's correlation, shared by its command and result.
183    pub fn correlation(&self) -> CorrelationId {
184        self.correlation
185    }
186
187    /// Record the tool's result and the measured duration.
188    pub async fn complete(self, result: impl Into<Vec<u8>>) -> Result<AgdxReceipt, LaserError> {
189        let duration = self.started.elapsed();
190        self.session
191            .write_result(
192                self.correlation,
193                OPERATION_EXECUTE_TOOL,
194                Some(&self.tool),
195                result.into(),
196                None,
197                duration,
198                None,
199                None,
200            )
201            .await
202    }
203
204    /// Record that the tool failed.
205    pub async fn fail(self, error: AgentErrorBody) -> Result<AgdxReceipt, LaserError> {
206        self.session
207            .write_failure(
208                self.correlation,
209                OPERATION_EXECUTE_TOOL,
210                Some(&self.tool),
211                &error,
212            )
213            .await
214    }
215}
216
217/// The session's state document: RFC 6902 patches on the session lane,
218/// applied in lane order, with snapshots as checkpoints.
219pub struct SessionState {
220    session: Session,
221}
222
223impl SessionState {
224    /// Set `key` to `value`.
225    pub async fn set(
226        &self,
227        key: &str,
228        value: serde_json::Value,
229    ) -> Result<AgdxReceipt, LaserError> {
230        let path = format!("/{}", key.replace('~', "~0").replace('/', "~1"));
231        let operation = serde_json::from_value(serde_json::json!({
232            "op": "add",
233            "path": path,
234            "value": value,
235        }))
236        .map_err(|error| LaserError::Invalid(format!("state patch: {error}")))?;
237        self.patch(vec![operation]).await
238    }
239
240    /// Apply `patch` atomically against the revision this handle last saw.
241    /// Append success does not prove the patch applied: a reader learns the
242    /// outcome from the folded state.
243    pub async fn patch(
244        &self,
245        patch: Vec<laser_wire::agent::PatchOperation>,
246    ) -> Result<AgdxReceipt, LaserError> {
247        self.seed().await?;
248        let (delta, due) = {
249            let mut cursor = self.session.state.lock().expect("session state lock");
250            let base = cursor.document.clone().unwrap_or(serde_json::json!({}));
251            let next = apply_json_patch(&base, &patch)
252                .map_err(|error| LaserError::Invalid(error.to_string()))?;
253            let delta = StateDelta {
254                base_revision: cursor.revision,
255                patch,
256                op_id: ulid::Ulid::generate().to_string(),
257            };
258            cursor.revision += 1;
259            cursor.document = Some(next);
260            cursor.since_snapshot += 1;
261            (delta, cursor.since_snapshot >= SNAPSHOT_EVERY_DELTAS)
262        };
263        let receipt = self
264            .session
265            .write_event(OPERATION_STATE_DELTA, encode_named(&delta)?, None)
266            .await?;
267        if due {
268            self.snapshot().await?;
269        }
270        Ok(receipt)
271    }
272
273    /// Replace the whole document with `document`, a JSON object, as one
274    /// revision-guarded patch that removes the keys it drops and sets every
275    /// key it holds.
276    pub async fn replace(&self, document: serde_json::Value) -> Result<AgdxReceipt, LaserError> {
277        let serde_json::Value::Object(next) = document else {
278            return Err(LaserError::Invalid(
279                "a state document is a JSON object".to_owned(),
280            ));
281        };
282        self.seed().await?;
283        let current = self
284            .session
285            .state
286            .lock()
287            .expect("session state lock")
288            .document
289            .clone()
290            .unwrap_or(serde_json::json!({}));
291        let pointer = |key: &str| format!("/{}", key.replace('~', "~0").replace('/', "~1"));
292        let mut operations = Vec::new();
293        if let serde_json::Value::Object(current) = &current {
294            for key in current.keys().filter(|key| !next.contains_key(*key)) {
295                operations.push(serde_json::json!({"op": "remove", "path": pointer(key)}));
296            }
297        }
298        for (key, value) in next {
299            operations
300                .push(serde_json::json!({"op": "add", "path": pointer(&key), "value": value}));
301        }
302        let patch = serde_json::from_value(serde_json::Value::Array(operations))
303            .map_err(|error| LaserError::Invalid(format!("state patch: {error}")))?;
304        self.patch(patch).await
305    }
306
307    /// Write the whole document this handle holds as a snapshot.
308    pub async fn snapshot(&self) -> Result<AgdxReceipt, LaserError> {
309        self.seed().await?;
310        let snapshot = {
311            let cursor = self.session.state.lock().expect("session state lock");
312            StateSnapshot {
313                base_revision: cursor.revision,
314                document: cursor.document.clone().unwrap_or(serde_json::json!({})),
315            }
316        };
317        let receipt = self
318            .session
319            .write_event(OPERATION_STATE_SNAPSHOT, encode_named(&snapshot)?, None)
320            .await?;
321        let mut cursor = self.session.state.lock().expect("session state lock");
322        if cursor.revision == snapshot.base_revision {
323            cursor.since_snapshot = 0;
324        }
325        Ok(receipt)
326    }
327
328    // A handle that has not written yet starts from the folded lane, so its
329    // first delta names the revision the lane is at.
330    async fn seed(&self) -> Result<(), LaserError> {
331        if self
332            .session
333            .state
334            .lock()
335            .expect("session state lock")
336            .document
337            .is_some()
338        {
339            return Ok(());
340        }
341        let view = self.current_view().await?;
342        let mut cursor = self.session.state.lock().expect("session state lock");
343        if cursor.document.is_none() {
344            cursor.revision = view.revision;
345            cursor.document = Some(view.document);
346        }
347        Ok(())
348    }
349
350    // The revision a writer continues from. The lane is read straight from
351    // the log, so it is never behind the writes already confirmed there. The
352    // managed view is used only when the retained lane no longer holds the
353    // document's baseline, and a session the index does not know yet keeps
354    // the lane fold.
355    pub(crate) async fn current_view(&self) -> Result<SessionStateView, LaserError> {
356        let lane = self.get().await?;
357        if lane.complete || !self.session.laser.capabilities().await.sessions {
358            return Ok(lane);
359        }
360        match self
361            .session
362            .laser
363            .sessions()
364            .state(self.session.conversation(), 0)
365            .await
366        {
367            Ok(view) if view.revision >= lane.revision => Ok(view),
368            _ => Ok(lane),
369        }
370    }
371
372    pub(crate) async fn snapshot_if_changed(&self) -> Result<(), LaserError> {
373        let changed = self
374            .session
375            .state
376            .lock()
377            .expect("session state lock")
378            .since_snapshot
379            > 0;
380        if changed {
381            self.snapshot().await?;
382        }
383        Ok(())
384    }
385
386    /// Fold the state from the retained session lane: the newest snapshot
387    /// plus the deltas after it. `complete` is false when the lane no longer
388    /// holds the records the document starts from.
389    pub async fn get(&self) -> Result<SessionStateView, LaserError> {
390        let records = self
391            .session
392            .scope()
393            .fetch_with(
394                vec![AgentTopic::Sessions],
395                Box::new(crate::context::LastN(usize::MAX)),
396            )
397            .await?;
398        let mut document = serde_json::json!({});
399        let mut revision = 0u64;
400        let mut complete = false;
401        for record in records {
402            let Some(envelope) = record.envelope else {
403                continue;
404            };
405            match envelope.operation.as_deref() {
406                Some(OPERATION_STATE_SNAPSHOT) => {
407                    let snapshot: StateSnapshot = decode_named(&envelope.body)?;
408                    // The first snapshot is the baseline. A later one counts
409                    // only at the revision the fold has reached, so a stale
410                    // snapshot from a second writer changes nothing.
411                    if complete && snapshot.base_revision != revision {
412                        continue;
413                    }
414                    document = snapshot.document;
415                    revision = snapshot.base_revision;
416                    complete = true;
417                }
418                Some(OPERATION_STATE_DELTA) => {
419                    let delta: StateDelta = decode_named(&envelope.body)?;
420                    if delta.base_revision == 0 && revision == 0 {
421                        complete = true;
422                    }
423                    if delta.base_revision != revision {
424                        continue;
425                    }
426                    if let Ok(next) = apply_json_patch(&document, &delta.patch) {
427                        document = next;
428                        revision += 1;
429                    }
430                }
431                _ => {}
432            }
433        }
434        Ok(SessionStateView {
435            revision,
436            document,
437            history: Vec::new(),
438            frontier: None,
439            complete,
440        })
441    }
442}
443
444impl Session {
445    /// This session as a stream-scoped reference, for writes that land
446    /// outside the session's own stream.
447    pub fn reference(&self) -> Result<SessionRef, LaserError> {
448        Ok(SessionRef {
449            stream: self.stream()?.to_owned(),
450            session: self.conversation().into(),
451        })
452    }
453
454    /// Replace the redaction applied to tool arguments and model request
455    /// bodies before they are published. The default drops the values of
456    /// common secret keys.
457    #[must_use]
458    pub fn redact(
459        mut self,
460        redactor: impl Fn(&mut serde_json::Value) + Send + Sync + 'static,
461    ) -> Self {
462        self.redactor = std::sync::Arc::new(redactor);
463        self
464    }
465
466    /// This handle with `source` as the record it acts on, stamped as the
467    /// source of graph writes and the origin of remembered items.
468    #[must_use]
469    pub fn acting_on(mut self, source: SourceRef) -> Self {
470        self.current = Some(source);
471        self
472    }
473
474    /// Whether an operator asked to cancel this session on `agent.control`,
475    /// by a cancel request or a forced cancel. Reads the retained control
476    /// records of this session, so it answers on open Apache Iggy too.
477    pub async fn cancel_requested(&self) -> Result<bool, LaserError> {
478        let records = self
479            .scope()
480            .fetch_with(
481                vec![AgentTopic::Control],
482                Box::new(crate::context::LastN(usize::MAX)),
483            )
484            .await?;
485        Ok(records.iter().any(|record| {
486            record.envelope.as_ref().is_some_and(|envelope| {
487                envelope.operation.as_deref()
488                    == Some(laser_wire::dispatch::OPERATION_SESSION_CANCEL)
489                    || envelope.task_state == Some(laser_wire::agent::TaskState::Canceled)
490            })
491        }))
492    }
493
494    /// The pause and cancel requests operators sent this session on
495    /// `agent.control`, addressed to this handle's agent or to every agent.
496    /// Inside a handler the runtime's control subscription keeps them
497    /// current, and the first read of a session folds its retained control
498    /// records. Outside a runtime every call reads the retained records. The
499    /// runtime only records requests: the handler decides when to stop.
500    pub async fn pending_control(&self) -> Result<crate::agent::PendingControl, LaserError> {
501        Ok(self.control_state().await?.flags)
502    }
503
504    // The control state of this session, from the runtime's control book
505    // when it is current, otherwise folded from the retained control records.
506    pub(crate) async fn control_state(
507        &self,
508    ) -> Result<crate::agent::control::ControlState, LaserError> {
509        let conversation = self.conversation();
510        if let Some(state) = self
511            .control
512            .as_ref()
513            .and_then(|book| book.state(conversation))
514        {
515            return Ok(state);
516        }
517        let me = self.agent.clone();
518        let mut records = self
519            .scope()
520            .fetch_with(
521                vec![AgentTopic::Control],
522                Box::new(crate::context::LastN(usize::MAX)),
523            )
524            .await?;
525        records.sort_by_key(|record| {
526            (
527                record.timestamp_micros,
528                record.id.partition_id,
529                record.id.offset,
530            )
531        });
532        let route = self.control_route().await?;
533        let (state, last) = crate::agent::control::fold(
534            records.iter().filter_map(|record| {
535                let request =
536                    crate::agent::control::control_request(record.envelope.as_ref()?, me.as_ref())?;
537                Some(((record.id.partition_id, record.id.offset), request))
538            }),
539            route,
540        );
541        Ok(match &self.control {
542            Some(book) => book.install(conversation, state, last),
543            None => state,
544        })
545    }
546
547    // The agent.control partition this session's control records belong on,
548    // `None` while the topic does not exist.
549    async fn control_route(&self) -> Result<Option<u32>, LaserError> {
550        let stream = iggy::prelude::Identifier::named(self.laser.stream_required()?)?;
551        let topic = iggy::prelude::Identifier::named(laser_wire::topics::AGENT_CONTROL)?;
552        let details =
553            iggy::prelude::TopicClient::get_topic(self.laser.client().as_ref(), &stream, &topic)
554                .await?;
555        Ok(details.and_then(|details| {
556            crate::agent::control::control_partition(self.conversation(), details.partitions_count)
557        }))
558    }
559
560    /// The session's state document.
561    pub fn state(&self) -> SessionState {
562        SessionState {
563            session: self.clone(),
564        }
565    }
566
567    /// Assemble the model context under `policy` from the session lane and
568    /// describe it in a manifest. Nothing is written.
569    pub async fn assemble(
570        &self,
571        policy: Box<dyn ContextPolicy>,
572    ) -> Result<AssembledContext, LaserError> {
573        let name = policy.name();
574        let version = policy.version();
575        let fragments = self
576            .scope()
577            .fetch_with(vec![AgentTopic::Sessions], policy)
578            .await?;
579        let (stream_id, topic_id, generation) = self.lane_identity().await?;
580        let mut tokens = 0u64;
581        let mut bytes = 0u64;
582        let mut frontier: BTreeMap<(u32, u32), u64> = BTreeMap::new();
583        let manifest_fragments = fragments
584            .iter()
585            .map(|message| {
586                let size = message.payload.len();
587                tokens += estimate_tokens(size);
588                bytes += size as u64;
589                let position = frontier
590                    .entry((topic_id, message.id.partition_id))
591                    .or_insert(message.id.offset);
592                *position = (*position).max(message.id.offset);
593                Fragment::Message {
594                    at: SourceRef::Message {
595                        stream: stream_id,
596                        topic: topic_id,
597                        partition: message.id.partition_id,
598                        offset: message.id.offset,
599                        generation: Some(generation),
600                        conversation: Some(self.conversation().to_string()),
601                    },
602                    tokens: u32::try_from(estimate_tokens(size)).unwrap_or(u32::MAX),
603                    bytes: u32::try_from(size).unwrap_or(u32::MAX),
604                }
605            })
606            .collect();
607        Ok(AssembledContext {
608            fragments,
609            manifest: ContextManifest {
610                policy: name,
611                policy_version: version,
612                fragments: manifest_fragments,
613                tokens,
614                bytes,
615                frontier: frontier
616                    .into_iter()
617                    .map(|((topic, partition), offset)| (topic, partition, offset))
618                    .collect(),
619                correlation: None,
620            },
621        })
622    }
623
624    /// Record a model request addressed to this agent, and the context it
625    /// received when `assembled` is given. Finish the returned call when the
626    /// application's provider answers.
627    pub async fn model(
628        &self,
629        request: ModelRequest,
630        assembled: Option<&AssembledContext>,
631    ) -> Result<ModelCall, LaserError> {
632        let correlation = CorrelationId::mint();
633        self.write_model_request(correlation, &request, assembled)
634            .await?;
635        Ok(ModelCall {
636            session: self.clone(),
637            correlation,
638            operation: request.operation,
639            started: Instant::now(),
640        })
641    }
642
643    /// Record a tool call addressed to this agent, with `args` redacted.
644    pub async fn tool(
645        &self,
646        name: impl Into<String>,
647        mut args: serde_json::Value,
648    ) -> Result<ToolCall, LaserError> {
649        let tool = name.into();
650        (self.redactor)(&mut args);
651        let correlation = CorrelationId::mint();
652        let agent = self.require_agent()?;
653        let body = serde_json::to_vec(&args)
654            .map_err(|error| LaserError::Codec(format!("tool arguments: {error}")))?;
655        self.lane()?
656            .command(correlation, body)
657            .with_operation(OPERATION_EXECUTE_TOOL)
658            .with_tool(tool.clone())
659            .with_target(agent)
660            .content_type(ContentType::Json)
661            .send()
662            .await?;
663        Ok(ToolCall {
664            session: self.clone(),
665            correlation,
666            tool,
667            started: Instant::now(),
668        })
669    }
670
671    /// Record a model call that already happened: the request, the context
672    /// when given, and the response. `duration` is the measured call time.
673    /// A response with no duration records `duration_micros = 0`.
674    pub async fn record_model_call(
675        &self,
676        request: ModelRequest,
677        response: ModelResponse,
678        assembled: Option<&AssembledContext>,
679    ) -> Result<AgdxReceipt, LaserError> {
680        let correlation = CorrelationId::mint();
681        self.write_model_request(correlation, &request, assembled)
682            .await?;
683        let duration = response.duration;
684        self.write_result(
685            correlation,
686            &request.operation,
687            None,
688            response.body,
689            response.usage,
690            duration.unwrap_or_default(),
691            response.model,
692            response.finish_reason,
693        )
694        .await
695    }
696
697    /// The key-value namespace `namespace` with every write linked to this
698    /// session. Feature `kv`.
699    #[cfg(feature = "kv")]
700    pub fn kv(&self, namespace: impl Into<String>) -> Result<crate::kv::Kv, LaserError> {
701        Ok(self.laser.kv(namespace).in_session(self.reference()?))
702    }
703
704    /// The knowledge graph `name` with every upsert linked to this session,
705    /// stamped with this agent as producer and the current record as source.
706    /// Feature `graph`.
707    #[cfg(feature = "graph")]
708    pub fn linked_graph(
709        &self,
710        name: impl Into<String>,
711    ) -> Result<crate::graph::GraphHandle<'_>, LaserError> {
712        let mut graph = self
713            .laser
714            .graph(name)
715            .in_session(self.reference()?)
716            .produced_by(self.producer());
717        if let Some(source) = &self.current {
718            graph = graph.sourced_from(source.clone());
719        }
720        Ok(graph)
721    }
722
723    /// Record that `items`, recalled for `query` when there was one, entered
724    /// the session's context, with each item's id and score.
725    pub async fn record_retrieval(
726        &self,
727        query: Option<String>,
728        items: &[crate::memory::MemoryItem],
729    ) -> Result<AgdxReceipt, LaserError> {
730        let retrieval = laser_wire::agent::ContextRetrieval {
731            query,
732            items: items
733                .iter()
734                .map(|item| (item.id.to_string(), item.score.unwrap_or_default()))
735                .collect(),
736        };
737        self.write_event(
738            laser_wire::dispatch::OPERATION_CONTEXT_RETRIEVED,
739            encode_named(&retrieval)?,
740            None,
741        )
742        .await
743    }
744
745    /// Record that a summary replaced the covered records of the session's
746    /// context.
747    pub async fn record_compaction(
748        &self,
749        compaction: laser_wire::agent::ContextCompaction,
750    ) -> Result<AgdxReceipt, LaserError> {
751        self.write_event(
752            laser_wire::dispatch::OPERATION_CONTEXT_COMPACTED,
753            encode_named(&compaction)?,
754            None,
755        )
756        .await
757    }
758
759    /// This session's memory with every remembered item stamped with this
760    /// agent as producer and the current record as origin.
761    pub fn linked_memory(&self) -> crate::context_scope::ScopedMemory {
762        self.memory()
763            .with_lineage(self.current.clone(), Some(self.producer()))
764    }
765
766    fn producer(&self) -> ProducerInfo {
767        ProducerInfo {
768            name: match &self.agent {
769                Some(agent) => format!("sdk:{agent}"),
770                None => "sdk".to_owned(),
771            },
772            version: self.config().sdk_info().version.clone(),
773        }
774    }
775
776    fn require_agent(&self) -> Result<laser_wire::agent::AgentId, LaserError> {
777        self.agent
778            .clone()
779            .ok_or_else(|| LaserError::Invalid("this session handle has no agent".to_owned()))
780    }
781
782    async fn lane_identity(&self) -> Result<(u32, u32, u64), LaserError> {
783        let stream = Identifier::named(self.stream()?)?;
784        let client = self.laser.client();
785        let stream_id = client
786            .get_stream(&stream)
787            .await?
788            .ok_or_else(|| LaserError::Invalid("the session stream does not exist".to_owned()))?
789            .id;
790        let topic = client
791            .get_topic(&stream, &AgentTopic::Sessions.as_identifier())
792            .await?
793            .ok_or_else(|| LaserError::Invalid("agent.sessions does not exist".to_owned()))?;
794        Ok((stream_id, topic.id, topic.created_at.as_micros()))
795    }
796
797    async fn write_model_request(
798        &self,
799        correlation: CorrelationId,
800        request: &ModelRequest,
801        assembled: Option<&AssembledContext>,
802    ) -> Result<(), LaserError> {
803        let agent = self.require_agent()?;
804        let mut body = request.body.clone();
805        if let Ok(mut json) = serde_json::from_slice::<serde_json::Value>(&body) {
806            (self.redactor)(&mut json);
807            body = serde_json::to_vec(&json)
808                .map_err(|error| LaserError::Codec(format!("model request: {error}")))?;
809        }
810        let lane = self.lane()?;
811        let mut send = lane
812            .command(correlation, body)
813            .with_operation(request.operation.clone())
814            .with_target(agent)
815            .with_metadata(METADATA_REQUEST_MODEL, Value::Str(request.model.clone()));
816        if let Some(provider) = &request.provider {
817            send = send.with_metadata(METADATA_PROVIDER_NAME, Value::Str(provider.clone()));
818        }
819        send.send().await?;
820        if let Some(assembled) = assembled {
821            let mut manifest = assembled.manifest.clone();
822            manifest.correlation = Some(correlation);
823            self.write_event(
824                OPERATION_CONTEXT_ASSEMBLED,
825                encode_named(&manifest)?,
826                Some(correlation),
827            )
828            .await?;
829        }
830        Ok(())
831    }
832
833    #[allow(clippy::too_many_arguments)]
834    async fn write_result(
835        &self,
836        correlation: CorrelationId,
837        operation: &str,
838        tool: Option<&str>,
839        body: Vec<u8>,
840        usage: Option<TokenUsage>,
841        duration: Duration,
842        model: Option<String>,
843        finish_reason: Option<String>,
844    ) -> Result<AgdxReceipt, LaserError> {
845        let agent = self.require_agent()?;
846        let lane = self.lane()?;
847        let mut send = lane
848            .respond(correlation, body)
849            .with_operation(operation)
850            .with_target(agent)
851            .with_metadata(
852                METADATA_DURATION_MICROS,
853                Value::Uint(u64::try_from(duration.as_micros()).unwrap_or(u64::MAX)),
854            );
855        if let Some(tool) = tool {
856            send = send.with_tool(tool);
857        }
858        if let Some(usage) = usage {
859            send = send.with_usage(usage);
860        }
861        if let Some(model) = model {
862            send = send.with_metadata(METADATA_RESPONSE_MODEL, Value::Str(model));
863        }
864        if let Some(reason) = finish_reason {
865            send = send.with_finish_reason(reason);
866        }
867        send.send_receipt().await
868    }
869
870    async fn write_failure(
871        &self,
872        correlation: CorrelationId,
873        operation: &str,
874        tool: Option<&str>,
875        error: &AgentErrorBody,
876    ) -> Result<AgdxReceipt, LaserError> {
877        let agent = self.require_agent()?;
878        let lane = self.lane()?;
879        let mut send = lane
880            .fail(correlation, error)?
881            .with_operation(operation)
882            .with_target(agent);
883        if let Some(tool) = tool {
884            send = send.with_tool(tool);
885        }
886        send.send_receipt().await
887    }
888
889    pub(crate) async fn write_event(
890        &self,
891        operation: &str,
892        body: Vec<u8>,
893        correlation: Option<CorrelationId>,
894    ) -> Result<AgdxReceipt, LaserError> {
895        let lane = self.lane()?;
896        let mut send = lane
897            .emit(body)
898            .with_operation(operation)
899            .content_type(ContentType::Cbor);
900        if let Some(correlation) = correlation {
901            send = send.with_correlation(correlation);
902        }
903        send.send_receipt().await
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910
911    #[test]
912    fn given_secret_keys_at_any_depth_when_redacted_then_should_drop_only_their_values() {
913        let mut args = serde_json::json!({
914            "query": "status",
915            "Authorization": "Bearer x",
916            "nested": {"api_key": "k", "keep": 1},
917            "list": [{"password": "p"}],
918        });
919        default_redact(&mut args);
920        assert_eq!(args["query"], "status");
921        assert_eq!(args["Authorization"], "[redacted]");
922        assert_eq!(args["nested"]["api_key"], "[redacted]");
923        assert_eq!(args["nested"]["keep"], 1);
924        assert_eq!(args["list"][0]["password"], "[redacted]");
925    }
926}
927
928/// A session handed to an agent: written as submitted on the lane, with a
929/// command in the agent's inbox. The agent marks it working when it picks the
930/// command up.
931#[must_use = "a submission does nothing until send().await"]
932pub struct SubmitBuilder {
933    sessions: crate::agent::Sessions,
934    agent: laser_wire::agent::AgentId,
935    input: Vec<u8>,
936    submitter: Option<laser_wire::agent::AgentId>,
937    operation: String,
938    label: Option<String>,
939    namespace: Option<String>,
940    budget: Option<laser_wire::agent::Budget>,
941    tags: Vec<String>,
942}
943
944/// What a submission wrote: the session and the command's correlation.
945#[derive(Debug, Clone, Copy, PartialEq, Eq)]
946pub struct Submitted {
947    pub session: crate::types::ConversationId,
948    pub correlation: CorrelationId,
949}
950
951impl SubmitBuilder {
952    /// The agent that submits the session. Required.
953    pub fn from(mut self, submitter: impl Into<laser_wire::agent::AgentId>) -> Self {
954        let submitter = submitter.into();
955        self.submitter = Some(submitter);
956        self
957    }
958
959    /// The command operation, `invoke_agent` unless set.
960    pub fn operation(mut self, operation: impl Into<String>) -> Self {
961        self.operation = operation.into();
962        self
963    }
964
965    /// Name the session, deriving its id from the stream, namespace, and label.
966    pub fn label(mut self, label: impl Into<String>) -> Self {
967        self.label = Some(label.into());
968        self
969    }
970
971    /// The namespace a labeled session id derives under.
972    pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
973        self.namespace = Some(namespace.into());
974        self
975    }
976
977    /// The token and cost ceiling a reader compares the session's usage with.
978    pub fn budget(mut self, budget: laser_wire::agent::Budget) -> Self {
979        self.budget = Some(budget);
980        self
981    }
982
983    /// One searchable tag.
984    pub fn tag(mut self, tag: impl Into<String>) -> Self {
985        self.tags.push(tag.into());
986        self
987    }
988
989    /// Write the submitted status and the command.
990    pub async fn send(self) -> Result<Submitted, LaserError> {
991        let submitter = self.submitter.ok_or_else(|| {
992            LaserError::Invalid("a submission needs `.from(submitter)`".to_owned())
993        })?;
994        let stream = self.sessions.stream()?.to_owned();
995        let session = match &self.label {
996            Some(label) => crate::agent::derive_session_id(
997                &stream,
998                self.namespace.as_deref().unwrap_or_default(),
999                label,
1000            ),
1001            None => crate::types::ConversationId::new(),
1002        };
1003        let start = laser_wire::agent::SessionStart {
1004            label: self.label,
1005            namespace: self.namespace,
1006            agent: self.agent.clone(),
1007            sdk: self.sessions.config().sdk_info().clone(),
1008            parent: None,
1009            root: None,
1010            idle_timeout_micros: Some(
1011                u64::try_from(self.sessions.config().idle_timeout_value().as_micros())
1012                    .unwrap_or(u64::MAX),
1013            ),
1014            budget: self.budget,
1015            tags: self.tags,
1016        };
1017        laser_wire::validate::Validate::validate(&start)?;
1018        let handle = self.sessions.open(session).as_agent(submitter);
1019        let lane = handle.lane()?;
1020        lane.status(laser_wire::agent::OPERATION_SESSION)
1021            .with_task_state(laser_wire::agent::TaskState::Submitted)
1022            .body(encode_named(&start)?)
1023            .content_type(ContentType::Cbor)
1024            .send()
1025            .await?;
1026        let correlation = CorrelationId::mint();
1027        lane.command(correlation, self.input)
1028            .with_operation(self.operation)
1029            .with_target(self.agent)
1030            .with_metadata(laser_wire::agent::METADATA_SUBMITTED, Value::Bool(true))
1031            .send()
1032            .await?;
1033        Ok(Submitted {
1034            session,
1035            correlation,
1036        })
1037    }
1038}
1039
1040/// Operator control of one session, sent on `agent.control`. Only accounts
1041/// with send permission on that topic can use it.
1042pub struct SessionControl {
1043    laser: crate::laser::Laser,
1044    session: crate::types::ConversationId,
1045    operator: Option<laser_wire::agent::AgentId>,
1046    participants: Option<Vec<laser_wire::agent::AgentId>>,
1047    #[cfg(feature = "sign")]
1048    key: Option<crate::sign::SigningKey>,
1049}
1050
1051impl SessionControl {
1052    /// The operator identity the control records carry. Required.
1053    #[must_use]
1054    pub fn as_operator(mut self, operator: impl Into<laser_wire::agent::AgentId>) -> Self {
1055        let operator = operator.into();
1056        self.operator = Some(operator);
1057        self
1058    }
1059
1060    /// Sign every control record with `key`, so a verifying reader can
1061    /// prove which operator sent it. Feature `sign`.
1062    #[cfg(feature = "sign")]
1063    #[must_use]
1064    pub fn signed_by(mut self, key: crate::sign::SigningKey) -> Self {
1065        self.key = Some(key);
1066        self
1067    }
1068
1069    /// The agents whose acknowledgments complete a pause, instead of the set
1070    /// [`pause`](Self::pause) reads from the session lane.
1071    #[must_use]
1072    pub fn participants(
1073        mut self,
1074        participants: impl IntoIterator<Item = impl Into<laser_wire::agent::AgentId>>,
1075    ) -> Self {
1076        self.participants = Some(participants.into_iter().map(Into::into).collect());
1077        self
1078    }
1079
1080    /// Ask the session's agents to pause. The request names the agents whose
1081    /// acknowledgments complete the pause: the set given to
1082    /// [`participants`](Self::participants), or else the agents the session
1083    /// lane shows working on the session, the addressees of its work commands
1084    /// and the agents that picked it up. Every agent that receives work for
1085    /// the session holds it until the resume, named or not.
1086    pub async fn pause(&self) -> Result<AgdxReceipt, LaserError> {
1087        let participants = match &self.participants {
1088            Some(participants) => participants.clone(),
1089            None => crate::agent::pause::lane_participants(&self.laser, self.session).await?,
1090        };
1091        let body = serde_json::to_vec(&laser_wire::agent::SessionPauseRequest { participants })
1092            .map_err(|error| LaserError::Codec(format!("pause request: {error}")))?;
1093        self.request_with(laser_wire::dispatch::OPERATION_SESSION_PAUSE, body)
1094            .await
1095    }
1096
1097    /// Ask a paused session's agents to resume.
1098    pub async fn resume(&self) -> Result<AgdxReceipt, LaserError> {
1099        self.request(laser_wire::dispatch::OPERATION_SESSION_RESUME)
1100            .await
1101    }
1102
1103    /// Ask the session's agents to end it as canceled at their next boundary.
1104    pub async fn cancel(&self) -> Result<AgdxReceipt, LaserError> {
1105        self.request(laser_wire::dispatch::OPERATION_SESSION_CANCEL)
1106            .await
1107    }
1108
1109    /// End the session as canceled without its agents, for a session whose
1110    /// agent is gone.
1111    pub async fn force_cancel(&self) -> Result<AgdxReceipt, LaserError> {
1112        let end = laser_wire::agent::SessionEnd {
1113            reason: Some("forced".to_owned()),
1114            error: None,
1115        };
1116        let producer = self.producer()?;
1117        let send = producer
1118            .status(laser_wire::agent::OPERATION_SESSION)
1119            .with_task_state(laser_wire::agent::TaskState::Canceled)
1120            .body(encode_named(&end)?)
1121            .content_type(ContentType::Cbor)
1122            .last();
1123        self.sign(send).send_receipt().await
1124    }
1125
1126    async fn request(&self, operation: &str) -> Result<AgdxReceipt, LaserError> {
1127        self.request_with(operation, b"{}".to_vec()).await
1128    }
1129
1130    async fn request_with(
1131        &self,
1132        operation: &str,
1133        body: Vec<u8>,
1134    ) -> Result<AgdxReceipt, LaserError> {
1135        let producer = self.producer()?;
1136        let send = producer
1137            .command(CorrelationId::mint(), body)
1138            .with_operation(operation)
1139            .content_type(ContentType::Json);
1140        self.sign(send).send_receipt().await
1141    }
1142
1143    fn sign<'a>(&'a self, send: crate::agent::AgdxSend<'a>) -> crate::agent::AgdxSend<'a> {
1144        #[cfg(feature = "sign")]
1145        if let Some(key) = &self.key {
1146            return send.signed_by(key);
1147        }
1148        send
1149    }
1150
1151    fn producer(&self) -> Result<crate::agent::Agdx, LaserError> {
1152        let operator = self.operator.clone().ok_or_else(|| {
1153            LaserError::Invalid("session control needs `.as_operator(id)`".to_owned())
1154        })?;
1155        Ok(self
1156            .laser
1157            .agdx(AgentTopic::Control, operator, self.session.into()))
1158    }
1159}
1160
1161impl crate::agent::Sessions {
1162    /// Hand a new session to `agent` with `input` as its first command.
1163    pub fn submit(
1164        &self,
1165        agent: impl Into<laser_wire::agent::AgentId>,
1166        input: impl Into<Vec<u8>>,
1167    ) -> SubmitBuilder {
1168        SubmitBuilder {
1169            sessions: self.clone(),
1170            agent: agent.into(),
1171            input: input.into(),
1172            submitter: None,
1173            operation: laser_wire::dispatch::OPERATION_INVOKE_AGENT.to_owned(),
1174            label: None,
1175            namespace: None,
1176            budget: None,
1177            tags: Vec::new(),
1178        }
1179    }
1180
1181    /// Operator control of the session `id` in `stream`.
1182    pub fn control(&self, stream: &str, id: crate::types::ConversationId) -> SessionControl {
1183        SessionControl {
1184            laser: self.laser().with_default_stream(stream),
1185            session: id,
1186            operator: None,
1187            participants: None,
1188            #[cfg(feature = "sign")]
1189            key: None,
1190        }
1191    }
1192}