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
23const REDACTED_KEYS: [&str; 6] = [
26 "authorization",
27 "api_key",
28 "token",
29 "password",
30 "secret",
31 "cookie",
32];
33
34const SNAPSHOT_EVERY_DELTAS: u32 = 64;
36
37pub 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#[derive(Default)]
59pub(crate) struct StateCursor {
60 revision: u64,
61 document: Option<serde_json::Value>,
62 since_snapshot: u32,
63}
64
65#[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 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 #[must_use]
88 pub fn provider(mut self, provider: impl Into<String>) -> Self {
89 self.provider = Some(provider.into());
90 self
91 }
92
93 #[must_use]
95 pub fn operation(mut self, operation: impl Into<String>) -> Self {
96 self.operation = operation.into();
97 self
98 }
99}
100
101#[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 pub duration: Option<Duration>,
111}
112
113#[derive(Debug, Clone)]
115pub struct AssembledContext {
116 pub fragments: Vec<ContextMessage>,
117 pub manifest: ContextManifest,
118}
119
120impl AssembledContext {
121 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
131pub struct ModelCall {
134 session: Session,
135 correlation: CorrelationId,
136 operation: String,
137 started: Instant,
138}
139
140impl ModelCall {
141 pub fn correlation(&self) -> CorrelationId {
143 self.correlation
144 }
145
146 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 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
172pub struct ToolCall {
175 session: Session,
176 correlation: CorrelationId,
177 tool: String,
178 started: Instant,
179}
180
181impl ToolCall {
182 pub fn correlation(&self) -> CorrelationId {
184 self.correlation
185 }
186
187 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 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
217pub struct SessionState {
220 session: Session,
221}
222
223impl SessionState {
224 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 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 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) = ¤t {
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 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 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 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 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 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 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 #[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 #[must_use]
469 pub fn acting_on(mut self, source: SourceRef) -> Self {
470 self.current = Some(source);
471 self
472 }
473
474 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 pub async fn pending_control(&self) -> Result<crate::agent::PendingControl, LaserError> {
501 Ok(self.control_state().await?.flags)
502 }
503
504 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 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 pub fn state(&self) -> SessionState {
562 SessionState {
563 session: self.clone(),
564 }
565 }
566
567 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 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 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 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 #[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 #[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 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 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
946pub struct Submitted {
947 pub session: crate::types::ConversationId,
948 pub correlation: CorrelationId,
949}
950
951impl SubmitBuilder {
952 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 pub fn operation(mut self, operation: impl Into<String>) -> Self {
961 self.operation = operation.into();
962 self
963 }
964
965 pub fn label(mut self, label: impl Into<String>) -> Self {
967 self.label = Some(label.into());
968 self
969 }
970
971 pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
973 self.namespace = Some(namespace.into());
974 self
975 }
976
977 pub fn budget(mut self, budget: laser_wire::agent::Budget) -> Self {
979 self.budget = Some(budget);
980 self
981 }
982
983 pub fn tag(mut self, tag: impl Into<String>) -> Self {
985 self.tags.push(tag.into());
986 self
987 }
988
989 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
1040pub 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 #[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 #[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 #[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 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 pub async fn resume(&self) -> Result<AgdxReceipt, LaserError> {
1099 self.request(laser_wire::dispatch::OPERATION_SESSION_RESUME)
1100 .await
1101 }
1102
1103 pub async fn cancel(&self) -> Result<AgdxReceipt, LaserError> {
1105 self.request(laser_wire::dispatch::OPERATION_SESSION_CANCEL)
1106 .await
1107 }
1108
1109 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 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 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}