1use crate::value::VmDictExt;
20use std::cell::{Cell, RefCell};
21use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
22use std::future::Future;
23use std::path::{Path, PathBuf};
24use std::sync::{Mutex, OnceLock};
25use std::time::Instant;
26
27use crate::actor_chain::ActorChain;
28use crate::agent_events::{
29 AgentEvent, AttachmentFlavor, AttachmentRendering, HostInjectionProvenance, InjectionDelivery,
30 SanitizationAction, SanitizationVerdict, ToolCallStatus,
31};
32use crate::agent_transcript_budget::{
33 apply_transcript_with_budget, transcript_budget_policy_json, transcript_budget_usage_json,
34 transcript_message_count, transcript_usage,
35};
36use crate::runtime_limits::RuntimeLimits;
37use crate::security::TrustLevel;
38use crate::tool_annotations::ToolKind;
39use crate::value::VmValue;
40use crate::workspace_anchor::{
41 MountMode, MountedRoot, WorkspaceAnchor, WorkspacePolicy, WORKSPACE_ANCHOR_METADATA_KEY,
42};
43
44const LIVE_CLIENT_EVENT_KIND: &str = "live_session_client";
45const LIVE_CLIENT_PERMISSION_EVENT_KIND: &str = "live_session_permission_route";
46
47pub const DEFAULT_SESSION_CAP: usize = RuntimeLimits::DEFAULT.max_agent_sessions;
50
51pub const DEFAULT_TRANSCRIPT_MESSAGE_CAP: usize = 4096;
55
56pub const DEFAULT_TRANSCRIPT_EVENT_CAP: usize = 32768;
59pub const MAX_SCRATCHPAD_BYTES: usize = 16 * 1024;
60#[cfg(debug_assertions)]
61const CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC: &str = "HARN-CACHE-001";
62
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub enum TranscriptBudgetRecovery {
65 Reject,
66 Trim { keep_last: usize },
67 Compact { keep_last: usize },
68}
69
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub struct SessionTranscriptBudgetPolicy {
72 pub max_messages: usize,
73 pub max_events: usize,
74 pub max_approx_bytes: Option<usize>,
75 pub recovery: TranscriptBudgetRecovery,
76}
77
78impl SessionTranscriptBudgetPolicy {
79 pub fn reject(max_messages: usize, max_events: usize) -> Self {
80 Self {
81 max_messages: max_messages.max(1),
82 max_events: max_events.max(1),
83 max_approx_bytes: None,
84 recovery: TranscriptBudgetRecovery::Reject,
85 }
86 }
87
88 pub fn trim(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
89 Self {
90 max_messages: max_messages.max(1),
91 max_events: max_events.max(1),
92 max_approx_bytes: None,
93 recovery: TranscriptBudgetRecovery::Trim { keep_last },
94 }
95 }
96
97 pub fn compact(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
98 Self {
99 max_messages: max_messages.max(1),
100 max_events: max_events.max(1),
101 max_approx_bytes: None,
102 recovery: TranscriptBudgetRecovery::Compact { keep_last },
103 }
104 }
105
106 pub fn with_max_approx_bytes(mut self, max_approx_bytes: Option<usize>) -> Self {
107 self.max_approx_bytes = max_approx_bytes.map(|limit| limit.max(1));
108 self
109 }
110
111 pub(crate) fn normalized(&self) -> Self {
112 Self {
113 max_messages: self.max_messages.max(1),
114 max_events: self.max_events.max(1),
115 max_approx_bytes: self.max_approx_bytes.map(|limit| limit.max(1)),
116 recovery: self.recovery.clone(),
117 }
118 }
119}
120
121impl Default for SessionTranscriptBudgetPolicy {
122 fn default() -> Self {
123 Self::reject(DEFAULT_TRANSCRIPT_MESSAGE_CAP, DEFAULT_TRANSCRIPT_EVENT_CAP)
124 }
125}
126
127pub struct SessionState {
128 pub id: String,
129 pub transcript: VmValue,
130 pub subscribers: Vec<VmValue>,
131 pub created_at: String,
132 pub last_accessed: Instant,
133 pub parent_id: Option<String>,
134 pub child_ids: Vec<String>,
135 pub branched_at_event_index: Option<usize>,
136 pub actor_chain: Option<ActorChain>,
137 pub active_skills: Vec<String>,
143 pub tool_format: Option<String>,
147 pub system_prompt: Option<String>,
151 pub pinned_model: Option<String>,
157 pub pinned_reasoning_policy: Option<String>,
163 pub workspace_policy: WorkspacePolicy,
166 pub workspace_anchor: Option<WorkspaceAnchor>,
172 pub scratchpad: Option<VmValue>,
175 pub scratchpad_version: u64,
176 pub transcript_budget_policy: SessionTranscriptBudgetPolicy,
177 pub last_transcript_budget_action: Option<serde_json::Value>,
178 pub live_clients: BTreeMap<String, LiveSessionClient>,
179 pub live_controller_id: Option<String>,
180 pub completed_turn_checkpoints: Vec<SessionTurnCheckpoint>,
181 pub redo_stack: Vec<SessionRedoEntry>,
182 pub text_tool_call_seq: u64,
183 pub taint: Vec<crate::security::TaintRecord>,
187}
188
189impl SessionState {
190 fn new(id: String) -> Self {
191 let now = Instant::now();
192 let transcript = empty_transcript(&id);
193 Self {
194 id,
195 transcript,
196 subscribers: Vec::new(),
197 created_at: crate::orchestration::now_rfc3339(),
198 last_accessed: now,
199 parent_id: None,
200 child_ids: Vec::new(),
201 branched_at_event_index: None,
202 actor_chain: None,
203 active_skills: Vec::new(),
204 tool_format: None,
205 system_prompt: None,
206 pinned_model: None,
207 pinned_reasoning_policy: None,
208 workspace_policy: WorkspacePolicy::default(),
209 workspace_anchor: None,
210 scratchpad: None,
211 scratchpad_version: 0,
212 transcript_budget_policy: default_transcript_budget_policy(),
213 last_transcript_budget_action: None,
214 live_clients: BTreeMap::new(),
215 live_controller_id: None,
216 completed_turn_checkpoints: Vec::new(),
217 redo_stack: Vec::new(),
218 text_tool_call_seq: 0,
219 taint: Vec::new(),
220 }
221 }
222
223 fn touch(&mut self) {
224 self.last_accessed = Instant::now();
225 }
226
227 pub(crate) fn replace_transcript(&mut self, transcript: VmValue) {
228 if !crate::values_equal(&self.transcript, &transcript) {
229 self.redo_stack.clear();
230 }
231 self.transcript = transcript;
232 self.touch();
233 }
234}
235
236pub(crate) fn push_session_taint(id: &str, record: crate::security::TaintRecord) {
237 SESSIONS.with(|sessions| {
238 if let Some(state) = sessions.borrow_mut().get_mut(id) {
239 state.taint.push(record);
240 state.touch();
241 }
242 });
243}
244
245pub(crate) fn session_taint_snapshot(id: &str) -> Vec<crate::security::TaintRecord> {
246 SESSIONS.with(|sessions| {
247 sessions
248 .borrow()
249 .get(id)
250 .map(|state| state.taint.clone())
251 .unwrap_or_default()
252 })
253}
254
255#[derive(Clone, Debug, PartialEq, Eq)]
256pub enum LiveClientMode {
257 Observer,
258 Controller,
259}
260
261impl LiveClientMode {
262 fn as_str(&self) -> &'static str {
263 match self {
264 Self::Observer => "observer",
265 Self::Controller => "controller",
266 }
267 }
268}
269
270#[derive(Clone, Debug, PartialEq, Eq)]
271pub struct LiveSessionClient {
272 pub client_id: String,
273 pub mode: LiveClientMode,
274 pub attached_at: String,
275 pub last_seen_at: String,
276 pub prompt_injection: bool,
277 pub permission_routing: bool,
278 pub metadata: serde_json::Value,
279}
280
281#[derive(Clone, Debug, PartialEq, Eq)]
282pub struct AttachLiveClient {
283 pub client_id: String,
284 pub mode: LiveClientMode,
285 pub takeover: bool,
286 pub prompt_injection: bool,
287 pub permission_routing: bool,
288 pub metadata: serde_json::Value,
289}
290
291#[derive(Clone, Debug, PartialEq, Eq)]
292pub struct LiveClientChange {
293 pub client: Option<LiveSessionClient>,
294 pub previous_controller_id: Option<String>,
295 pub active_controller_id: Option<String>,
296 pub clients: Vec<LiveSessionClient>,
297}
298
299#[derive(Clone, Debug, PartialEq, Eq)]
300pub struct SessionAncestry {
301 pub parent_id: Option<String>,
302 pub child_ids: Vec<String>,
303 pub root_id: String,
304}
305
306#[derive(Clone, Debug, PartialEq, Eq)]
307pub struct SessionTruncateResult {
308 pub kept_turn_count: usize,
309 pub removed_turn_count: usize,
310 pub new_tip_turn_id: Option<String>,
311}
312
313#[derive(Clone, Debug, PartialEq, Eq)]
314pub struct SessionCheckpointSummary {
315 pub checkpoint_id: String,
316 pub before_message_count: usize,
317 pub after_message_count: usize,
318 pub fs_snapshot_ids: Vec<String>,
319}
320
321#[derive(Clone, Debug)]
322pub struct SessionTurnCheckpoint {
323 pub checkpoint_id: String,
324 pub completed_at: String,
325 pub before_transcript: VmValue,
326 pub after_transcript: VmValue,
327 pub before_message_count: usize,
328 pub after_message_count: usize,
329 pub fs_snapshot_ids: Vec<String>,
330}
331
332#[derive(Clone, Debug)]
333pub struct SessionRedoEntry {
334 pub checkpoint: SessionTurnCheckpoint,
335 pub redo_fs_snapshot_ids: Vec<String>,
336}
337
338#[derive(Clone, Debug, PartialEq, Eq)]
339pub enum SessionCheckpointError {
340 UnknownSession,
341 NoCheckpoint,
342 NoRedo,
343}
344
345#[derive(Clone, Debug, PartialEq, Eq)]
346pub struct SessionCheckpointOutcome {
347 pub status: &'static str,
348 pub checkpoint: SessionCheckpointSummary,
349 pub redo_fs_snapshot_ids: Vec<String>,
350}
351
352#[derive(Clone, Debug, PartialEq, Eq)]
353pub struct ReminderInjectionReport {
354 pub reminder_id: String,
355 pub deduped_count: usize,
356}
357
358thread_local! {
359 static SESSIONS: RefCell<HashMap<String, SessionState>> = RefCell::new(HashMap::new());
360 static SESSION_CAP: Cell<usize> = const { Cell::new(DEFAULT_SESSION_CAP) };
361 static DEFAULT_TRANSCRIPT_BUDGET_POLICY: RefCell<SessionTranscriptBudgetPolicy> =
362 RefCell::new(SessionTranscriptBudgetPolicy::default());
363 static CURRENT_SESSION_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
364 static CURRENT_TOOL_CALL_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
365}
366
367tokio::task_local! {
368 static CURRENT_TOOL_CALL_TASK: String;
369}
370
371static SESSION_CHANGED_PATHS: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
381
382fn session_changed_paths_store() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
383 SESSION_CHANGED_PATHS.get_or_init(|| Mutex::new(BTreeMap::new()))
384}
385
386pub fn record_session_changed_path(session_id: &str, path: &str) {
389 if session_id.is_empty() || path.is_empty() {
390 return;
391 }
392 if let Ok(mut store) = session_changed_paths_store().lock() {
393 store
394 .entry(session_id.to_string())
395 .or_default()
396 .insert(path.to_string());
397 }
398}
399
400pub fn session_changed_paths(session_id: &str) -> Vec<String> {
403 session_changed_paths_store()
404 .lock()
405 .ok()
406 .and_then(|store| {
407 store
408 .get(session_id)
409 .map(|set| set.iter().cloned().collect())
410 })
411 .unwrap_or_default()
412}
413
414pub fn take_session_changed_paths(session_id: &str) -> Vec<String> {
418 session_changed_paths_store()
419 .lock()
420 .ok()
421 .and_then(|mut store| store.remove(session_id))
422 .map(|set| set.into_iter().collect())
423 .unwrap_or_default()
424}
425
426pub fn clear_session_changed_paths(session_id: &str) {
428 if let Ok(mut store) = session_changed_paths_store().lock() {
429 store.remove(session_id);
430 }
431}
432
433pub struct CurrentSessionGuard {
434 active: bool,
435}
436
437impl Drop for CurrentSessionGuard {
438 fn drop(&mut self) {
439 if self.active {
440 pop_current_session();
441 }
442 }
443}
444
445pub struct CurrentToolCallGuard {
451 active: bool,
452}
453
454impl Drop for CurrentToolCallGuard {
455 fn drop(&mut self) {
456 if self.active {
457 pop_current_tool_call();
458 }
459 }
460}
461
462pub fn set_session_cap(cap: usize) {
465 SESSION_CAP.with(|c| c.set(cap.max(1)));
466}
467
468pub fn session_cap() -> usize {
469 SESSION_CAP.with(|c| c.get())
470}
471
472pub fn set_default_transcript_budget_policy(policy: SessionTranscriptBudgetPolicy) {
473 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| {
474 *cell.borrow_mut() = policy.normalized();
475 });
476}
477
478pub fn reset_default_transcript_budget_policy() {
479 set_default_transcript_budget_policy(SessionTranscriptBudgetPolicy::default());
480}
481
482pub fn default_transcript_budget_policy() -> SessionTranscriptBudgetPolicy {
483 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| cell.borrow().clone())
484}
485
486pub fn transcript_budget_policy(id: &str) -> Option<SessionTranscriptBudgetPolicy> {
487 SESSIONS.with(|s| {
488 s.borrow()
489 .get(id)
490 .map(|state| state.transcript_budget_policy.clone())
491 })
492}
493
494pub fn set_transcript_budget_policy(
495 id: &str,
496 policy: SessionTranscriptBudgetPolicy,
497) -> Result<(), String> {
498 SESSIONS.with(|s| {
499 let mut map = s.borrow_mut();
500 let Some(state) = map.get_mut(id) else {
501 return Err(format!("agent session '{id}' does not exist"));
502 };
503 let previous = state.transcript_budget_policy.clone();
504 let previous_action = state.last_transcript_budget_action.clone();
505 state.transcript_budget_policy = policy.normalized();
506 let candidate = state.transcript.clone();
507 if let Err(error) = apply_transcript_with_budget(state, candidate, "policy_update") {
508 state.transcript_budget_policy = previous;
509 state.last_transcript_budget_action = previous_action;
510 return Err(error);
511 }
512 Ok(())
513 })
514}
515
516pub fn reset_session_store() {
518 SESSIONS.with(|s| s.borrow_mut().clear());
519 CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().clear());
520 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().clear());
521 reset_default_transcript_budget_policy();
522}
523
524pub(crate) fn push_current_session(id: String) {
525 if id.is_empty() {
526 return;
527 }
528 CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().push(id));
529}
530
531pub(crate) fn swap_current_session_stack(replacement: Vec<String>) -> Vec<String> {
532 CURRENT_SESSION_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), replacement))
533}
534
535pub(crate) fn pop_current_session() {
536 CURRENT_SESSION_STACK.with(|stack| {
537 let _ = stack.borrow_mut().pop();
538 });
539}
540
541pub fn current_session_id() -> Option<String> {
542 CURRENT_SESSION_STACK.with(|stack| stack.borrow().last().cloned())
543}
544
545pub(crate) fn next_text_tool_call_seq(id: &str) -> Option<u64> {
546 SESSIONS.with(|s| {
547 let mut map = s.borrow_mut();
548 let state = map.get_mut(id)?;
549 let seq = state.text_tool_call_seq;
550 state.text_tool_call_seq = state.text_tool_call_seq.checked_add(1).unwrap_or(0);
551 state.touch();
552 Some(seq)
553 })
554}
555
556fn parse_text_tool_call_seq(id: &str) -> Option<u64> {
557 let digits = id.strip_prefix("tc_")?;
558 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
559 return None;
560 }
561 digits.parse().ok()
562}
563
564fn update_next_text_tool_call_seq(value: &serde_json::Value, next_seq: &mut u64) {
565 match value {
566 serde_json::Value::Array(items) => {
567 for item in items {
568 update_next_text_tool_call_seq(item, next_seq);
569 }
570 }
571 serde_json::Value::Object(map) => {
572 for key in ["id", "tool_call_id"] {
573 if let Some(seq) = map
574 .get(key)
575 .and_then(serde_json::Value::as_str)
576 .and_then(parse_text_tool_call_seq)
577 {
578 *next_seq = (*next_seq).max(seq.saturating_add(1));
579 }
580 }
581 for item in map.values() {
582 update_next_text_tool_call_seq(item, next_seq);
583 }
584 }
585 _ => {}
586 }
587}
588
589fn next_text_tool_call_seq_from_json_messages(messages: &[serde_json::Value]) -> u64 {
590 let mut next_seq = 0;
591 for message in messages {
592 update_next_text_tool_call_seq(message, &mut next_seq);
593 }
594 next_seq
595}
596
597fn next_text_tool_call_seq_from_transcript(transcript: &VmValue) -> u64 {
598 let Some(dict) = transcript.as_dict() else {
599 return 0;
600 };
601 let Some(VmValue::List(messages)) = dict.get("messages") else {
602 return 0;
603 };
604 next_text_tool_call_seq_from_json_messages(
605 &messages
606 .iter()
607 .map(crate::llm::helpers::vm_value_to_json)
608 .collect::<Vec<_>>(),
609 )
610}
611
612pub fn current_actor_chain() -> Option<ActorChain> {
613 current_session_id().as_deref().and_then(actor_chain)
614}
615
616pub fn enter_current_session(id: impl Into<String>) -> CurrentSessionGuard {
617 let id = id.into();
618 if id.trim().is_empty() {
619 return CurrentSessionGuard { active: false };
620 }
621 push_current_session(id);
622 CurrentSessionGuard { active: true }
623}
624
625pub fn actor_chain(id: &str) -> Option<ActorChain> {
626 SESSIONS.with(|s| {
627 s.borrow()
628 .get(id)
629 .and_then(|state| state.actor_chain.clone())
630 })
631}
632
633pub fn set_actor_chain(id: &str, actor_chain: Option<ActorChain>) -> Result<bool, String> {
634 SESSIONS.with(|s| {
635 let mut map = s.borrow_mut();
636 let Some(state) = map.get_mut(id) else {
637 return Err(format!("agent session '{id}' does not exist"));
638 };
639 let changed = state.actor_chain != actor_chain;
640 state.actor_chain = actor_chain;
641 state.touch();
642 Ok(changed)
643 })
644}
645
646fn push_current_tool_call(id: String) {
647 if id.is_empty() {
648 return;
649 }
650 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().push(id));
651}
652
653fn pop_current_tool_call() {
654 CURRENT_TOOL_CALL_STACK.with(|stack| {
655 let _ = stack.borrow_mut().pop();
656 });
657}
658
659pub fn current_tool_call_id() -> Option<String> {
664 if let Ok(id) = CURRENT_TOOL_CALL_TASK.try_with(Clone::clone) {
665 if !id.trim().is_empty() {
666 return Some(id);
667 }
668 }
669 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow().last().cloned())
670}
671
672pub async fn scope_current_tool_call<F, T>(id: impl Into<String>, future: F) -> T
678where
679 F: Future<Output = T>,
680{
681 let id = id.into();
682 if id.trim().is_empty() {
683 future.await
684 } else {
685 CURRENT_TOOL_CALL_TASK.scope(id, future).await
686 }
687}
688
689pub fn enter_current_tool_call(id: impl Into<String>) -> CurrentToolCallGuard {
691 let id = id.into();
692 if id.trim().is_empty() {
693 return CurrentToolCallGuard { active: false };
694 }
695 push_current_tool_call(id);
696 CurrentToolCallGuard { active: true }
697}
698
699pub fn exists(id: &str) -> bool {
700 SESSIONS.with(|s| s.borrow().contains_key(id))
701}
702
703pub fn length(id: &str) -> Option<usize> {
704 SESSIONS.with(|s| {
705 s.borrow().get(id).map(|state| {
706 state
707 .transcript
708 .as_dict()
709 .and_then(|d| d.get("messages"))
710 .and_then(|v| match v {
711 VmValue::List(list) => Some(list.len()),
712 _ => None,
713 })
714 .unwrap_or(0)
715 })
716 })
717}
718
719pub fn scratchpad(id: &str) -> Option<VmValue> {
720 SESSIONS.with(|s| {
721 s.borrow()
722 .get(id)
723 .and_then(|state| state.scratchpad.clone())
724 })
725}
726
727pub fn scratchpad_version(id: &str) -> Option<u64> {
728 SESSIONS.with(|s| s.borrow().get(id).map(|state| state.scratchpad_version))
729}
730
731pub fn set_scratchpad(
732 id: &str,
733 scratchpad: VmValue,
734 source: impl Into<String>,
735 reason: Option<String>,
736 metadata: serde_json::Value,
737) -> Result<u64, String> {
738 validate_scratchpad_value(&scratchpad)?;
739 SESSIONS.with(|s| {
740 let mut map = s.borrow_mut();
741 let Some(state) = map.get_mut(id) else {
742 return Err(format!("agent session '{id}' does not exist"));
743 };
744 let version = state.scratchpad_version.saturating_add(1);
745 let event = scratchpad_transcript_event(
746 "set",
747 version,
748 Some(&scratchpad),
749 source.into(),
750 reason,
751 metadata,
752 );
753 append_event_to_state(state, event, "set_scratchpad")?;
754 state.scratchpad = Some(scratchpad);
755 state.scratchpad_version = version;
756 state.touch();
757 Ok(version)
758 })
759}
760
761pub fn clear_scratchpad(
762 id: &str,
763 source: impl Into<String>,
764 reason: Option<String>,
765 metadata: serde_json::Value,
766) -> Result<u64, String> {
767 SESSIONS.with(|s| {
768 let mut map = s.borrow_mut();
769 let Some(state) = map.get_mut(id) else {
770 return Err(format!("agent session '{id}' does not exist"));
771 };
772 let version = state.scratchpad_version.saturating_add(1);
773 let event =
774 scratchpad_transcript_event("clear", version, None, source.into(), reason, metadata);
775 append_event_to_state(state, event, "clear_scratchpad")?;
776 state.scratchpad = None;
777 state.scratchpad_version = version;
778 state.touch();
779 Ok(version)
780 })
781}
782
783fn validate_scratchpad_value(value: &VmValue) -> Result<(), String> {
784 if !matches!(value, VmValue::Dict(_)) {
785 return Err("agent session scratchpad must be a dict".to_string());
786 }
787 let json = crate::llm::helpers::vm_value_to_json(value);
788 let approx_bytes = serde_json::to_vec(&json)
789 .map(|bytes| bytes.len())
790 .unwrap_or(usize::MAX);
791 if approx_bytes > MAX_SCRATCHPAD_BYTES {
792 return Err(format!(
793 "agent session scratchpad is {approx_bytes} bytes; max is {MAX_SCRATCHPAD_BYTES}"
794 ));
795 }
796 Ok(())
797}
798
799fn scratchpad_transcript_event(
800 action: &str,
801 version: u64,
802 scratchpad: Option<&VmValue>,
803 source: String,
804 reason: Option<String>,
805 metadata: serde_json::Value,
806) -> VmValue {
807 let scratchpad_json = scratchpad.map(crate::llm::helpers::vm_value_to_json);
808 let approx_bytes = scratchpad_json
809 .as_ref()
810 .and_then(|value| serde_json::to_vec(value).ok().map(|bytes| bytes.len()))
811 .unwrap_or(0);
812 let event_metadata = serde_json::json!({
813 "action": action,
814 "version": version,
815 "source": normalize_scratchpad_source(source),
816 "reason": reason.unwrap_or_default(),
817 "approx_bytes": approx_bytes,
818 "counts": scratchpad_json
819 .as_ref()
820 .map(scratchpad_counts_json)
821 .unwrap_or_else(|| serde_json::json!({})),
822 "metadata": metadata,
823 });
824 let content = format!("Agent scratchpad {action}");
825 crate::llm::helpers::transcript_event(
826 "agent_scratchpad",
827 "system",
828 "internal",
829 &content,
830 Some(event_metadata),
831 )
832}
833
834fn normalize_scratchpad_source(source: String) -> String {
835 let trimmed = source.trim();
836 if trimmed.is_empty() {
837 "harn.agent_scratchpad".to_string()
838 } else {
839 trimmed.to_string()
840 }
841}
842
843fn scratchpad_counts_json(value: &serde_json::Value) -> serde_json::Value {
844 serde_json::json!({
845 "goals": scratchpad_array_len(value, "goals"),
846 "open_items": scratchpad_array_len(value, "open_items"),
847 "facts": scratchpad_array_len(value, "facts"),
848 "refs": scratchpad_array_len(value, "refs"),
849 })
850}
851
852fn scratchpad_array_len(value: &serde_json::Value, key: &str) -> usize {
853 value
854 .get(key)
855 .and_then(serde_json::Value::as_array)
856 .map(Vec::len)
857 .unwrap_or(0)
858}
859
860pub fn snapshot(id: &str) -> Option<VmValue> {
861 SESSIONS.with(|s| s.borrow().get(id).map(session_snapshot))
862}
863
864pub fn transcript(id: &str) -> Option<VmValue> {
866 SESSIONS.with(|s| {
867 s.borrow()
868 .get(id)
869 .map(|state| transcript_with_session_metadata(state.transcript.clone(), state))
870 })
871}
872
873pub fn open_or_create(id: Option<String>) -> String {
882 open_or_create_with_actor_chain(id, None)
883}
884
885pub fn open_or_create_with_actor_chain(
886 id: Option<String>,
887 requested_actor_chain: Option<ActorChain>,
888) -> String {
889 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
890 let parent_session = current_session_id();
891 let inherited_actor_chain = requested_actor_chain
892 .clone()
893 .or_else(|| parent_session.as_deref().and_then(actor_chain));
894 let mut was_new = false;
895 SESSIONS.with(|s| {
896 let mut map = s.borrow_mut();
897 if let Some(state) = map.get_mut(&resolved) {
898 if let Some(actor_chain) = requested_actor_chain.clone() {
899 state.actor_chain = Some(actor_chain);
900 }
901 state.touch();
902 return;
903 }
904 was_new = true;
905 let cap = SESSION_CAP.with(|c| c.get());
906 if map.len() >= cap {
907 if let Some(victim) = map
908 .iter()
909 .min_by_key(|(_, state)| state.last_accessed)
910 .map(|(id, _)| id.clone())
911 {
912 map.remove(&victim);
913 }
914 }
915 let mut state = SessionState::new(resolved.clone());
916 state.actor_chain = inherited_actor_chain.clone();
917 map.insert(resolved.clone(), state);
918 });
919 if was_new {
920 if let Some(parent) = parent_session.as_deref() {
921 crate::agent_events::mirror_session_sinks(parent, &resolved);
922 }
923 try_register_event_log(&resolved);
924 }
925 resolved
926}
927
928pub fn open_child_session(parent_id: &str, id: Option<String>) -> String {
929 open_child_session_with_actor(parent_id, id, None)
930}
931
932pub fn open_child_session_with_actor(
933 parent_id: &str,
934 id: Option<String>,
935 actor: Option<&str>,
936) -> String {
937 let actor_chain = actor_chain(parent_id).map(|chain| match actor {
938 Some(actor) if !actor.trim().is_empty() => chain.pushed(actor.trim()),
939 _ => chain,
940 });
941 let resolved = open_or_create_with_actor_chain(id, actor_chain);
942 link_child_session(parent_id, &resolved);
943 resolved
944}
945
946pub fn link_child_session(parent_id: &str, child_id: &str) {
947 link_child_session_with_branch(parent_id, child_id, None);
948}
949
950pub fn link_child_session_with_branch(
951 parent_id: &str,
952 child_id: &str,
953 branched_at_event_index: Option<usize>,
954) {
955 if parent_id == child_id {
956 return;
957 }
958 open_or_create(Some(parent_id.to_string()));
959 open_or_create(Some(child_id.to_string()));
960 SESSIONS.with(|s| {
961 let mut map = s.borrow_mut();
962 update_lineage(&mut map, parent_id, child_id, branched_at_event_index);
963 });
964}
965
966pub fn parent_id(id: &str) -> Option<String> {
967 SESSIONS.with(|s| s.borrow().get(id).and_then(|state| state.parent_id.clone()))
968}
969
970pub fn child_ids(id: &str) -> Vec<String> {
971 SESSIONS.with(|s| {
972 s.borrow()
973 .get(id)
974 .map(|state| state.child_ids.clone())
975 .unwrap_or_default()
976 })
977}
978
979pub fn ancestry(id: &str) -> Option<SessionAncestry> {
980 SESSIONS.with(|s| {
981 let map = s.borrow();
982 let state = map.get(id)?;
983 let mut root_id = state.id.clone();
984 let mut cursor = state.parent_id.clone();
985 let mut seen = HashSet::from([state.id.clone()]);
986 while let Some(parent_id) = cursor {
987 if !seen.insert(parent_id.clone()) {
988 break;
989 }
990 root_id = parent_id.clone();
991 cursor = map
992 .get(&parent_id)
993 .and_then(|parent| parent.parent_id.clone());
994 }
995 Some(SessionAncestry {
996 parent_id: state.parent_id.clone(),
997 child_ids: state.child_ids.clone(),
998 root_id,
999 })
1000 })
1001}
1002
1003pub fn live_clients(id: &str) -> Option<Vec<LiveSessionClient>> {
1004 SESSIONS.with(|s| {
1005 s.borrow()
1006 .get(id)
1007 .map(|state| state.live_clients.values().cloned().collect())
1008 })
1009}
1010
1011pub fn attach_live_client(id: &str, request: AttachLiveClient) -> Result<LiveClientChange, String> {
1012 SESSIONS.with(|s| {
1013 let mut map = s.borrow_mut();
1014 let Some(state) = map.get_mut(id) else {
1015 return Err(format!("agent session '{id}' does not exist"));
1016 };
1017 let client_id = validate_live_client_id(request.client_id)?;
1018 let now = crate::orchestration::now_rfc3339();
1019 let previous_clients = state.live_clients.clone();
1020 let previous_controller_id = state.live_controller_id.clone();
1021
1022 if request.mode == LiveClientMode::Controller {
1023 let conflicting_controller = previous_controller_id
1024 .as_ref()
1025 .filter(|controller_id| *controller_id != &client_id)
1026 .filter(|controller_id| state.live_clients.contains_key(*controller_id));
1027 if let Some(previous) = conflicting_controller {
1028 if !request.takeover {
1029 return Err(format!("live session already has controller '{previous}'"));
1030 }
1031 if let Some(previous_client) = state.live_clients.get_mut(previous) {
1032 previous_client.mode = LiveClientMode::Observer;
1033 previous_client.prompt_injection = false;
1034 previous_client.permission_routing = false;
1035 previous_client.last_seen_at = now.clone();
1036 }
1037 }
1038 state.live_controller_id = Some(client_id.clone());
1039 } else if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1040 state.live_controller_id = None;
1041 }
1042
1043 let attached_at = state
1044 .live_clients
1045 .get(&client_id)
1046 .map(|client| client.attached_at.clone())
1047 .unwrap_or_else(|| now.clone());
1048 let client = LiveSessionClient {
1049 client_id: client_id.clone(),
1050 mode: request.mode,
1051 attached_at,
1052 last_seen_at: now,
1053 prompt_injection: request.prompt_injection,
1054 permission_routing: request.permission_routing,
1055 metadata: request.metadata,
1056 };
1057 state.live_clients.insert(client_id, client.clone());
1058 state.touch();
1059 let active_controller_id = state.live_controller_id.clone();
1060 append_live_client_event(
1061 state,
1062 "attached",
1063 Some(&client),
1064 previous_controller_id.as_deref(),
1065 active_controller_id.as_deref(),
1066 serde_json::Value::Null,
1067 )
1068 .inspect_err(|_error| {
1069 state.live_clients = previous_clients;
1070 state.live_controller_id = previous_controller_id.clone();
1071 })?;
1072 Ok(live_client_change(
1073 Some(client),
1074 previous_controller_id,
1075 state,
1076 ))
1077 })
1078}
1079
1080pub fn takeover_live_client(
1081 id: &str,
1082 client_id: impl Into<String>,
1083 metadata: serde_json::Value,
1084) -> Result<LiveClientChange, String> {
1085 attach_live_client(
1086 id,
1087 AttachLiveClient {
1088 client_id: client_id.into(),
1089 mode: LiveClientMode::Controller,
1090 takeover: true,
1091 prompt_injection: true,
1092 permission_routing: true,
1093 metadata,
1094 },
1095 )
1096}
1097
1098pub fn detach_live_client(
1099 id: &str,
1100 client_id: impl Into<String>,
1101 reason: Option<String>,
1102 metadata: serde_json::Value,
1103) -> Result<LiveClientChange, String> {
1104 SESSIONS.with(|s| {
1105 let mut map = s.borrow_mut();
1106 let Some(state) = map.get_mut(id) else {
1107 return Err(format!("agent session '{id}' does not exist"));
1108 };
1109 let client_id = validate_live_client_id(client_id.into())?;
1110 let previous_clients = state.live_clients.clone();
1111 let previous_controller_id = state.live_controller_id.clone();
1112 let Some(mut client) = state.live_clients.remove(&client_id) else {
1113 return Err(format!("live client '{client_id}' is not attached"));
1114 };
1115 client.last_seen_at = crate::orchestration::now_rfc3339();
1116 if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1117 state.live_controller_id = None;
1118 }
1119 state.touch();
1120 let active_controller_id = state.live_controller_id.clone();
1121 append_live_client_event(
1122 state,
1123 "detached",
1124 Some(&client),
1125 previous_controller_id.as_deref(),
1126 active_controller_id.as_deref(),
1127 serde_json::json!({
1128 "reason": reason.unwrap_or_else(|| "client_detached".to_string()),
1129 "metadata": metadata,
1130 }),
1131 )
1132 .inspect_err(|_error| {
1133 state.live_clients = previous_clients;
1134 state.live_controller_id = previous_controller_id.clone();
1135 })?;
1136 Ok(live_client_change(None, previous_controller_id, state))
1137 })
1138}
1139
1140pub fn heartbeat_live_client(
1141 id: &str,
1142 client_id: impl Into<String>,
1143 metadata: serde_json::Value,
1144) -> Result<LiveClientChange, String> {
1145 SESSIONS.with(|s| {
1146 let mut map = s.borrow_mut();
1147 let Some(state) = map.get_mut(id) else {
1148 return Err(format!("agent session '{id}' does not exist"));
1149 };
1150 let client_id = validate_live_client_id(client_id.into())?;
1151 let previous_clients = state.live_clients.clone();
1152 let previous_controller_id = state.live_controller_id.clone();
1153 let Some(client) = state.live_clients.get_mut(&client_id) else {
1154 return Err(format!("live client '{client_id}' is not attached"));
1155 };
1156 client.last_seen_at = crate::orchestration::now_rfc3339();
1157 if !metadata.is_null() {
1158 client.metadata = metadata.clone();
1159 }
1160 let client = client.clone();
1161 state.touch();
1162 let active_controller_id = state.live_controller_id.clone();
1163 append_live_client_event(
1164 state,
1165 "heartbeat",
1166 Some(&client),
1167 previous_controller_id.as_deref(),
1168 active_controller_id.as_deref(),
1169 serde_json::json!({ "metadata": metadata }),
1170 )
1171 .inspect_err(|_error| {
1172 state.live_clients = previous_clients;
1173 state.live_controller_id = previous_controller_id.clone();
1174 })?;
1175 Ok(live_client_change(
1176 Some(client),
1177 previous_controller_id,
1178 state,
1179 ))
1180 })
1181}
1182
1183pub fn inject_prompt_from_live_client(
1184 id: &str,
1185 client_id: impl Into<String>,
1186 content: VmValue,
1187 metadata: serde_json::Value,
1188) -> Result<(), String> {
1189 let client_id = validate_live_client_id(client_id.into())?;
1190 ensure_live_controller(id, &client_id, LiveControllerCapability::PromptInjection)?;
1191 let mut message = BTreeMap::new();
1192 message.put_str("role", "user");
1193 message.insert("content".to_string(), content);
1194 message.insert(
1195 "metadata".to_string(),
1196 crate::stdlib::json_to_vm_value(&serde_json::json!({
1197 "live_session": {
1198 "client_id": client_id,
1199 "mode": "controller",
1200 "source": "live_session_attach",
1201 "metadata": metadata,
1202 }
1203 })),
1204 );
1205 inject_message(id, VmValue::dict(message))
1206}
1207
1208pub fn route_live_permission_request(
1209 id: &str,
1210 client_id: impl Into<String>,
1211 request: serde_json::Value,
1212 metadata: serde_json::Value,
1213) -> Result<serde_json::Value, String> {
1214 let client_id = validate_live_client_id(client_id.into())?;
1215 let client =
1216 ensure_live_controller(id, &client_id, LiveControllerCapability::PermissionRouting)?;
1217 let request_id = request
1218 .get("id")
1219 .or_else(|| request.get("request_id"))
1220 .and_then(serde_json::Value::as_str)
1221 .filter(|id| !id.trim().is_empty())
1222 .unwrap_or("permission_request");
1223 let event_metadata = serde_json::json!({
1224 "action": "permission_routed",
1225 "client": live_client_json(&client),
1226 "request_id": request_id,
1227 "request": request,
1228 "metadata": metadata,
1229 });
1230 let event = crate::llm::helpers::transcript_event(
1231 LIVE_CLIENT_PERMISSION_EVENT_KIND,
1232 "system",
1233 "internal",
1234 "Live session permission request routed",
1235 Some(event_metadata.clone()),
1236 );
1237 append_event(id, event)?;
1238 Ok(event_metadata)
1239}
1240
1241enum LiveControllerCapability {
1242 PromptInjection,
1243 PermissionRouting,
1244}
1245
1246fn ensure_live_controller(
1247 id: &str,
1248 client_id: &str,
1249 capability: LiveControllerCapability,
1250) -> Result<LiveSessionClient, String> {
1251 SESSIONS.with(|s| {
1252 let map = s.borrow();
1253 let Some(state) = map.get(id) else {
1254 return Err(format!("agent session '{id}' does not exist"));
1255 };
1256 if state.live_controller_id.as_deref() != Some(client_id) {
1257 return Err(format!(
1258 "live client '{client_id}' is not the active controller"
1259 ));
1260 }
1261 let Some(client) = state.live_clients.get(client_id) else {
1262 return Err(format!("live client '{client_id}' is not attached"));
1263 };
1264 match capability {
1265 LiveControllerCapability::PromptInjection if !client.prompt_injection => Err(format!(
1266 "live client '{client_id}' cannot inject prompts for this session"
1267 )),
1268 LiveControllerCapability::PermissionRouting if !client.permission_routing => Err(
1269 format!("live client '{client_id}' cannot route permissions for this session"),
1270 ),
1271 _ => Ok(client.clone()),
1272 }
1273 })
1274}
1275
1276fn append_live_client_event(
1277 state: &mut SessionState,
1278 action: &str,
1279 client: Option<&LiveSessionClient>,
1280 previous_controller_id: Option<&str>,
1281 active_controller_id: Option<&str>,
1282 extra: serde_json::Value,
1283) -> Result<(), String> {
1284 let metadata = serde_json::json!({
1285 "action": action,
1286 "session_id": state.id,
1287 "client": client.map(live_client_json),
1288 "previous_controller_id": previous_controller_id,
1289 "active_controller_id": active_controller_id,
1290 "clients": state
1291 .live_clients
1292 .values()
1293 .map(live_client_json)
1294 .collect::<Vec<_>>(),
1295 "extra": extra,
1296 });
1297 let event = crate::llm::helpers::transcript_event(
1298 LIVE_CLIENT_EVENT_KIND,
1299 "system",
1300 "internal",
1301 "Live session client lifecycle changed",
1302 Some(metadata),
1303 );
1304 append_event_to_state(state, event, "live_client")
1305}
1306
1307fn live_client_change(
1308 client: Option<LiveSessionClient>,
1309 previous_controller_id: Option<String>,
1310 state: &SessionState,
1311) -> LiveClientChange {
1312 LiveClientChange {
1313 client,
1314 previous_controller_id,
1315 active_controller_id: state.live_controller_id.clone(),
1316 clients: state.live_clients.values().cloned().collect(),
1317 }
1318}
1319
1320fn validate_live_client_id(id: impl Into<String>) -> Result<String, String> {
1321 let id = id.into();
1322 let trimmed = id.trim();
1323 if trimmed.is_empty() {
1324 return Err("live client id cannot be empty".to_string());
1325 }
1326 Ok(trimmed.to_string())
1327}
1328
1329pub fn live_client_json(client: &LiveSessionClient) -> serde_json::Value {
1330 serde_json::json!({
1331 "client_id": client.client_id,
1332 "mode": client.mode.as_str(),
1333 "attached_at": client.attached_at,
1334 "last_seen_at": client.last_seen_at,
1335 "prompt_injection": client.prompt_injection,
1336 "permission_routing": client.permission_routing,
1337 "metadata": client.metadata,
1338 })
1339}
1340
1341pub fn live_client_change_json(change: &LiveClientChange) -> serde_json::Value {
1342 serde_json::json!({
1343 "client": change.client.as_ref().map(live_client_json),
1344 "previous_controller_id": change.previous_controller_id,
1345 "active_controller_id": change.active_controller_id,
1346 "clients": change
1347 .clients
1348 .iter()
1349 .map(live_client_json)
1350 .collect::<Vec<_>>(),
1351 })
1352}
1353
1354fn try_register_event_log(session_id: &str) {
1358 if let Some(log) = crate::event_log::active_event_log() {
1359 crate::agent_events::register_sink(
1360 session_id,
1361 crate::agent_events::EventLogSink::new(log, session_id),
1362 );
1363 return;
1364 }
1365 let Ok(dir) = std::env::var("HARN_EVENT_LOG_DIR") else {
1366 return;
1367 };
1368 if dir.is_empty() {
1369 return;
1370 }
1371 let path = std::path::PathBuf::from(dir).join(format!("event_log-{session_id}.jsonl"));
1372 if let Ok(sink) = crate::agent_events::JsonlEventSink::open(&path) {
1373 crate::agent_events::register_sink(session_id, sink);
1374 }
1375}
1376
1377pub fn register_event_log_sink(session_id: &str) {
1378 try_register_event_log(session_id);
1379}
1380
1381pub fn close(id: &str) {
1382 SESSIONS.with(|s| {
1383 s.borrow_mut().remove(id);
1384 });
1385 crate::orchestration::agent_inbox::clear_session(id);
1389 crate::agent_events::clear_session_sinks(id);
1390}
1391
1392pub fn close_with_status(
1393 id: &str,
1394 reason: impl Into<String>,
1395 status: impl Into<String>,
1396 metadata: serde_json::Value,
1397) -> bool {
1398 if !exists(id) {
1399 return false;
1400 }
1401 let reason = reason.into();
1402 let status = status.into();
1403 let event_metadata = serde_json::json!({
1404 "reason": reason,
1405 "status": status,
1406 "metadata": metadata,
1407 });
1408 let transcript_event = crate::llm::helpers::transcript_event(
1409 "agent_session_closed",
1410 "system",
1411 "internal",
1412 "Agent session closed",
1413 Some(event_metadata),
1414 );
1415 let _ = append_event(id, transcript_event);
1416 crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::SessionClosed {
1417 session_id: id.to_string(),
1418 reason,
1419 status,
1420 metadata,
1421 });
1422 close(id);
1423 true
1424}
1425
1426pub fn reset_transcript(id: &str) -> bool {
1427 SESSIONS.with(|s| {
1428 let mut map = s.borrow_mut();
1429 let Some(state) = map.get_mut(id) else {
1430 return false;
1431 };
1432 state.transcript = empty_transcript(id);
1433 state.tool_format = None;
1434 state.system_prompt = None;
1435 state.scratchpad = None;
1436 state.scratchpad_version = 0;
1437 state.last_transcript_budget_action = None;
1438 state.completed_turn_checkpoints.clear();
1439 state.redo_stack.clear();
1440 state.text_tool_call_seq = 0;
1441 state.touch();
1442 true
1443 })
1444}
1445
1446pub fn fork(src_id: &str, dst_id: Option<String>) -> Option<String> {
1453 let (
1454 src_transcript,
1455 src_tool_format,
1456 src_system_prompt,
1457 src_pinned_model,
1458 src_pinned_reasoning_policy,
1459 src_actor_chain,
1460 src_workspace_anchor,
1461 src_workspace_policy,
1462 src_scratchpad,
1463 src_scratchpad_version,
1464 src_transcript_budget_policy,
1465 src_last_transcript_budget_action,
1466 src_text_tool_call_seq,
1467 src_taint,
1468 dst,
1469 ) = SESSIONS.with(|s| {
1470 let mut map = s.borrow_mut();
1471 let src = map.get_mut(src_id)?;
1472 src.touch();
1473 let dst = dst_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1474 let forked_transcript = clone_transcript_with_id(&src.transcript, &dst);
1475 Some((
1476 forked_transcript,
1477 src.tool_format.clone(),
1478 src.system_prompt.clone(),
1479 src.pinned_model.clone(),
1480 src.pinned_reasoning_policy.clone(),
1481 src.actor_chain.clone(),
1482 src.workspace_anchor.clone(),
1483 src.workspace_policy.clone(),
1484 src.scratchpad.clone(),
1485 src.scratchpad_version,
1486 src.transcript_budget_policy.clone(),
1487 src.last_transcript_budget_action.clone(),
1488 src.text_tool_call_seq,
1489 src.taint.clone(),
1490 dst,
1491 ))
1492 })?;
1493 open_or_create(Some(dst.clone()));
1495 SESSIONS.with(|s| {
1496 let mut map = s.borrow_mut();
1497 if let Some(state) = map.get_mut(&dst) {
1498 state.transcript = src_transcript;
1499 state.tool_format = src_tool_format;
1500 state.system_prompt = src_system_prompt;
1501 state.pinned_model = src_pinned_model;
1502 state.pinned_reasoning_policy = src_pinned_reasoning_policy;
1503 state.actor_chain = src_actor_chain;
1504 state.workspace_anchor = src_workspace_anchor;
1505 state.workspace_policy = src_workspace_policy;
1506 state.scratchpad = src_scratchpad;
1507 state.scratchpad_version = src_scratchpad_version;
1508 state.transcript_budget_policy = src_transcript_budget_policy;
1509 state.last_transcript_budget_action = src_last_transcript_budget_action;
1510 state.text_tool_call_seq = src_text_tool_call_seq;
1511 state.taint = src_taint;
1512 state.touch();
1513 }
1514 update_lineage(&mut map, src_id, &dst, None);
1515 });
1516 let budget_ok = SESSIONS.with(|s| {
1517 let mut map = s.borrow_mut();
1518 let Some(state) = map.get_mut(&dst) else {
1519 return false;
1520 };
1521 let candidate = state.transcript.clone();
1522 apply_transcript_with_budget(state, candidate, "fork").is_ok()
1523 });
1524 if !budget_ok {
1525 SESSIONS.with(|s| {
1531 let mut map = s.borrow_mut();
1532 if let Some(parent) = map.get_mut(src_id) {
1533 parent.child_ids.retain(|id| id != &dst);
1534 }
1535 });
1536 close(&dst);
1537 return None;
1538 }
1539 if exists(&dst) {
1543 Some(dst)
1544 } else {
1545 None
1546 }
1547}
1548
1549pub fn fork_at(src_id: &str, keep_first: usize, dst_id: Option<String>) -> Option<String> {
1560 let branched_at_event_index = SESSIONS.with(|s| {
1561 let map = s.borrow();
1562 let src = map.get(src_id)?;
1563 Some(branch_event_index(&src.transcript, keep_first))
1564 })?;
1565 let new_id = fork(src_id, dst_id)?;
1566 link_child_session_with_branch(src_id, &new_id, Some(branched_at_event_index));
1567 let _ = truncate(&new_id, keep_first);
1568 Some(new_id)
1569}
1570
1571pub fn truncate(id: &str, keep_first: usize) -> Option<SessionTruncateResult> {
1575 SESSIONS.with(|s| {
1576 let mut map = s.borrow_mut();
1577 let state = map.get_mut(id)?;
1578 let result = truncate_state(state, keep_first)?;
1579 Some(result)
1580 })
1581}
1582
1583fn truncate_state(state: &mut SessionState, keep_first: usize) -> Option<SessionTruncateResult> {
1584 let dict = state
1585 .transcript
1586 .as_dict()
1587 .cloned()
1588 .unwrap_or_else(crate::value::DictMap::new);
1589 let messages: Vec<VmValue> = match dict.get("messages") {
1590 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1591 _ => Vec::new(),
1592 };
1593 let existing_events = match dict.get("events") {
1594 Some(VmValue::List(list)) => Some(list.iter().cloned().collect::<Vec<_>>()),
1595 _ => None,
1596 };
1597 let kept_turn_count = keep_first.min(messages.len());
1598 let removed_turn_count = messages.len().saturating_sub(kept_turn_count);
1599 let mut new_tip_turn_id = existing_events
1600 .as_ref()
1601 .map(|events| turn_event_id_for_count(events, kept_turn_count))
1602 .unwrap_or_else(|| {
1603 let events = crate::llm::helpers::transcript_events_from_messages(&messages);
1604 turn_event_id_for_count(&events, kept_turn_count)
1605 });
1606
1607 if removed_turn_count > 0 {
1608 let retained: Vec<VmValue> = messages.into_iter().take(kept_turn_count).collect();
1609 let retained_events = match existing_events {
1610 Some(events) => {
1611 let keep_event_count = event_prefix_len_for_messages(&events, kept_turn_count);
1612 events.into_iter().take(keep_event_count).collect()
1613 }
1614 None => crate::llm::helpers::transcript_events_from_messages(&retained),
1615 };
1616 new_tip_turn_id = turn_event_id_for_count(&retained_events, kept_turn_count);
1617 let mut next = dict;
1618 next.insert(
1619 crate::value::intern_key("events"),
1620 VmValue::List(std::sync::Arc::new(retained_events)),
1621 );
1622 next.insert(
1623 crate::value::intern_key("messages"),
1624 VmValue::List(std::sync::Arc::new(retained)),
1625 );
1626 next.remove("summary");
1627 apply_transcript_with_budget(state, VmValue::dict(next), "truncate").ok()?;
1628 }
1629 state.touch();
1630 Some(SessionTruncateResult {
1631 kept_turn_count,
1632 removed_turn_count,
1633 new_tip_turn_id,
1634 })
1635}
1636
1637pub fn pop_last_if_assistant(id: &str) -> Result<bool, String> {
1644 SESSIONS.with(|s| {
1645 let mut map = s.borrow_mut();
1646 let Some(state) = map.get_mut(id) else {
1647 return Err(format!(
1648 "pop_last_if_assistant: unknown session id '{id}'"
1649 ));
1650 };
1651 let messages: Vec<VmValue> = match state.transcript.as_dict() {
1652 Some(dict) => match dict.get("messages") {
1653 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1654 _ => Vec::new(),
1655 },
1656 None => Vec::new(),
1657 };
1658 if messages.is_empty() {
1659 return Ok(false);
1660 }
1661 let trailing_role = messages
1662 .last()
1663 .and_then(|m| m.as_dict())
1664 .and_then(|d| d.get("role"))
1665 .map(|v| v.display())
1666 .unwrap_or_default();
1667 if trailing_role != "assistant" {
1668 return Err(format!(
1669 "pop_last_if_assistant: trailing message role is '{trailing_role}', expected 'assistant'"
1670 ));
1671 }
1672 let keep = messages.len() - 1;
1673 truncate_state(state, keep);
1674 Ok(true)
1675 })
1676}
1677
1678pub fn trim(id: &str, keep_last: usize) -> Option<usize> {
1679 SESSIONS.with(|s| {
1680 let mut map = s.borrow_mut();
1681 let state = map.get_mut(id)?;
1682 let dict = state.transcript.as_dict()?.clone();
1683 let messages: Vec<VmValue> = match dict.get("messages") {
1684 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1685 _ => Vec::new(),
1686 };
1687 let start = messages.len().saturating_sub(keep_last);
1688 let retained: Vec<VmValue> = messages.into_iter().skip(start).collect();
1689 let kept = retained.len();
1690 let mut next = dict;
1691 next.insert(
1692 crate::value::intern_key("events"),
1693 VmValue::List(std::sync::Arc::new(
1694 crate::llm::helpers::transcript_events_from_messages(&retained),
1695 )),
1696 );
1697 next.insert(
1698 crate::value::intern_key("messages"),
1699 VmValue::List(std::sync::Arc::new(retained)),
1700 );
1701 apply_transcript_with_budget(state, VmValue::dict(next), "trim").ok()?;
1702 Some(kept)
1703 })
1704}
1705
1706pub fn inject_message(id: &str, message: VmValue) -> Result<(), String> {
1709 let Some(msg_dict) = message.as_dict().cloned() else {
1710 return Err("agent_session_inject: message must be a dict".into());
1711 };
1712 let role_ok = matches!(msg_dict.get("role"), Some(VmValue::String(_)));
1713 if !role_ok {
1714 return Err(
1715 "agent_session_inject: message must have a string `role` (user|assistant|tool_result|system)"
1716 .into(),
1717 );
1718 }
1719 SESSIONS.with(|s| {
1720 let mut map = s.borrow_mut();
1721 let Some(state) = map.get_mut(id) else {
1722 return Err(format!("agent_session_inject: unknown session id '{id}'"));
1723 };
1724 let dict = state
1725 .transcript
1726 .as_dict()
1727 .cloned()
1728 .unwrap_or_else(crate::value::DictMap::new);
1729 let mut messages: Vec<VmValue> = match dict.get("messages") {
1730 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1731 _ => Vec::new(),
1732 };
1733 let mut events: Vec<VmValue> = match dict.get("events") {
1734 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1735 _ => crate::llm::helpers::transcript_events_from_messages(&messages),
1736 };
1737 let new_message = VmValue::dict(msg_dict);
1738 let message_index = messages.len();
1739 events.push(crate::llm::helpers::transcript_event_from_message(
1740 &new_message,
1741 ));
1742 messages.push(new_message);
1743 let mut next = dict;
1744 next.insert(
1745 crate::value::intern_key("events"),
1746 VmValue::List(std::sync::Arc::new(events)),
1747 );
1748 next.insert(
1749 crate::value::intern_key("messages"),
1750 VmValue::List(std::sync::Arc::new(messages)),
1751 );
1752 let persisted_message = next
1753 .get("messages")
1754 .and_then(|value| match value {
1755 VmValue::List(list) => list.get(message_index).cloned(),
1756 _ => None,
1757 })
1758 .unwrap_or(VmValue::Nil);
1759 apply_transcript_with_budget(state, VmValue::dict(next), "inject_message")?;
1760 emit_identified_user_message_event(id, &persisted_message);
1761 emit_llm_message_event(id, message_index, &persisted_message);
1762 Ok(())
1763 })
1764}
1765
1766#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
1767#[serde(rename_all = "snake_case")]
1768pub enum HostInjectionKind {
1769 HostToolResult,
1770 HostAttachment,
1771}
1772
1773impl HostInjectionKind {
1774 fn as_str(self) -> &'static str {
1775 match self {
1776 Self::HostToolResult => "host_tool_result",
1777 Self::HostAttachment => "host_attachment",
1778 }
1779 }
1780}
1781
1782#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
1783#[serde(deny_unknown_fields)]
1784pub struct HostInjectionRequest {
1785 pub kind: HostInjectionKind,
1786 #[serde(default)]
1787 pub delivery: InjectionDelivery,
1788 pub payload: serde_json::Value,
1789 pub provenance: HostInjectionProvenance,
1790}
1791
1792#[derive(serde::Deserialize)]
1793#[serde(deny_unknown_fields)]
1794struct HostToolResultPayload {
1795 #[serde(default)]
1796 tool_call_id: Option<String>,
1797 tool_name: String,
1798 #[serde(default)]
1799 kind: Option<ToolKind>,
1800 #[serde(default)]
1801 raw_input: serde_json::Value,
1802 #[serde(default = "default_completed_tool_status")]
1803 status: ToolCallStatus,
1804 #[serde(default)]
1805 raw_output: Option<serde_json::Value>,
1806 #[serde(default)]
1807 result_pointer: Option<String>,
1808 #[serde(default)]
1809 error: Option<String>,
1810 #[serde(default)]
1811 duration_ms: Option<u64>,
1812}
1813
1814#[derive(serde::Deserialize)]
1815#[serde(deny_unknown_fields)]
1816struct HostAttachmentPayload {
1817 media_type: String,
1818 flavor: AttachmentFlavor,
1819 artifact_pointer: String,
1820 sha256: String,
1821 size_bytes: u64,
1822 #[serde(default)]
1823 description: Option<String>,
1824 #[serde(default)]
1825 description_model: Option<String>,
1826}
1827
1828fn default_completed_tool_status() -> ToolCallStatus {
1829 ToolCallStatus::Completed
1830}
1831
1832pub fn inject_host_event(id: &str, injection: VmValue) -> Result<serde_json::Value, String> {
1833 let injection_json = crate::llm::helpers::vm_value_to_json(&injection);
1834 let request: HostInjectionRequest = serde_json::from_value(injection_json)
1835 .map_err(|error| format!("agent_inject_host_event: invalid injection: {error}"))?;
1836 inject_host_event_request(id, request)
1837}
1838
1839pub fn inject_host_event_request(
1844 id: &str,
1845 request: HostInjectionRequest,
1846) -> Result<serde_json::Value, String> {
1847 if !exists(id) {
1848 return Err(format!(
1849 "agent_inject_host_event: unknown session id '{id}'"
1850 ));
1851 }
1852 validate_host_injection_payload(request.kind, request.payload.clone())?;
1853 let injection_id = uuid::Uuid::now_v7().to_string();
1854 if request.delivery == InjectionDelivery::Immediate {
1855 let sequence = crate::orchestration::agent_inbox::reserve_sequence(id);
1856 deliver_host_injection_request(id, injection_id.clone(), sequence, request, "immediate")?;
1857 return Ok(serde_json::json!({
1858 "injection_id": injection_id,
1859 "sequence": sequence,
1860 "delivery": InjectionDelivery::Immediate.as_str(),
1861 "status": "injected",
1862 }));
1863 }
1864 let queued = serde_json::json!({
1865 "injection_id": injection_id,
1866 "request": request,
1867 });
1868 let sequence = crate::orchestration::agent_inbox::push_host_injection(
1869 id,
1870 request.kind.as_str(),
1871 queued,
1872 request.delivery,
1873 "agent_inject_host_event",
1874 );
1875 Ok(serde_json::json!({
1876 "injection_id": injection_id,
1877 "sequence": sequence,
1878 "delivery": request.delivery.as_str(),
1879 "status": "queued",
1880 }))
1881}
1882
1883pub fn drain_queued_host_injections(
1884 id: &str,
1885 delivery: InjectionDelivery,
1886 seam: &str,
1887) -> Result<Vec<serde_json::Value>, String> {
1888 if !exists(id) {
1889 return Err(format!(
1890 "agent_inject_host_event: unknown session id '{id}'"
1891 ));
1892 }
1893 let entries = crate::orchestration::agent_inbox::drain_where(id, |entry| {
1894 entry.payload.is_some() && entry.delivery == Some(delivery)
1895 });
1896 let mut delivered = Vec::with_capacity(entries.len());
1897 for entry in entries {
1898 let payload = entry.payload.ok_or_else(|| {
1899 "agent_inject_host_event: queued typed inbox entry missing payload".to_string()
1900 })?;
1901 let injection_id = payload
1902 .get("injection_id")
1903 .and_then(serde_json::Value::as_str)
1904 .ok_or_else(|| {
1905 "agent_inject_host_event: queued injection missing injection_id".to_string()
1906 })?
1907 .to_string();
1908 let request_value = payload.get("request").cloned().ok_or_else(|| {
1909 "agent_inject_host_event: queued injection missing request".to_string()
1910 })?;
1911 let request: HostInjectionRequest =
1912 serde_json::from_value(request_value).map_err(|error| {
1913 format!("agent_inject_host_event: invalid queued injection: {error}")
1914 })?;
1915 deliver_host_injection_request(id, injection_id.clone(), entry.sequence, request, seam)?;
1916 delivered.push(serde_json::json!({
1917 "injection_id": injection_id,
1918 "sequence": entry.sequence,
1919 "delivery": delivery.as_str(),
1920 "delivered_at_seam": seam,
1921 "kind": entry.kind,
1922 "source": entry.source,
1923 "ts_ms": entry.ts_ms,
1924 }));
1925 }
1926 Ok(delivered)
1927}
1928
1929fn validate_host_injection_payload(
1930 kind: HostInjectionKind,
1931 payload: serde_json::Value,
1932) -> Result<(), String> {
1933 match kind {
1934 HostInjectionKind::HostToolResult => {
1935 let _: HostToolResultPayload = serde_json::from_value(payload).map_err(|error| {
1936 format!("agent_inject_host_event: invalid host_tool_result payload: {error}")
1937 })?;
1938 }
1939 HostInjectionKind::HostAttachment => {
1940 let attachment: HostAttachmentPayload =
1941 serde_json::from_value(payload).map_err(|error| {
1942 format!("agent_inject_host_event: invalid host_attachment payload: {error}")
1943 })?;
1944 if attachment.media_type.trim().is_empty() {
1945 return Err(
1946 "agent_inject_host_event: host_attachment media_type must not be empty".into(),
1947 );
1948 }
1949 if attachment.artifact_pointer.trim().is_empty() {
1950 return Err(
1951 "agent_inject_host_event: host_attachment artifact_pointer must not be empty"
1952 .into(),
1953 );
1954 }
1955 if attachment.sha256.len() != 64
1956 || !attachment
1957 .sha256
1958 .bytes()
1959 .all(|byte| byte.is_ascii_hexdigit())
1960 {
1961 return Err("agent_inject_host_event: host_attachment sha256 must be a 64-character hex digest".into());
1962 }
1963 if attachment.description_model.is_some() && attachment.description.is_none() {
1964 return Err("agent_inject_host_event: host_attachment description_model requires a recorded description".into());
1965 }
1966 }
1967 }
1968 Ok(())
1969}
1970
1971fn deliver_host_injection_request(
1972 id: &str,
1973 injection_id: String,
1974 sequence: u64,
1975 request: HostInjectionRequest,
1976 delivered_at_seam: &str,
1977) -> Result<(), String> {
1978 let (message, transcript_event, agent_event) = match request.kind {
1979 HostInjectionKind::HostToolResult => {
1980 let payload: HostToolResultPayload =
1981 serde_json::from_value(request.payload).map_err(|error| {
1982 format!("agent_inject_host_event: invalid host_tool_result payload: {error}")
1983 })?;
1984 build_host_tool_result_injection(
1985 id,
1986 injection_id,
1987 sequence,
1988 request.delivery,
1989 request.provenance,
1990 payload,
1991 delivered_at_seam,
1992 )
1993 }
1994 HostInjectionKind::HostAttachment => {
1995 let payload: HostAttachmentPayload =
1996 serde_json::from_value(request.payload).map_err(|error| {
1997 format!("agent_inject_host_event: invalid host_attachment payload: {error}")
1998 })?;
1999 build_host_attachment_injection(
2000 id,
2001 injection_id,
2002 sequence,
2003 request.delivery,
2004 request.provenance,
2005 payload,
2006 delivered_at_seam,
2007 )
2008 }
2009 };
2010 inject_typed_message(id, message, transcript_event, agent_event)
2011}
2012
2013fn build_host_tool_result_injection(
2014 session_id: &str,
2015 injection_id: String,
2016 sequence: u64,
2017 delivery: InjectionDelivery,
2018 provenance: HostInjectionProvenance,
2019 payload: HostToolResultPayload,
2020 delivered_at_seam: &str,
2021) -> (VmValue, VmValue, AgentEvent) {
2022 let tool_call_id = payload
2023 .tool_call_id
2024 .as_deref()
2025 .filter(|value| !value.trim().is_empty())
2026 .map(str::to_owned)
2027 .unwrap_or_else(|| format!("hosttc_{}", injection_id.replace('-', "")));
2028 let body = host_tool_result_body(&payload);
2029 let trust = trust_for_tool(payload.kind, &provenance);
2030 let origin = format!("host_injected:{}", payload.tool_name);
2031 let ingress = crate::security::sanitize_ingress(&body, &origin, trust);
2032 let text = host_injection_envelope(
2033 "host_tool_result",
2034 &payload.tool_name,
2035 &injection_id,
2036 &ingress.delivered,
2037 );
2038 let sanitization = sanitization_verdict(
2039 trust,
2040 &serde_json::json!({
2041 "raw_output": payload.raw_output,
2042 "result_pointer": payload.result_pointer,
2043 "error": payload.error,
2044 }),
2045 &text,
2046 SanitizationAction::Passed,
2047 &ingress,
2048 None,
2049 );
2050 record_host_ingress(session_id, &origin, &tool_call_id, trust, &body, &ingress);
2051 let metadata = serde_json::json!({
2052 "injection_id": injection_id,
2053 "sequence": sequence,
2054 "provenance": provenance,
2055 "sanitization": sanitization,
2056 "tool_call_id": tool_call_id,
2057 "tool_name": payload.tool_name,
2058 "result_pointer": payload.result_pointer,
2059 });
2060 let message = host_injection_user_message(&injection_id, &text, &metadata, None);
2061 let transcript_event = crate::llm::helpers::transcript_event(
2062 "host_tool_result",
2063 "user",
2064 "public",
2065 &text,
2066 Some(metadata),
2067 );
2068 let event = AgentEvent::HostToolResult {
2069 session_id: session_id.to_string(),
2070 injection_id,
2071 tool_call_id,
2072 tool_name: payload.tool_name,
2073 kind: payload.kind,
2074 raw_input: payload.raw_input,
2075 status: payload.status,
2076 raw_output: payload.raw_output,
2077 result_pointer: payload.result_pointer,
2078 error: payload.error,
2079 duration_ms: payload.duration_ms,
2080 delivery,
2081 delivered_at_seam: Some(delivered_at_seam.to_string()),
2082 sequence,
2083 provenance,
2084 sanitization,
2085 };
2086 (message, transcript_event, event)
2087}
2088
2089fn build_host_attachment_injection(
2090 session_id: &str,
2091 injection_id: String,
2092 sequence: u64,
2093 delivery: InjectionDelivery,
2094 provenance: HostInjectionProvenance,
2095 payload: HostAttachmentPayload,
2096 delivered_at_seam: &str,
2097) -> (VmValue, VmValue, AgentEvent) {
2098 let materialized =
2099 crate::host_attachments::materialize(&payload.artifact_pointer, &payload.media_type);
2100 let (rendered, body, image) = select_attachment_rendering(session_id, &payload, materialized);
2101 let trust = trust_for_attachment(payload.flavor, &provenance);
2102 let origin = format!("host_attachment:{}", payload.artifact_pointer);
2103 let ingress = crate::security::sanitize_ingress(&body, &origin, trust);
2104 let text = host_injection_envelope(
2105 "host_attachment",
2106 &payload.media_type,
2107 &injection_id,
2108 &ingress.delivered,
2109 );
2110 let action = match rendered {
2111 AttachmentRendering::DescriptionPlusPointer => SanitizationAction::Summarized,
2112 AttachmentRendering::PointerOnly => SanitizationAction::Pointerized,
2113 AttachmentRendering::ImageBlock | AttachmentRendering::InlineText => {
2114 SanitizationAction::Passed
2115 }
2116 };
2117 let sanitization = sanitization_verdict(
2118 trust,
2119 &serde_json::json!({
2120 "artifact_pointer": payload.artifact_pointer,
2121 "sha256": payload.sha256,
2122 "size_bytes": payload.size_bytes,
2123 "description": payload.description,
2124 }),
2125 &text,
2126 action,
2127 &ingress,
2128 payload.description_model.as_deref(),
2129 );
2130 record_host_ingress(session_id, &origin, &injection_id, trust, &body, &ingress);
2131 let metadata = serde_json::json!({
2132 "injection_id": injection_id,
2133 "sequence": sequence,
2134 "provenance": provenance,
2135 "sanitization": sanitization,
2136 "artifact_pointer": payload.artifact_pointer,
2137 "sha256": payload.sha256,
2138 "media_type": payload.media_type,
2139 "flavor": payload.flavor,
2140 "rendered": rendered,
2141 "description": payload.description,
2142 "description_model": payload.description_model,
2143 });
2144 let message = host_injection_user_message(&injection_id, &text, &metadata, image);
2145 let transcript_event = crate::llm::helpers::transcript_event(
2146 "host_attachment",
2147 "user",
2148 "public",
2149 &text,
2150 Some(metadata),
2151 );
2152 let event = AgentEvent::HostAttachment {
2153 session_id: session_id.to_string(),
2154 injection_id,
2155 media_type: payload.media_type,
2156 flavor: payload.flavor,
2157 artifact_pointer: payload.artifact_pointer,
2158 sha256: payload.sha256,
2159 size_bytes: payload.size_bytes,
2160 rendered,
2161 description: payload.description,
2162 description_model: payload.description_model,
2163 delivery,
2164 delivered_at_seam: Some(delivered_at_seam.to_string()),
2165 sequence,
2166 provenance,
2167 sanitization,
2168 };
2169 (message, transcript_event, event)
2170}
2171
2172fn inject_typed_message(
2173 id: &str,
2174 message: VmValue,
2175 transcript_event: VmValue,
2176 agent_event: AgentEvent,
2177) -> Result<(), String> {
2178 let Some(msg_dict) = message.as_dict().cloned() else {
2179 return Err("agent_inject_host_event: materialized message must be a dict".into());
2180 };
2181 SESSIONS.with(|s| {
2182 let mut map = s.borrow_mut();
2183 let Some(state) = map.get_mut(id) else {
2184 return Err(format!(
2185 "agent_inject_host_event: unknown session id '{id}'"
2186 ));
2187 };
2188 let dict = state
2189 .transcript
2190 .as_dict()
2191 .cloned()
2192 .unwrap_or_else(crate::value::DictMap::new);
2193 let mut messages: Vec<VmValue> = match dict.get("messages") {
2194 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2195 _ => Vec::new(),
2196 };
2197 let mut events: Vec<VmValue> = match dict.get("events") {
2198 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2199 _ => crate::llm::helpers::transcript_events_from_messages(&messages),
2200 };
2201 let new_message = VmValue::dict(msg_dict);
2202 let message_index = messages.len();
2203 events.push(transcript_event);
2204 messages.push(new_message);
2205 let mut next = dict;
2206 next.insert(
2207 crate::value::intern_key("events"),
2208 VmValue::List(std::sync::Arc::new(events)),
2209 );
2210 next.insert(
2211 crate::value::intern_key("messages"),
2212 VmValue::List(std::sync::Arc::new(messages)),
2213 );
2214 let persisted_message = next
2215 .get("messages")
2216 .and_then(|value| match value {
2217 VmValue::List(list) => list.get(message_index).cloned(),
2218 _ => None,
2219 })
2220 .unwrap_or(VmValue::Nil);
2221 apply_transcript_with_budget(state, VmValue::dict(next), "inject_host_event")?;
2222 emit_identified_user_message_event(id, &persisted_message);
2223 emit_llm_message_event(id, message_index, &persisted_message);
2224 crate::agent_events::emit_event(&agent_event);
2225 Ok(())
2226 })
2227}
2228
2229fn host_injection_user_message(
2230 injection_id: &str,
2231 text: &str,
2232 metadata: &serde_json::Value,
2233 image: Option<crate::host_attachments::MaterializedAttachment>,
2234) -> VmValue {
2235 let mut message = BTreeMap::new();
2236 message.put_str("role", "user");
2237 message.put_str(
2238 "messageId",
2239 format!("hostinj_{}", injection_id.replace('-', "")),
2240 );
2241 let mut content = vec![serde_json::json!({"type": "text", "text": text})];
2242 match image {
2243 Some(crate::host_attachments::MaterializedAttachment::ImageUrl(url)) => {
2244 content.push(serde_json::json!({"type": "image", "url": url}));
2245 }
2246 Some(crate::host_attachments::MaterializedAttachment::ImageBase64 { media_type, data }) => {
2247 content.push(
2248 serde_json::json!({"type": "image", "base64": data, "media_type": media_type}),
2249 );
2250 }
2251 Some(crate::host_attachments::MaterializedAttachment::Text(_)) | None => {}
2252 }
2253 message.insert(
2254 "content".to_string(),
2255 crate::stdlib::json_to_vm_value(&serde_json::Value::Array(content)),
2256 );
2257 message.insert(
2258 "metadata".to_string(),
2259 crate::stdlib::json_to_vm_value(&serde_json::json!({
2260 "host_injection": metadata,
2261 })),
2262 );
2263 VmValue::dict(message)
2264}
2265
2266fn host_tool_result_body(payload: &HostToolResultPayload) -> String {
2267 if let Some(output) = payload.raw_output.as_ref() {
2268 if let Some(text) = output.as_str() {
2269 return text.to_string();
2270 }
2271 return serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string());
2272 }
2273 if let Some(pointer) = payload.result_pointer.as_deref() {
2274 return format!("Result artifact: {pointer}");
2275 }
2276 if let Some(error) = payload.error.as_deref() {
2277 return format!("Error: {error}");
2278 }
2279 String::new()
2280}
2281
2282fn select_attachment_rendering(
2283 session_id: &str,
2284 payload: &HostAttachmentPayload,
2285 materialized: Result<crate::host_attachments::MaterializedAttachment, String>,
2286) -> (
2287 AttachmentRendering,
2288 String,
2289 Option<crate::host_attachments::MaterializedAttachment>,
2290) {
2291 if let Ok(crate::host_attachments::MaterializedAttachment::Text(text)) = &materialized {
2292 return (AttachmentRendering::InlineText, text.clone(), None);
2293 }
2294 let vision_capable = pinned_model(session_id)
2295 .map(|selector| crate::llm_config::resolve_model_info(&selector))
2296 .map(|resolved| {
2297 crate::llm::capabilities::lookup(&resolved.provider, &resolved.id).vision_supported
2298 })
2299 .unwrap_or(false);
2300 if vision_capable {
2301 if let Ok(
2302 image @ (crate::host_attachments::MaterializedAttachment::ImageUrl(_)
2303 | crate::host_attachments::MaterializedAttachment::ImageBase64 { .. }),
2304 ) = materialized
2305 {
2306 return (
2307 AttachmentRendering::ImageBlock,
2308 format!("Attachment: {}", payload.artifact_pointer),
2309 Some(image),
2310 );
2311 }
2312 }
2313 match payload
2314 .description
2315 .as_deref()
2316 .filter(|text| !text.trim().is_empty())
2317 {
2318 Some(description) => (
2319 AttachmentRendering::DescriptionPlusPointer,
2320 format!("{description}\nArtifact: {}", payload.artifact_pointer),
2321 None,
2322 ),
2323 None => (
2324 AttachmentRendering::PointerOnly,
2325 format!("Attachment: {}", payload.artifact_pointer),
2326 None,
2327 ),
2328 }
2329}
2330
2331fn host_injection_envelope(kind: &str, subject: &str, injection_id: &str, body: &str) -> String {
2332 format!(
2333 "<{kind} subject=\"{}\" injection_id=\"{}\">\n{}\n</{kind}>",
2334 escape_attr(subject),
2335 escape_attr(injection_id),
2336 body
2337 )
2338}
2339
2340fn escape_attr(value: &str) -> String {
2341 value
2342 .replace('&', "&")
2343 .replace('"', """)
2344 .replace('<', "<")
2345 .replace('>', ">")
2346}
2347
2348fn sanitization_verdict(
2349 trust: TrustLevel,
2350 original: &serde_json::Value,
2351 delivered: &str,
2352 action: SanitizationAction,
2353 ingress: &crate::security::SanitizedIngress,
2354 summary_model: Option<&str>,
2355) -> SanitizationVerdict {
2356 SanitizationVerdict {
2357 trust,
2358 detector: ingress.detector.clone(),
2359 action,
2360 original_bytes: serde_json::to_vec(original)
2361 .map(|bytes| bytes.len() as u64)
2362 .unwrap_or(0),
2363 delivered_bytes: delivered.len() as u64,
2364 summary_model: summary_model.map(str::to_owned),
2365 labels: ingress.labels.clone(),
2366 }
2367}
2368
2369fn record_host_ingress(
2370 session_id: &str,
2371 origin: &str,
2372 introduced_by: &str,
2373 trust: TrustLevel,
2374 raw: &str,
2375 ingress: &crate::security::SanitizedIngress,
2376) {
2377 if !trust.is_untrusted() || raw.is_empty() {
2378 return;
2379 }
2380 push_session_taint(
2381 session_id,
2382 crate::security::TaintRecord {
2383 origin: origin.to_string(),
2384 trust,
2385 introduced_by: introduced_by.to_string(),
2386 detector: ingress.detector.clone(),
2387 labels: ingress.labels.clone(),
2388 endpoints: ingress.endpoints.clone(),
2389 },
2390 );
2391}
2392
2393fn trust_for_tool(kind: Option<ToolKind>, provenance: &HostInjectionProvenance) -> TrustLevel {
2394 if provenance.source == "user_attachment" {
2395 return TrustLevel::Untrusted;
2396 }
2397 match kind {
2398 Some(ToolKind::Fetch) | Some(ToolKind::Search) => TrustLevel::Untrusted,
2399 Some(ToolKind::Read) => TrustLevel::SemiTrusted,
2400 Some(ToolKind::Execute)
2401 | Some(ToolKind::Edit)
2402 | Some(ToolKind::Delete)
2403 | Some(ToolKind::Move) => TrustLevel::SemiTrusted,
2404 _ => TrustLevel::Trusted,
2405 }
2406}
2407
2408fn trust_for_attachment(
2409 flavor: AttachmentFlavor,
2410 provenance: &HostInjectionProvenance,
2411) -> TrustLevel {
2412 if provenance.source == "user_attachment" {
2413 return TrustLevel::Untrusted;
2414 }
2415 match flavor {
2416 AttachmentFlavor::TextFrame | AttachmentFlavor::FrameRing => TrustLevel::SemiTrusted,
2417 AttachmentFlavor::Image | AttachmentFlavor::File => TrustLevel::Untrusted,
2418 }
2419}
2420
2421fn emit_identified_user_message_event(session_id: &str, message: &VmValue) {
2422 let message_json = crate::llm::helpers::vm_value_to_json(message);
2423 let role = message_json.get("role").and_then(|value| value.as_str());
2424 if role != Some("user") {
2425 return;
2426 }
2427 let Some(message_id) = message_json
2428 .get("messageId")
2429 .or_else(|| message_json.get("message_id"))
2430 .and_then(|value| value.as_str())
2431 .filter(|value| !value.trim().is_empty())
2432 else {
2433 return;
2434 };
2435 let content = message_json
2436 .get("content")
2437 .map(user_message_content_blocks)
2438 .unwrap_or_default();
2439 crate::agent_events::emit_event(&crate::agent_events::AgentEvent::UserMessage {
2440 session_id: session_id.to_string(),
2441 message_id: message_id.to_string(),
2442 content,
2443 });
2444}
2445
2446fn user_message_content_blocks(content: &serde_json::Value) -> Vec<serde_json::Value> {
2447 match content {
2448 serde_json::Value::Array(items) => items.clone(),
2449 serde_json::Value::String(text) => vec![serde_json::json!({
2450 "type": "text",
2451 "text": text,
2452 })],
2453 other => vec![serde_json::json!({
2454 "type": "text",
2455 "text": other.to_string(),
2456 })],
2457 }
2458}
2459
2460fn emit_llm_message_event(session_id: &str, message_index: usize, message: &VmValue) {
2461 let mut fields = serde_json::Map::new();
2462 fields.insert(
2463 "session_id".to_string(),
2464 serde_json::Value::String(session_id.to_string()),
2465 );
2466 fields.insert(
2467 "message_index".to_string(),
2468 serde_json::json!(message_index),
2469 );
2470 let message_json = crate::llm::helpers::vm_value_to_json(message);
2471 if let Some(role) = message_json.get("role").and_then(|value| value.as_str()) {
2472 fields.insert(
2473 "role".to_string(),
2474 serde_json::Value::String(role.to_string()),
2475 );
2476 }
2477 if let Some(content) = message_json.get("content") {
2478 fields.insert("content".to_string(), content.clone());
2479 }
2480 fields.insert("message".to_string(), message_json);
2481 crate::llm::append_observability_sidecar_entry("message", fields);
2482}
2483
2484pub fn seed_from_messages(
2490 id: Option<String>,
2491 messages: &[serde_json::Value],
2492 metadata: serde_json::Value,
2493 system_prompt: Option<String>,
2494 tool_format: Option<String>,
2495) -> Result<String, String> {
2496 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
2497 if exists(&resolved) {
2498 return Err(format!("agent session '{resolved}' already exists"));
2499 }
2500 open_or_create(Some(resolved.clone()));
2501 SESSIONS.with(|s| {
2502 let mut map = s.borrow_mut();
2503 let Some(state) = map.get_mut(&resolved) else {
2504 return Err(format!("failed to create agent session '{resolved}'"));
2505 };
2506 state.tool_format = tool_format.filter(|value| !value.trim().is_empty());
2507 state.system_prompt = system_prompt.filter(|value| !value.trim().is_empty());
2508
2509 let mut metadata = metadata
2510 .as_object()
2511 .cloned()
2512 .unwrap_or_else(serde_json::Map::new);
2513 if let Some(tool_format) = state.tool_format.as_ref() {
2514 metadata.insert(
2515 "tool_format".to_string(),
2516 serde_json::Value::String(tool_format.clone()),
2517 );
2518 metadata.insert(
2519 "tool_mode_locked".to_string(),
2520 serde_json::Value::Bool(true),
2521 );
2522 }
2523 if let Some(system_prompt) = state.system_prompt.as_ref() {
2524 metadata.insert(
2525 "system_prompt".to_string(),
2526 crate::llm::helpers::system_prompt_metadata(system_prompt),
2527 );
2528 }
2529 let text_tool_call_seq = next_text_tool_call_seq_from_json_messages(messages);
2530 let vm_messages = crate::llm::helpers::json_messages_to_vm(messages);
2531 let candidate = crate::llm::helpers::new_transcript_with(
2532 Some(resolved.clone()),
2533 vm_messages,
2534 None,
2535 Some(crate::stdlib::json_to_vm_value(&serde_json::Value::Object(
2536 metadata,
2537 ))),
2538 );
2539 apply_transcript_with_budget(state, candidate, "seed_from_messages")?;
2540 state.text_tool_call_seq = text_tool_call_seq;
2541 Ok(resolved)
2542 })
2543}
2544
2545pub fn messages_json(id: &str) -> Vec<serde_json::Value> {
2549 SESSIONS.with(|s| {
2550 let map = s.borrow();
2551 let Some(state) = map.get(id) else {
2552 return Vec::new();
2553 };
2554 let Some(dict) = state.transcript.as_dict() else {
2555 return Vec::new();
2556 };
2557 match dict.get("messages") {
2558 Some(VmValue::List(list)) => list
2559 .iter()
2560 .map(crate::llm::helpers::vm_value_to_json)
2561 .collect(),
2562 _ => Vec::new(),
2563 }
2564 })
2565}
2566
2567#[derive(Clone, Debug, Default)]
2568pub struct SessionPromptState {
2569 pub messages: Vec<serde_json::Value>,
2570 pub summary: Option<String>,
2571}
2572
2573fn summary_message_json(summary: &str) -> serde_json::Value {
2574 serde_json::json!({
2575 "role": "user",
2576 "content": summary,
2577 })
2578}
2579
2580fn messages_begin_with_summary(messages: &[serde_json::Value], summary: &str) -> bool {
2581 messages.first().is_some_and(|message| {
2582 message.get("role").and_then(|value| value.as_str()) == Some("user")
2583 && message.get("content").and_then(|value| value.as_str()) == Some(summary)
2584 })
2585}
2586
2587pub fn prompt_state_json(id: &str) -> SessionPromptState {
2595 SESSIONS.with(|s| {
2596 let map = s.borrow();
2597 let Some(state) = map.get(id) else {
2598 return SessionPromptState::default();
2599 };
2600 let Some(dict) = state.transcript.as_dict() else {
2601 return SessionPromptState::default();
2602 };
2603 let mut messages = match dict.get("messages") {
2604 Some(VmValue::List(list)) => list
2605 .iter()
2606 .map(crate::llm::helpers::vm_value_to_json)
2607 .collect::<Vec<_>>(),
2608 _ => Vec::new(),
2609 };
2610 let summary = dict.get("summary").and_then(|value| match value {
2611 VmValue::String(text) if !text.trim().is_empty() => Some(text.to_string()),
2612 _ => None,
2613 });
2614 if let Some(summary_text) = summary.as_deref() {
2615 if !messages_begin_with_summary(&messages, summary_text) {
2616 messages.insert(0, summary_message_json(summary_text));
2617 }
2618 }
2619 SessionPromptState { messages, summary }
2620 })
2621}
2622
2623pub fn store_transcript(id: &str, transcript: VmValue) -> Result<(), String> {
2626 SESSIONS.with(|s| {
2627 let mut map = s.borrow_mut();
2628 let Some(state) = map.get_mut(id) else {
2629 return Err(format!(
2630 "agent_session_store_transcript: unknown session id '{id}'"
2631 ));
2632 };
2633 let transcript = transcript_with_session_metadata(transcript, state);
2634 let text_tool_call_seq = next_text_tool_call_seq_from_transcript(&transcript);
2635 apply_transcript_with_budget(state, transcript, "store_transcript")?;
2636 state.text_tool_call_seq = state.text_tool_call_seq.max(text_tool_call_seq);
2637 Ok(())
2638 })
2639}
2640
2641fn checkpoint_summary(checkpoint: &SessionTurnCheckpoint) -> SessionCheckpointSummary {
2642 SessionCheckpointSummary {
2643 checkpoint_id: checkpoint.checkpoint_id.clone(),
2644 before_message_count: checkpoint.before_message_count,
2645 after_message_count: checkpoint.after_message_count,
2646 fs_snapshot_ids: checkpoint.fs_snapshot_ids.clone(),
2647 }
2648}
2649
2650fn checkpoint_error_status(error: SessionCheckpointError) -> &'static str {
2651 match error {
2652 SessionCheckpointError::UnknownSession => "unknown_session",
2653 SessionCheckpointError::NoCheckpoint => "no_checkpoint",
2654 SessionCheckpointError::NoRedo => "no_redo",
2655 }
2656}
2657
2658pub fn checkpoint_status_name(error: SessionCheckpointError) -> &'static str {
2659 checkpoint_error_status(error)
2660}
2661
2662pub fn invalidate_redo(id: &str) -> bool {
2665 SESSIONS.with(|s| {
2666 let mut map = s.borrow_mut();
2667 let Some(state) = map.get_mut(id) else {
2668 return false;
2669 };
2670 let had_redo = !state.redo_stack.is_empty();
2671 state.redo_stack.clear();
2672 state.touch();
2673 had_redo
2674 })
2675}
2676
2677pub fn record_completed_turn_checkpoint(
2684 id: &str,
2685 before_transcript: VmValue,
2686 fs_snapshot_ids: Vec<String>,
2687) -> Result<Option<SessionCheckpointSummary>, SessionCheckpointError> {
2688 SESSIONS.with(|s| {
2689 let mut map = s.borrow_mut();
2690 let Some(state) = map.get_mut(id) else {
2691 return Err(SessionCheckpointError::UnknownSession);
2692 };
2693 let after_transcript = transcript_with_session_metadata(state.transcript.clone(), state);
2694 let before_message_count = transcript_message_count(&before_transcript);
2695 let after_message_count = transcript_message_count(&after_transcript);
2696 if crate::values_equal(&before_transcript, &after_transcript) && fs_snapshot_ids.is_empty()
2697 {
2698 return Ok(None);
2699 }
2700 let checkpoint = SessionTurnCheckpoint {
2701 checkpoint_id: format!("turn_{}", uuid::Uuid::now_v7().simple()),
2702 completed_at: crate::orchestration::now_rfc3339(),
2703 before_message_count,
2704 after_message_count,
2705 before_transcript,
2706 after_transcript,
2707 fs_snapshot_ids,
2708 };
2709 state.redo_stack.clear();
2710 state.completed_turn_checkpoints.push(checkpoint.clone());
2711 state.touch();
2712 Ok(Some(checkpoint_summary(&checkpoint)))
2713 })
2714}
2715
2716pub fn rollback_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2717 SESSIONS.with(|s| {
2718 let map = s.borrow();
2719 let Some(state) = map.get(id) else {
2720 return Err(SessionCheckpointError::UnknownSession);
2721 };
2722 state
2723 .completed_turn_checkpoints
2724 .last()
2725 .map(checkpoint_summary)
2726 .ok_or(SessionCheckpointError::NoCheckpoint)
2727 })
2728}
2729
2730pub fn redo_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2731 SESSIONS.with(|s| {
2732 let map = s.borrow();
2733 let Some(state) = map.get(id) else {
2734 return Err(SessionCheckpointError::UnknownSession);
2735 };
2736 state
2737 .redo_stack
2738 .last()
2739 .map(|entry| {
2740 let mut summary = checkpoint_summary(&entry.checkpoint);
2741 summary.fs_snapshot_ids = entry.redo_fs_snapshot_ids.clone();
2742 summary
2743 })
2744 .ok_or(SessionCheckpointError::NoRedo)
2745 })
2746}
2747
2748pub fn rollback_last_completed_turn(
2749 id: &str,
2750 redo_fs_snapshot_ids: Vec<String>,
2751) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2752 SESSIONS.with(|s| {
2753 let mut map = s.borrow_mut();
2754 let Some(state) = map.get_mut(id) else {
2755 return Err(SessionCheckpointError::UnknownSession);
2756 };
2757 let Some(checkpoint) = state.completed_turn_checkpoints.pop() else {
2758 return Err(SessionCheckpointError::NoCheckpoint);
2759 };
2760 state.transcript = checkpoint.before_transcript.clone();
2761 state.redo_stack.push(SessionRedoEntry {
2762 checkpoint: checkpoint.clone(),
2763 redo_fs_snapshot_ids: redo_fs_snapshot_ids.clone(),
2764 });
2765 state.touch();
2766 Ok(SessionCheckpointOutcome {
2767 status: "rolled_back",
2768 checkpoint: checkpoint_summary(&checkpoint),
2769 redo_fs_snapshot_ids,
2770 })
2771 })
2772}
2773
2774pub fn redo_last_rollback(id: &str) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2775 SESSIONS.with(|s| {
2776 let mut map = s.borrow_mut();
2777 let Some(state) = map.get_mut(id) else {
2778 return Err(SessionCheckpointError::UnknownSession);
2779 };
2780 let Some(entry) = state.redo_stack.pop() else {
2781 return Err(SessionCheckpointError::NoRedo);
2782 };
2783 let checkpoint = entry.checkpoint;
2784 state.transcript = checkpoint.after_transcript.clone();
2785 state.completed_turn_checkpoints.push(checkpoint.clone());
2786 state.touch();
2787 Ok(SessionCheckpointOutcome {
2788 status: "redone",
2789 checkpoint: checkpoint_summary(&checkpoint),
2790 redo_fs_snapshot_ids: entry.redo_fs_snapshot_ids,
2791 })
2792 })
2793}
2794
2795pub fn prune_invalid_reminder_events(id: &str) -> usize {
2799 SESSIONS.with(|s| {
2800 let mut map = s.borrow_mut();
2801 let Some(state) = map.get_mut(id) else {
2802 return 0;
2803 };
2804 let Some(dict) = state.transcript.as_dict().cloned() else {
2805 return 0;
2806 };
2807 let Some(VmValue::List(events)) = dict.get("events") else {
2808 return 0;
2809 };
2810 let mut pruned = 0_usize;
2811 let mut kept = Vec::with_capacity(events.len());
2812 for event in events.iter().cloned() {
2813 let is_reminder = event
2814 .as_dict()
2815 .and_then(|event| event.get("kind"))
2816 .map(VmValue::display)
2817 .as_deref()
2818 == Some(crate::llm::helpers::SYSTEM_REMINDER_EVENT_KIND);
2819 if !is_reminder {
2820 kept.push(event);
2821 continue;
2822 }
2823 let valid = crate::llm::helpers::reminder_from_event(&event)
2824 .is_some_and(|reminder| !reminder.body.trim().is_empty());
2825 if valid {
2826 kept.push(event);
2827 } else {
2828 pruned += 1;
2829 }
2830 }
2831 if pruned > 0 {
2832 let mut next = dict;
2833 next.insert(
2834 crate::value::intern_key("events"),
2835 VmValue::List(std::sync::Arc::new(kept)),
2836 );
2837 let _ = apply_transcript_with_budget(
2838 state,
2839 VmValue::dict(next),
2840 "prune_invalid_reminder_events",
2841 );
2842 state.touch();
2843 }
2844 pruned
2845 })
2846}
2847
2848pub fn apply_reminder_post_turn(id: &str, turn: i64) -> Result<serde_json::Value, String> {
2853 let report = SESSIONS.with(|s| {
2854 let mut map = s.borrow_mut();
2855 let Some(state) = map.get_mut(id) else {
2856 return Err(format!(
2857 "agent_session_apply_reminder_post_turn: unknown session id '{id}'"
2858 ));
2859 };
2860 let report = crate::llm::helpers::apply_reminder_post_turn(&state.transcript, turn);
2861 if report.decremented_count > 0 || !report.expired.is_empty() {
2862 if let Some(next) = report.transcript.clone() {
2863 apply_transcript_with_budget(state, next, "apply_reminder_post_turn")?;
2864 }
2865 state.touch();
2866 }
2867 Ok(report)
2868 })?;
2869
2870 for reminder in &report.expired {
2871 let mut payload = crate::llm::helpers::reminder_lifecycle_payload(Some(id), reminder);
2872 if let Some(obj) = payload.as_object_mut() {
2873 obj.insert(
2874 "transcript_id".to_string(),
2875 serde_json::Value::String(id.to_string()),
2876 );
2877 obj.insert(
2878 "reason".to_string(),
2879 serde_json::Value::String("ttl".to_string()),
2880 );
2881 obj.insert(
2882 "ttl_turns_before".to_string(),
2883 serde_json::json!(&reminder.ttl_turns),
2884 );
2885 obj.insert("expired_at_turn".to_string(), serde_json::json!(turn));
2886 }
2887 crate::llm::helpers::emit_reminder_lifecycle_event(
2888 crate::llm::helpers::REMINDER_EXPIRED_EVENT_KIND,
2889 payload,
2890 );
2891 }
2892
2893 Ok(serde_json::json!({
2894 "expired_count": report.expired.len(),
2895 "decremented_count": report.decremented_count,
2896 "remaining_count": report.remaining_count,
2897 }))
2898}
2899
2900pub fn inject_reminder(
2905 id: &str,
2906 reminder: crate::llm::helpers::SystemReminder,
2907) -> Result<ReminderInjectionReport, String> {
2908 let reminder_id = reminder.id.clone();
2909 let dedupe_key = reminder.dedupe_key.clone();
2910 let mut deduped_reminder_ids = Vec::new();
2911 SESSIONS.with(|s| {
2912 let mut map = s.borrow_mut();
2913 let Some(state) = map.get_mut(id) else {
2914 return Err(format!(
2915 "agent_session_inject_reminder: unknown session id '{id}'"
2916 ));
2917 };
2918 let dict = state
2919 .transcript
2920 .as_dict()
2921 .cloned()
2922 .unwrap_or_else(crate::value::DictMap::new);
2923 let mut events: Vec<VmValue> = match dict.get("events") {
2924 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2925 _ => dict
2926 .get("messages")
2927 .and_then(|value| match value {
2928 VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
2929 _ => None,
2930 })
2931 .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
2932 .unwrap_or_default(),
2933 };
2934 if let Some(expected_key) = dedupe_key.as_deref() {
2935 events.retain(|event| {
2936 let Some(existing) = crate::llm::helpers::reminder_from_event(event) else {
2937 return true;
2938 };
2939 if existing.dedupe_key.as_deref() == Some(expected_key) {
2940 deduped_reminder_ids.push(existing.id);
2941 false
2942 } else {
2943 true
2944 }
2945 });
2946 }
2947 events.push(crate::llm::helpers::transcript_reminder_event(&reminder));
2948 let mut next = dict;
2949 next.insert(
2950 crate::value::intern_key("events"),
2951 VmValue::List(std::sync::Arc::new(events)),
2952 );
2953 apply_transcript_with_budget(state, VmValue::dict(next), "inject_reminder")?;
2954 state.touch();
2955 Ok(())
2956 })?;
2957
2958 if !deduped_reminder_ids.is_empty() {
2959 let dropped_count = deduped_reminder_ids.len();
2960 crate::llm::helpers::emit_reminder_lifecycle_event(
2961 crate::llm::helpers::REMINDER_DEDUPED_EVENT_KIND,
2962 serde_json::json!({
2963 "session_id": id,
2964 "transcript_id": id,
2965 "reminder_id": &reminder_id,
2966 "replacing_id": &reminder_id,
2967 "replaced_id": deduped_reminder_ids.first(),
2968 "replaced_ids": &deduped_reminder_ids,
2969 "dedupe_key": &dedupe_key,
2970 "dropped_reminder_ids": &deduped_reminder_ids,
2971 "dropped_count": dropped_count,
2972 }),
2973 );
2974 }
2975
2976 crate::llm::helpers::emit_reminder_lifecycle_event(
2977 crate::llm::helpers::REMINDER_INJECTED_EVENT_KIND,
2978 crate::llm::helpers::reminder_lifecycle_payload(Some(id), &reminder),
2979 );
2980
2981 Ok(ReminderInjectionReport {
2982 reminder_id,
2983 deduped_count: deduped_reminder_ids.len(),
2984 })
2985}
2986
2987pub fn append_event(id: &str, event: VmValue) -> Result<(), String> {
2993 let Some(event_dict) = event.as_dict() else {
2994 return Err("agent_session_append_event: event must be a dict".into());
2995 };
2996 let kind_ok = matches!(event_dict.get("kind"), Some(VmValue::String(_)));
2997 if !kind_ok {
2998 return Err("agent_session_append_event: event must have a string `kind`".into());
2999 }
3000 SESSIONS.with(|s| {
3001 let mut map = s.borrow_mut();
3002 let Some(state) = map.get_mut(id) else {
3003 return Err(format!(
3004 "agent_session_append_event: unknown session id '{id}'"
3005 ));
3006 };
3007 append_event_to_state(state, event, "append_event")?;
3008 Ok(())
3009 })
3010}
3011
3012fn append_event_to_state(
3013 state: &mut SessionState,
3014 event: VmValue,
3015 action: &str,
3016) -> Result<(), String> {
3017 let dict = state
3018 .transcript
3019 .as_dict()
3020 .cloned()
3021 .unwrap_or_else(crate::value::DictMap::new);
3022 let mut events: Vec<VmValue> = match dict.get("events") {
3023 Some(VmValue::List(list)) => list.iter().cloned().collect(),
3024 _ => dict
3025 .get("messages")
3026 .and_then(|value| match value {
3027 VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
3028 _ => None,
3029 })
3030 .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
3031 .unwrap_or_default(),
3032 };
3033 events.push(event);
3034 let mut next = dict;
3035 next.insert(
3036 crate::value::intern_key("events"),
3037 VmValue::List(std::sync::Arc::new(events)),
3038 );
3039 apply_transcript_with_budget(state, VmValue::dict(next), action)
3040}
3041
3042pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
3045 replace_messages_with_summary(id, messages, None)
3046}
3047
3048pub fn replace_messages_with_summary(
3053 id: &str,
3054 messages: &[serde_json::Value],
3055 summary: Option<&str>,
3056) -> Result<(), String> {
3057 SESSIONS.with(|s| {
3058 let mut map = s.borrow_mut();
3059 let Some(state) = map.get_mut(id) else {
3060 return Err(format!(
3061 "agent_session_replace_messages: unknown session id '{id}'"
3062 ));
3063 };
3064 let dict = state
3065 .transcript
3066 .as_dict()
3067 .cloned()
3068 .unwrap_or_else(crate::value::DictMap::new);
3069 let vm_messages: Vec<VmValue> = messages
3070 .iter()
3071 .map(crate::stdlib::json_to_vm_value)
3072 .collect();
3073 let mut next = dict;
3074 next.insert(
3075 crate::value::intern_key("events"),
3076 VmValue::List(std::sync::Arc::new(
3077 crate::llm::helpers::transcript_events_from_messages(&vm_messages),
3078 )),
3079 );
3080 next.insert(
3081 crate::value::intern_key("messages"),
3082 VmValue::List(std::sync::Arc::new(vm_messages)),
3083 );
3084 if let Some(summary) = summary {
3085 next.put_str("summary", summary);
3086 } else {
3087 next.remove("summary");
3088 }
3089 apply_transcript_with_budget(state, VmValue::dict(next), "replace_messages")?;
3090 Ok(())
3091 })
3092}
3093
3094pub fn append_subscriber(id: &str, callback: VmValue) {
3095 open_or_create(Some(id.to_string()));
3096 SESSIONS.with(|s| {
3097 if let Some(state) = s.borrow_mut().get_mut(id) {
3098 state.subscribers.push(callback);
3099 state.touch();
3100 }
3101 });
3102}
3103
3104pub fn subscribers_for(id: &str) -> Vec<VmValue> {
3105 SESSIONS.with(|s| {
3106 s.borrow()
3107 .get(id)
3108 .map(|state| state.subscribers.clone())
3109 .unwrap_or_default()
3110 })
3111}
3112
3113pub fn subscriber_count(id: &str) -> usize {
3114 SESSIONS.with(|s| {
3115 s.borrow()
3116 .get(id)
3117 .map(|state| state.subscribers.len())
3118 .unwrap_or(0)
3119 })
3120}
3121
3122pub fn set_active_skills(id: &str, skills: Vec<String>) {
3126 SESSIONS.with(|s| {
3127 if let Some(state) = s.borrow_mut().get_mut(id) {
3128 state.active_skills = skills;
3129 state.touch();
3130 }
3131 });
3132}
3133
3134pub fn active_skills(id: &str) -> Vec<String> {
3138 SESSIONS.with(|s| {
3139 s.borrow()
3140 .get(id)
3141 .map(|state| state.active_skills.clone())
3142 .unwrap_or_default()
3143 })
3144}
3145
3146pub fn claim_tool_format(id: &str, tool_format: &str) -> Result<(), String> {
3152 let tool_format = tool_format.trim();
3153 if tool_format.is_empty() {
3154 return Ok(());
3155 }
3156 SESSIONS.with(|s| {
3157 let mut map = s.borrow_mut();
3158 let Some(state) = map.get_mut(id) else {
3159 return Err(format!("agent session '{id}' does not exist"));
3160 };
3161 match state.tool_format.as_deref() {
3162 Some(existing) if existing != tool_format => Err(format!(
3163 "agent session '{id}' is locked to tool_format='{existing}', but this run requested tool_format='{tool_format}'. Start a new session or fork/reset the transcript before changing tool mode."
3164 )),
3165 Some(_) => {
3166 state.touch();
3167 Ok(())
3168 }
3169 None => {
3170 state.tool_format = Some(tool_format.to_string());
3171 state.touch();
3172 Ok(())
3173 }
3174 }
3175 })
3176}
3177
3178pub fn tool_format(id: &str) -> Option<String> {
3179 SESSIONS.with(|s| {
3180 s.borrow()
3181 .get(id)
3182 .and_then(|state| state.tool_format.clone())
3183 })
3184}
3185
3186pub fn record_system_prompt(id: &str, system_prompt: &str) -> Result<(), String> {
3187 let system_prompt = system_prompt.trim();
3188 if system_prompt.is_empty() {
3189 return Ok(());
3190 }
3191 assert_cache_stable_system_prompt(system_prompt);
3192 SESSIONS.with(|s| {
3193 let mut map = s.borrow_mut();
3194 let Some(state) = map.get_mut(id) else {
3195 return Err(format!("agent session '{id}' does not exist"));
3196 };
3197 let changed = state.system_prompt.as_deref() != Some(system_prompt);
3198 state.system_prompt = Some(system_prompt.to_string());
3199 let dict = state
3200 .transcript
3201 .as_dict()
3202 .cloned()
3203 .unwrap_or_else(crate::value::DictMap::new);
3204 let mut next = dict;
3205 apply_system_prompt_metadata(&mut next, system_prompt);
3206 if changed {
3207 let mut events: Vec<VmValue> = match next.get("events") {
3208 Some(VmValue::List(list)) => list.iter().cloned().collect(),
3209 _ => Vec::new(),
3210 };
3211 events.push(crate::llm::helpers::transcript_event(
3212 "system_prompt",
3213 "system",
3214 "internal",
3215 "",
3216 Some(crate::llm::helpers::system_prompt_event_metadata(
3217 system_prompt,
3218 )),
3219 ));
3220 next.insert(
3221 crate::value::intern_key("events"),
3222 VmValue::List(std::sync::Arc::new(events)),
3223 );
3224 }
3225 apply_transcript_with_budget(state, VmValue::dict(next), "record_system_prompt")?;
3226 Ok(())
3227 })
3228}
3229
3230pub fn system_prompt(id: &str) -> Option<String> {
3231 SESSIONS.with(|s| {
3232 s.borrow()
3233 .get(id)
3234 .and_then(|state| state.system_prompt.clone())
3235 })
3236}
3237
3238#[cfg(debug_assertions)]
3239fn forbidden_workspace_prompt_token(system_prompt: &str) -> Option<&'static str> {
3240 let mut remaining = system_prompt;
3241 while let Some(index) = remaining.find("{{") {
3242 let candidate = remaining[index + 2..].trim_start();
3243 if candidate.starts_with("workspace_") {
3244 return Some("workspace_");
3245 }
3246 if candidate.starts_with("project_") {
3247 return Some("project_");
3248 }
3249 remaining = candidate;
3250 }
3251 None
3252}
3253
3254#[cfg(debug_assertions)]
3255fn assert_cache_stable_system_prompt(system_prompt: &str) {
3256 if let Some(prefix) = forbidden_workspace_prompt_token(system_prompt) {
3257 panic!(
3258 "{CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC}: session system prompts must not interpolate `{{{{{prefix}...` tokens; move workspace/project context into the workspace-anchor reminder"
3259 );
3260 }
3261}
3262
3263#[cfg(not(debug_assertions))]
3264fn assert_cache_stable_system_prompt(_system_prompt: &str) {}
3265
3266pub fn set_pinned_model(id: &str, model: Option<String>) -> Result<bool, String> {
3271 let normalized = model
3272 .map(|value| value.trim().to_string())
3273 .filter(|value| !value.is_empty());
3274 SESSIONS.with(|s| {
3275 let mut map = s.borrow_mut();
3276 let Some(state) = map.get_mut(id) else {
3277 return Err(format!("agent session '{id}' does not exist"));
3278 };
3279 let changed = state.pinned_model != normalized;
3280 state.pinned_model = normalized;
3281 state.touch();
3282 Ok(changed)
3283 })
3284}
3285
3286pub fn pinned_model(id: &str) -> Option<String> {
3290 SESSIONS.with(|s| {
3291 s.borrow()
3292 .get(id)
3293 .and_then(|state| state.pinned_model.clone())
3294 })
3295}
3296
3297pub fn set_pinned_reasoning_policy(id: &str, policy: Option<String>) -> Result<bool, String> {
3299 let normalized = match policy {
3300 Some(value) => crate::llm::reasoning_policy::normalize_policy_selector(&value)?,
3301 None => None,
3302 };
3303 SESSIONS.with(|s| {
3304 let mut map = s.borrow_mut();
3305 let Some(state) = map.get_mut(id) else {
3306 return Err(format!("agent session '{id}' does not exist"));
3307 };
3308 let changed = state.pinned_reasoning_policy != normalized;
3309 state.pinned_reasoning_policy = normalized;
3310 state.touch();
3311 Ok(changed)
3312 })
3313}
3314
3315pub fn pinned_reasoning_policy(id: &str) -> Option<String> {
3317 SESSIONS.with(|s| {
3318 s.borrow()
3319 .get(id)
3320 .and_then(|state| state.pinned_reasoning_policy.clone())
3321 })
3322}
3323
3324pub fn set_workspace_anchor(id: &str, anchor: Option<WorkspaceAnchor>) -> Result<bool, String> {
3328 SESSIONS.with(|s| {
3329 let mut map = s.borrow_mut();
3330 let Some(state) = map.get_mut(id) else {
3331 return Err(format!("agent session '{id}' does not exist"));
3332 };
3333 let changed = state.workspace_anchor != anchor;
3334 state.workspace_anchor = anchor;
3335 if changed {
3336 state.redo_stack.clear();
3337 crate::llm::permissions::clear_session_grants(id);
3338 }
3339 state.touch();
3340 Ok(changed)
3341 })
3342}
3343
3344pub fn workspace_anchor(id: &str) -> Option<WorkspaceAnchor> {
3346 SESSIONS.with(|s| {
3347 s.borrow()
3348 .get(id)
3349 .and_then(|state| state.workspace_anchor.clone())
3350 })
3351}
3352
3353#[derive(Clone, Debug, PartialEq, Eq)]
3357pub struct ReanchorOutcome {
3358 pub previous: Option<WorkspaceAnchor>,
3359 pub current: WorkspaceAnchor,
3360 pub changed: bool,
3361}
3362
3363pub fn reanchor_session(
3368 id: &str,
3369 new_anchor: WorkspaceAnchor,
3370 carry_transcript: bool,
3371 compacted: bool,
3372 reason: Option<String>,
3373) -> Result<ReanchorOutcome, String> {
3374 let outcome = SESSIONS.with(|s| {
3375 let mut map = s.borrow_mut();
3376 let Some(state) = map.get_mut(id) else {
3377 return Err(format!("agent session '{id}' does not exist"));
3378 };
3379 let previous = state.workspace_anchor.clone();
3380 let changed = previous.as_ref() != Some(&new_anchor);
3381 state.workspace_anchor = Some(new_anchor.clone());
3382 if changed {
3383 crate::llm::permissions::clear_session_grants(id);
3384 }
3385 state.touch();
3386 Ok(ReanchorOutcome {
3387 previous,
3388 current: new_anchor,
3389 changed,
3390 })
3391 })?;
3392 if !outcome.changed {
3393 return Ok(outcome);
3394 }
3395 let previous_json = outcome.previous.as_ref().map(WorkspaceAnchor::to_json);
3396 let current_json = outcome.current.to_json();
3397 let event_metadata = serde_json::json!({
3398 "previous": previous_json,
3399 "current": current_json,
3400 "carry_transcript": carry_transcript,
3401 "compacted": compacted,
3402 "reason": reason,
3403 });
3404 let event = crate::llm::helpers::transcript_event(
3405 "AnchorChanged",
3406 "system",
3407 "internal",
3408 "",
3409 Some(event_metadata),
3410 );
3411 let _ = append_event(id, event);
3412 crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::AnchorChanged {
3413 session_id: id.to_string(),
3414 previous: previous_json,
3415 current: current_json,
3416 carry_transcript,
3417 compacted,
3418 reason,
3419 });
3420 Ok(outcome)
3421}
3422
3423pub fn set_workspace_policy(id: &str, policy: WorkspacePolicy) -> Result<bool, String> {
3426 SESSIONS.with(|s| {
3427 let mut map = s.borrow_mut();
3428 let Some(state) = map.get_mut(id) else {
3429 return Err(format!("agent session '{id}' does not exist"));
3430 };
3431 let changed = state.workspace_policy != policy;
3432 state.workspace_policy = policy;
3433 if changed {
3434 state.redo_stack.clear();
3435 }
3436 state.touch();
3437 Ok(changed)
3438 })
3439}
3440
3441pub fn workspace_policy(id: &str) -> Option<WorkspacePolicy> {
3443 SESSIONS.with(|s| {
3444 s.borrow()
3445 .get(id)
3446 .map(|state| state.workspace_policy.clone())
3447 })
3448}
3449
3450pub fn add_workspace_root(
3454 id: &str,
3455 root: &str,
3456 mount_mode: Option<MountMode>,
3457 reason: Option<String>,
3458) -> Result<String, String> {
3459 let normalized_root = validate_workspace_root_path(root)?;
3460 let mounted_at = crate::orchestration::now_rfc3339();
3461 SESSIONS.with(|s| {
3462 let mut map = s.borrow_mut();
3463 let Some(state) = map.get_mut(id) else {
3464 return Err(format!("agent session '{id}' does not exist"));
3465 };
3466 let default_mount_mode = state.workspace_policy.default_mount_mode;
3467 let Some(anchor) = state.workspace_anchor.as_mut() else {
3468 return Err(format!("agent session '{id}' has no workspace anchor"));
3469 };
3470 let resolved_mount_mode = mount_mode.unwrap_or(default_mount_mode);
3471 if let Some(existing) = anchor
3472 .additional_roots
3473 .iter_mut()
3474 .find(|entry| entry.path == normalized_root)
3475 {
3476 let changed =
3477 existing.mount_mode != resolved_mount_mode || existing.mounted_at != mounted_at;
3478 existing.mount_mode = resolved_mount_mode;
3479 existing.mounted_at = mounted_at.clone();
3480 if changed {
3481 state.redo_stack.clear();
3482 }
3483 } else {
3484 anchor.additional_roots.push(MountedRoot {
3485 path: normalized_root.clone(),
3486 mount_mode: resolved_mount_mode,
3487 mounted_at: mounted_at.clone(),
3488 });
3489 state.redo_stack.clear();
3490 }
3491 let event = crate::llm::helpers::transcript_event(
3492 "RootMounted",
3493 "system",
3494 "internal",
3495 "",
3496 Some(serde_json::json!({
3497 "path": normalized_root.to_string_lossy(),
3498 "mount_mode": resolved_mount_mode.as_str(),
3499 "mounted_at": mounted_at.clone(),
3500 "reason": reason,
3501 })),
3502 );
3503 append_event_to_state(state, event, "add_workspace_root")?;
3504 crate::llm::permissions::clear_session_grants(id);
3505 state.touch();
3506 Ok(mounted_at.clone())
3507 })
3508}
3509
3510pub fn remove_workspace_root(id: &str, root: &str) -> Result<bool, String> {
3513 let normalized_root = normalize_workspace_root_path(root);
3514 SESSIONS.with(|s| {
3515 let mut map = s.borrow_mut();
3516 let Some(state) = map.get_mut(id) else {
3517 return Err(format!("agent session '{id}' does not exist"));
3518 };
3519 let Some(anchor) = state.workspace_anchor.as_mut() else {
3520 return Err(format!("agent session '{id}' has no workspace anchor"));
3521 };
3522 let before = anchor.additional_roots.len();
3523 anchor
3524 .additional_roots
3525 .retain(|entry| entry.path != normalized_root);
3526 let removed = anchor.additional_roots.len() != before;
3527 if removed {
3528 state.redo_stack.clear();
3529 crate::llm::permissions::clear_session_grants(id);
3530 }
3531 state.touch();
3532 Ok(removed)
3533 })
3534}
3535
3536pub fn list_workspace_roots(id: &str) -> Result<(PathBuf, Vec<MountedRoot>), String> {
3537 SESSIONS.with(|s| {
3538 let map = s.borrow();
3539 let Some(state) = map.get(id) else {
3540 return Err(format!("agent session '{id}' does not exist"));
3541 };
3542 let Some(anchor) = state.workspace_anchor.as_ref() else {
3543 return Err(format!("agent session '{id}' has no workspace anchor"));
3544 };
3545 Ok((anchor.primary.clone(), anchor.additional_roots.clone()))
3546 })
3547}
3548
3549fn validate_workspace_root_path(root: &str) -> Result<PathBuf, String> {
3550 let normalized = normalize_workspace_root_path(root);
3551 let canonical = std::fs::canonicalize(&normalized)
3552 .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
3553 let metadata = std::fs::metadata(&canonical)
3554 .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
3555 if !metadata.is_dir() {
3556 return Err(format!("workspace root '{root}' must be a directory"));
3557 }
3558 std::fs::read_dir(&canonical)
3559 .map_err(|error| format!("workspace root '{root}' must be readable: {error}"))?;
3560 Ok(canonical)
3561}
3562
3563fn normalize_workspace_root_path(root: &str) -> PathBuf {
3564 let absolute = crate::stdlib::process::normalize_context_path(Path::new(root));
3565 std::fs::canonicalize(&absolute).unwrap_or(absolute)
3566}
3567
3568fn empty_transcript(id: &str) -> VmValue {
3569 use crate::llm::helpers::new_transcript_with;
3570 new_transcript_with(Some(id.to_string()), Vec::new(), None, None)
3571}
3572
3573fn clone_transcript_with_id(transcript: &VmValue, new_id: &str) -> VmValue {
3574 let Some(dict) = transcript.as_dict() else {
3575 return empty_transcript(new_id);
3576 };
3577 let mut next = dict.clone();
3578 next.put_str("id", new_id);
3579 VmValue::dict(next)
3580}
3581
3582fn clone_transcript_with_parent(transcript: &VmValue, parent_id: &str) -> VmValue {
3583 let Some(dict) = transcript.as_dict() else {
3584 return transcript.clone();
3585 };
3586 let mut next = dict.clone();
3587 let metadata = match next.get("metadata") {
3588 Some(VmValue::Dict(metadata)) => {
3589 let mut metadata = metadata.as_ref().clone();
3590 metadata.put_str("parent_session_id", parent_id);
3591 VmValue::dict(metadata)
3592 }
3593 _ => VmValue::dict(BTreeMap::from([(
3594 "parent_session_id".to_string(),
3595 VmValue::String(arcstr::ArcStr::from(parent_id.to_string())),
3596 )])),
3597 };
3598 next.insert(crate::value::intern_key("metadata"), metadata);
3599 VmValue::dict(next)
3600}
3601
3602fn apply_system_prompt_metadata(next: &mut crate::value::DictMap, system_prompt: &str) {
3603 let mut metadata = match next.get("metadata") {
3604 Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
3605 _ => crate::value::DictMap::new(),
3606 };
3607 metadata.insert(
3608 crate::value::intern_key("system_prompt"),
3609 crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
3610 system_prompt,
3611 )),
3612 );
3613 next.insert(
3614 crate::value::intern_key("metadata"),
3615 VmValue::dict(metadata),
3616 );
3617}
3618
3619fn transcript_with_session_metadata(transcript: VmValue, state: &SessionState) -> VmValue {
3620 let Some(dict) = transcript.as_dict() else {
3621 return transcript;
3622 };
3623 let mut next = dict.clone();
3624 let mut metadata = match next.get("metadata") {
3625 Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
3626 _ => crate::value::DictMap::new(),
3627 };
3628 if let Some(tool_format) = state.tool_format.as_ref() {
3629 metadata.put_str("tool_format", tool_format.clone());
3630 metadata.insert(
3631 crate::value::intern_key("tool_mode_locked"),
3632 VmValue::Bool(true),
3633 );
3634 }
3635 if let Some(system_prompt) = state.system_prompt.as_ref() {
3636 metadata.insert(
3637 crate::value::intern_key("system_prompt"),
3638 crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
3639 system_prompt,
3640 )),
3641 );
3642 }
3643 if let Some(actor_chain) = state.actor_chain.as_ref() {
3644 metadata.insert(
3645 crate::value::intern_key("actor_chain"),
3646 actor_chain.to_vm_value(),
3647 );
3648 } else {
3649 metadata.remove("actor_chain");
3650 }
3651 if let Some(anchor) = state.workspace_anchor.as_ref() {
3652 metadata.insert(
3653 crate::value::intern_key(WORKSPACE_ANCHOR_METADATA_KEY),
3654 anchor.to_vm_value(),
3655 );
3656 } else {
3657 metadata.remove(WORKSPACE_ANCHOR_METADATA_KEY);
3658 }
3659 if let Some(scratchpad) = state.scratchpad.as_ref() {
3660 metadata.insert(
3661 crate::value::intern_key("agent_scratchpad"),
3662 scratchpad.clone(),
3663 );
3664 metadata.insert(
3665 crate::value::intern_key("agent_scratchpad_version"),
3666 VmValue::Int(state.scratchpad_version as i64),
3667 );
3668 } else {
3669 metadata.remove("agent_scratchpad");
3670 metadata.remove("agent_scratchpad_version");
3671 }
3672 if let Some(last_action) = state.last_transcript_budget_action.as_ref() {
3673 let usage = transcript_usage(
3674 &VmValue::dict(next.clone()),
3675 state.transcript_budget_policy.max_approx_bytes.is_some(),
3676 );
3677 metadata.insert(
3678 crate::value::intern_key("transcript_budget"),
3679 crate::stdlib::json_to_vm_value(&serde_json::json!({
3680 "policy": transcript_budget_policy_json(&state.transcript_budget_policy.normalized()),
3681 "usage": transcript_budget_usage_json(&usage),
3682 "last_action": last_action,
3683 })),
3684 );
3685 }
3686 if !metadata.is_empty() {
3687 next.insert(
3688 crate::value::intern_key("metadata"),
3689 VmValue::dict(metadata),
3690 );
3691 }
3692 VmValue::dict(next)
3693}
3694
3695fn session_snapshot(state: &SessionState) -> VmValue {
3707 let transcript = transcript_with_session_metadata(state.transcript.clone(), state);
3708 let Some(dict) = transcript.as_dict() else {
3709 return state.transcript.clone();
3710 };
3711 let mut next = dict.clone();
3712 let length = next
3713 .get("messages")
3714 .and_then(|value| match value {
3715 VmValue::List(list) => Some(list.len() as i64),
3716 _ => None,
3717 })
3718 .unwrap_or(0);
3719 next.insert(crate::value::intern_key("length"), VmValue::Int(length));
3720 next.put_str("created_at", state.created_at.clone());
3721 next.insert(
3722 crate::value::intern_key("parent_id"),
3723 state
3724 .parent_id
3725 .as_ref()
3726 .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3727 .unwrap_or(VmValue::Nil),
3728 );
3729 next.insert(
3730 crate::value::intern_key("child_ids"),
3731 VmValue::List(std::sync::Arc::new(
3732 state
3733 .child_ids
3734 .iter()
3735 .cloned()
3736 .map(|id| VmValue::String(arcstr::ArcStr::from(id)))
3737 .collect(),
3738 )),
3739 );
3740 next.insert(
3741 crate::value::intern_key("branched_at_event_index"),
3742 state
3743 .branched_at_event_index
3744 .map(|index| VmValue::Int(index as i64))
3745 .unwrap_or(VmValue::Nil),
3746 );
3747 next.insert(
3748 crate::value::intern_key("system_prompt"),
3749 state
3750 .system_prompt
3751 .as_ref()
3752 .map(|prompt| VmValue::String(arcstr::ArcStr::from(prompt.clone())))
3753 .unwrap_or(VmValue::Nil),
3754 );
3755 next.insert(
3756 crate::value::intern_key("tool_format"),
3757 state
3758 .tool_format
3759 .as_ref()
3760 .map(|format| VmValue::String(arcstr::ArcStr::from(format.clone())))
3761 .unwrap_or(VmValue::Nil),
3762 );
3763 next.insert(
3764 crate::value::intern_key("pinned_model"),
3765 state
3766 .pinned_model
3767 .as_ref()
3768 .map(|model| VmValue::String(arcstr::ArcStr::from(model.clone())))
3769 .unwrap_or(VmValue::Nil),
3770 );
3771 next.insert(
3772 crate::value::intern_key("pinned_reasoning_policy"),
3773 state
3774 .pinned_reasoning_policy
3775 .as_ref()
3776 .map(|policy| VmValue::String(arcstr::ArcStr::from(policy.clone())))
3777 .unwrap_or(VmValue::Nil),
3778 );
3779 next.insert(
3780 crate::value::intern_key("actor_chain"),
3781 state
3782 .actor_chain
3783 .as_ref()
3784 .map(ActorChain::to_vm_value)
3785 .unwrap_or(VmValue::Nil),
3786 );
3787 next.insert(
3788 crate::value::intern_key("scratchpad"),
3789 state.scratchpad.clone().unwrap_or(VmValue::Nil),
3790 );
3791 next.insert(
3792 crate::value::intern_key("scratchpad_version"),
3793 VmValue::Int(state.scratchpad_version as i64),
3794 );
3795 next.insert(
3796 crate::value::intern_key("workspace_anchor"),
3797 state
3798 .workspace_anchor
3799 .as_ref()
3800 .map(WorkspaceAnchor::to_vm_value)
3801 .unwrap_or(VmValue::Nil),
3802 );
3803 next.insert(
3804 crate::value::intern_key("workspace_policy"),
3805 state.workspace_policy.to_vm_value(),
3806 );
3807 next.insert(
3808 crate::value::intern_key("live_clients"),
3809 crate::stdlib::json_to_vm_value(&serde_json::Value::Array(
3810 state.live_clients.values().map(live_client_json).collect(),
3811 )),
3812 );
3813 next.insert(
3814 crate::value::intern_key("live_controller_id"),
3815 state
3816 .live_controller_id
3817 .as_ref()
3818 .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3819 .unwrap_or(VmValue::Nil),
3820 );
3821 next.insert(
3822 crate::value::intern_key("completed_turn_checkpoint_count"),
3823 VmValue::Int(state.completed_turn_checkpoints.len() as i64),
3824 );
3825 next.insert(
3826 crate::value::intern_key("redo_checkpoint_count"),
3827 VmValue::Int(state.redo_stack.len() as i64),
3828 );
3829 VmValue::dict(next)
3830}
3831
3832fn update_lineage(
3833 map: &mut HashMap<String, SessionState>,
3834 parent_id: &str,
3835 child_id: &str,
3836 branched_at_event_index: Option<usize>,
3837) {
3838 let old_parent_id = map.get(child_id).and_then(|child| child.parent_id.clone());
3839 if let Some(old_parent_id) = old_parent_id.filter(|old_parent_id| old_parent_id != parent_id) {
3840 if let Some(old_parent) = map.get_mut(&old_parent_id) {
3841 old_parent.child_ids.retain(|id| id != child_id);
3842 old_parent.touch();
3843 }
3844 }
3845 if let Some(parent) = map.get_mut(parent_id) {
3846 parent.touch();
3847 if !parent.child_ids.iter().any(|id| id == child_id) {
3848 parent.child_ids.push(child_id.to_string());
3849 }
3850 }
3851 if let Some(child) = map.get_mut(child_id) {
3852 child.touch();
3853 child.parent_id = Some(parent_id.to_string());
3854 child.branched_at_event_index = branched_at_event_index;
3855 child.transcript = clone_transcript_with_parent(&child.transcript, parent_id);
3856 }
3857}
3858
3859fn branch_event_index(transcript: &VmValue, keep_first: usize) -> usize {
3860 if keep_first == 0 {
3861 return 0;
3862 }
3863 let Some(dict) = transcript.as_dict() else {
3864 return keep_first;
3865 };
3866 let Some(VmValue::List(events)) = dict.get("events") else {
3867 return keep_first;
3868 };
3869 event_prefix_len_for_messages(events, keep_first)
3870}
3871
3872fn event_kind(event: &VmValue) -> Option<String> {
3873 event
3874 .as_dict()
3875 .and_then(|dict| dict.get("kind"))
3876 .map(VmValue::display)
3877}
3878
3879fn event_id(event: &VmValue) -> Option<String> {
3880 event
3881 .as_dict()
3882 .and_then(|dict| dict.get("id"))
3883 .map(VmValue::display)
3884}
3885
3886fn is_turn_event(event: &VmValue) -> bool {
3887 matches!(
3888 event_kind(event).as_deref(),
3889 Some("message" | "tool_result")
3890 )
3891}
3892
3893fn event_prefix_len_for_messages(events: &[VmValue], keep_first: usize) -> usize {
3894 if keep_first == 0 {
3895 return 0;
3896 }
3897 let mut retained_messages = 0usize;
3898 for (index, event) in events.iter().enumerate() {
3899 if is_turn_event(event) {
3900 retained_messages += 1;
3901 if retained_messages == keep_first {
3902 return index + 1;
3903 }
3904 }
3905 }
3906 events.len()
3907}
3908
3909fn turn_event_id_for_count(events: &[VmValue], keep_first: usize) -> Option<String> {
3910 if keep_first == 0 {
3911 return None;
3912 }
3913 let mut retained_messages = 0usize;
3914 for event in events {
3915 if is_turn_event(event) {
3916 retained_messages += 1;
3917 if retained_messages == keep_first {
3918 return event_id(event);
3919 }
3920 }
3921 }
3922 None
3923}
3924
3925#[cfg(test)]
3926#[path = "agent_sessions_tests.rs"]
3927mod tests;