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_transcript_budget::{
29 apply_transcript_with_budget, transcript_budget_policy_json, transcript_budget_usage_json,
30 transcript_message_count, transcript_usage,
31};
32use crate::runtime_limits::RuntimeLimits;
33use crate::value::VmValue;
34use crate::workspace_anchor::{
35 MountMode, MountedRoot, WorkspaceAnchor, WorkspacePolicy, WORKSPACE_ANCHOR_METADATA_KEY,
36};
37
38const LIVE_CLIENT_EVENT_KIND: &str = "live_session_client";
39const LIVE_CLIENT_PERMISSION_EVENT_KIND: &str = "live_session_permission_route";
40
41pub const DEFAULT_SESSION_CAP: usize = RuntimeLimits::DEFAULT.max_agent_sessions;
44
45pub const DEFAULT_TRANSCRIPT_MESSAGE_CAP: usize = 4096;
49
50pub const DEFAULT_TRANSCRIPT_EVENT_CAP: usize = 32768;
53pub const MAX_SCRATCHPAD_BYTES: usize = 16 * 1024;
54#[cfg(debug_assertions)]
55const CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC: &str = "HARN-CACHE-001";
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum TranscriptBudgetRecovery {
59 Reject,
60 Trim { keep_last: usize },
61 Compact { keep_last: usize },
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct SessionTranscriptBudgetPolicy {
66 pub max_messages: usize,
67 pub max_events: usize,
68 pub max_approx_bytes: Option<usize>,
69 pub recovery: TranscriptBudgetRecovery,
70}
71
72impl SessionTranscriptBudgetPolicy {
73 pub fn reject(max_messages: usize, max_events: usize) -> Self {
74 Self {
75 max_messages: max_messages.max(1),
76 max_events: max_events.max(1),
77 max_approx_bytes: None,
78 recovery: TranscriptBudgetRecovery::Reject,
79 }
80 }
81
82 pub fn trim(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
83 Self {
84 max_messages: max_messages.max(1),
85 max_events: max_events.max(1),
86 max_approx_bytes: None,
87 recovery: TranscriptBudgetRecovery::Trim { keep_last },
88 }
89 }
90
91 pub fn compact(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
92 Self {
93 max_messages: max_messages.max(1),
94 max_events: max_events.max(1),
95 max_approx_bytes: None,
96 recovery: TranscriptBudgetRecovery::Compact { keep_last },
97 }
98 }
99
100 pub fn with_max_approx_bytes(mut self, max_approx_bytes: Option<usize>) -> Self {
101 self.max_approx_bytes = max_approx_bytes.map(|limit| limit.max(1));
102 self
103 }
104
105 pub(crate) fn normalized(&self) -> Self {
106 Self {
107 max_messages: self.max_messages.max(1),
108 max_events: self.max_events.max(1),
109 max_approx_bytes: self.max_approx_bytes.map(|limit| limit.max(1)),
110 recovery: self.recovery.clone(),
111 }
112 }
113}
114
115impl Default for SessionTranscriptBudgetPolicy {
116 fn default() -> Self {
117 Self::reject(DEFAULT_TRANSCRIPT_MESSAGE_CAP, DEFAULT_TRANSCRIPT_EVENT_CAP)
118 }
119}
120
121pub struct SessionState {
122 pub id: String,
123 pub transcript: VmValue,
124 pub subscribers: Vec<VmValue>,
125 pub created_at: String,
126 pub last_accessed: Instant,
127 pub parent_id: Option<String>,
128 pub child_ids: Vec<String>,
129 pub branched_at_event_index: Option<usize>,
130 pub actor_chain: Option<ActorChain>,
131 pub active_skills: Vec<String>,
137 pub tool_format: Option<String>,
141 pub system_prompt: Option<String>,
145 pub pinned_model: Option<String>,
151 pub pinned_reasoning_policy: Option<String>,
157 pub workspace_policy: WorkspacePolicy,
160 pub workspace_anchor: Option<WorkspaceAnchor>,
166 pub scratchpad: Option<VmValue>,
169 pub scratchpad_version: u64,
170 pub transcript_budget_policy: SessionTranscriptBudgetPolicy,
171 pub last_transcript_budget_action: Option<serde_json::Value>,
172 pub live_clients: BTreeMap<String, LiveSessionClient>,
173 pub live_controller_id: Option<String>,
174 pub completed_turn_checkpoints: Vec<SessionTurnCheckpoint>,
175 pub redo_stack: Vec<SessionRedoEntry>,
176 pub text_tool_call_seq: u64,
177}
178
179impl SessionState {
180 fn new(id: String) -> Self {
181 let now = Instant::now();
182 let transcript = empty_transcript(&id);
183 Self {
184 id,
185 transcript,
186 subscribers: Vec::new(),
187 created_at: crate::orchestration::now_rfc3339(),
188 last_accessed: now,
189 parent_id: None,
190 child_ids: Vec::new(),
191 branched_at_event_index: None,
192 actor_chain: None,
193 active_skills: Vec::new(),
194 tool_format: None,
195 system_prompt: None,
196 pinned_model: None,
197 pinned_reasoning_policy: None,
198 workspace_policy: WorkspacePolicy::default(),
199 workspace_anchor: None,
200 scratchpad: None,
201 scratchpad_version: 0,
202 transcript_budget_policy: default_transcript_budget_policy(),
203 last_transcript_budget_action: None,
204 live_clients: BTreeMap::new(),
205 live_controller_id: None,
206 completed_turn_checkpoints: Vec::new(),
207 redo_stack: Vec::new(),
208 text_tool_call_seq: 0,
209 }
210 }
211
212 fn touch(&mut self) {
213 self.last_accessed = Instant::now();
214 }
215
216 pub(crate) fn replace_transcript(&mut self, transcript: VmValue) {
217 if !crate::values_equal(&self.transcript, &transcript) {
218 self.redo_stack.clear();
219 }
220 self.transcript = transcript;
221 self.touch();
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum LiveClientMode {
227 Observer,
228 Controller,
229}
230
231impl LiveClientMode {
232 fn as_str(&self) -> &'static str {
233 match self {
234 Self::Observer => "observer",
235 Self::Controller => "controller",
236 }
237 }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct LiveSessionClient {
242 pub client_id: String,
243 pub mode: LiveClientMode,
244 pub attached_at: String,
245 pub last_seen_at: String,
246 pub prompt_injection: bool,
247 pub permission_routing: bool,
248 pub metadata: serde_json::Value,
249}
250
251#[derive(Clone, Debug, PartialEq, Eq)]
252pub struct AttachLiveClient {
253 pub client_id: String,
254 pub mode: LiveClientMode,
255 pub takeover: bool,
256 pub prompt_injection: bool,
257 pub permission_routing: bool,
258 pub metadata: serde_json::Value,
259}
260
261#[derive(Clone, Debug, PartialEq, Eq)]
262pub struct LiveClientChange {
263 pub client: Option<LiveSessionClient>,
264 pub previous_controller_id: Option<String>,
265 pub active_controller_id: Option<String>,
266 pub clients: Vec<LiveSessionClient>,
267}
268
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct SessionAncestry {
271 pub parent_id: Option<String>,
272 pub child_ids: Vec<String>,
273 pub root_id: String,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct SessionTruncateResult {
278 pub kept_turn_count: usize,
279 pub removed_turn_count: usize,
280 pub new_tip_turn_id: Option<String>,
281}
282
283#[derive(Clone, Debug, PartialEq, Eq)]
284pub struct SessionCheckpointSummary {
285 pub checkpoint_id: String,
286 pub before_message_count: usize,
287 pub after_message_count: usize,
288 pub fs_snapshot_ids: Vec<String>,
289}
290
291#[derive(Clone, Debug)]
292pub struct SessionTurnCheckpoint {
293 pub checkpoint_id: String,
294 pub completed_at: String,
295 pub before_transcript: VmValue,
296 pub after_transcript: VmValue,
297 pub before_message_count: usize,
298 pub after_message_count: usize,
299 pub fs_snapshot_ids: Vec<String>,
300}
301
302#[derive(Clone, Debug)]
303pub struct SessionRedoEntry {
304 pub checkpoint: SessionTurnCheckpoint,
305 pub redo_fs_snapshot_ids: Vec<String>,
306}
307
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum SessionCheckpointError {
310 UnknownSession,
311 NoCheckpoint,
312 NoRedo,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct SessionCheckpointOutcome {
317 pub status: &'static str,
318 pub checkpoint: SessionCheckpointSummary,
319 pub redo_fs_snapshot_ids: Vec<String>,
320}
321
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct ReminderInjectionReport {
324 pub reminder_id: String,
325 pub deduped_count: usize,
326}
327
328thread_local! {
329 static SESSIONS: RefCell<HashMap<String, SessionState>> = RefCell::new(HashMap::new());
330 static SESSION_CAP: Cell<usize> = const { Cell::new(DEFAULT_SESSION_CAP) };
331 static DEFAULT_TRANSCRIPT_BUDGET_POLICY: RefCell<SessionTranscriptBudgetPolicy> =
332 RefCell::new(SessionTranscriptBudgetPolicy::default());
333 static CURRENT_SESSION_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
334 static CURRENT_TOOL_CALL_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
335}
336
337tokio::task_local! {
338 static CURRENT_TOOL_CALL_TASK: String;
339}
340
341static SESSION_CHANGED_PATHS: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
351
352fn session_changed_paths_store() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
353 SESSION_CHANGED_PATHS.get_or_init(|| Mutex::new(BTreeMap::new()))
354}
355
356pub fn record_session_changed_path(session_id: &str, path: &str) {
359 if session_id.is_empty() || path.is_empty() {
360 return;
361 }
362 if let Ok(mut store) = session_changed_paths_store().lock() {
363 store
364 .entry(session_id.to_string())
365 .or_default()
366 .insert(path.to_string());
367 }
368}
369
370pub fn session_changed_paths(session_id: &str) -> Vec<String> {
373 session_changed_paths_store()
374 .lock()
375 .ok()
376 .and_then(|store| {
377 store
378 .get(session_id)
379 .map(|set| set.iter().cloned().collect())
380 })
381 .unwrap_or_default()
382}
383
384pub fn take_session_changed_paths(session_id: &str) -> Vec<String> {
388 session_changed_paths_store()
389 .lock()
390 .ok()
391 .and_then(|mut store| store.remove(session_id))
392 .map(|set| set.into_iter().collect())
393 .unwrap_or_default()
394}
395
396pub fn clear_session_changed_paths(session_id: &str) {
398 if let Ok(mut store) = session_changed_paths_store().lock() {
399 store.remove(session_id);
400 }
401}
402
403pub struct CurrentSessionGuard {
404 active: bool,
405}
406
407impl Drop for CurrentSessionGuard {
408 fn drop(&mut self) {
409 if self.active {
410 pop_current_session();
411 }
412 }
413}
414
415pub struct CurrentToolCallGuard {
421 active: bool,
422}
423
424impl Drop for CurrentToolCallGuard {
425 fn drop(&mut self) {
426 if self.active {
427 pop_current_tool_call();
428 }
429 }
430}
431
432pub fn set_session_cap(cap: usize) {
435 SESSION_CAP.with(|c| c.set(cap.max(1)));
436}
437
438pub fn session_cap() -> usize {
439 SESSION_CAP.with(|c| c.get())
440}
441
442pub fn set_default_transcript_budget_policy(policy: SessionTranscriptBudgetPolicy) {
443 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| {
444 *cell.borrow_mut() = policy.normalized();
445 });
446}
447
448pub fn reset_default_transcript_budget_policy() {
449 set_default_transcript_budget_policy(SessionTranscriptBudgetPolicy::default());
450}
451
452pub fn default_transcript_budget_policy() -> SessionTranscriptBudgetPolicy {
453 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| cell.borrow().clone())
454}
455
456pub fn transcript_budget_policy(id: &str) -> Option<SessionTranscriptBudgetPolicy> {
457 SESSIONS.with(|s| {
458 s.borrow()
459 .get(id)
460 .map(|state| state.transcript_budget_policy.clone())
461 })
462}
463
464pub fn set_transcript_budget_policy(
465 id: &str,
466 policy: SessionTranscriptBudgetPolicy,
467) -> Result<(), String> {
468 SESSIONS.with(|s| {
469 let mut map = s.borrow_mut();
470 let Some(state) = map.get_mut(id) else {
471 return Err(format!("agent session '{id}' does not exist"));
472 };
473 let previous = state.transcript_budget_policy.clone();
474 let previous_action = state.last_transcript_budget_action.clone();
475 state.transcript_budget_policy = policy.normalized();
476 let candidate = state.transcript.clone();
477 if let Err(error) = apply_transcript_with_budget(state, candidate, "policy_update") {
478 state.transcript_budget_policy = previous;
479 state.last_transcript_budget_action = previous_action;
480 return Err(error);
481 }
482 Ok(())
483 })
484}
485
486pub fn reset_session_store() {
488 SESSIONS.with(|s| s.borrow_mut().clear());
489 CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().clear());
490 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().clear());
491 reset_default_transcript_budget_policy();
492}
493
494pub(crate) fn push_current_session(id: String) {
495 if id.is_empty() {
496 return;
497 }
498 CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().push(id));
499}
500
501pub(crate) fn swap_current_session_stack(replacement: Vec<String>) -> Vec<String> {
502 CURRENT_SESSION_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), replacement))
503}
504
505pub(crate) fn pop_current_session() {
506 CURRENT_SESSION_STACK.with(|stack| {
507 let _ = stack.borrow_mut().pop();
508 });
509}
510
511pub fn current_session_id() -> Option<String> {
512 CURRENT_SESSION_STACK.with(|stack| stack.borrow().last().cloned())
513}
514
515pub(crate) fn next_text_tool_call_seq(id: &str) -> Option<u64> {
516 SESSIONS.with(|s| {
517 let mut map = s.borrow_mut();
518 let state = map.get_mut(id)?;
519 let seq = state.text_tool_call_seq;
520 state.text_tool_call_seq = state.text_tool_call_seq.checked_add(1).unwrap_or(0);
521 state.touch();
522 Some(seq)
523 })
524}
525
526fn parse_text_tool_call_seq(id: &str) -> Option<u64> {
527 let digits = id.strip_prefix("tc_")?;
528 if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
529 return None;
530 }
531 digits.parse().ok()
532}
533
534fn update_next_text_tool_call_seq(value: &serde_json::Value, next_seq: &mut u64) {
535 match value {
536 serde_json::Value::Array(items) => {
537 for item in items {
538 update_next_text_tool_call_seq(item, next_seq);
539 }
540 }
541 serde_json::Value::Object(map) => {
542 for key in ["id", "tool_call_id"] {
543 if let Some(seq) = map
544 .get(key)
545 .and_then(serde_json::Value::as_str)
546 .and_then(parse_text_tool_call_seq)
547 {
548 *next_seq = (*next_seq).max(seq.saturating_add(1));
549 }
550 }
551 for item in map.values() {
552 update_next_text_tool_call_seq(item, next_seq);
553 }
554 }
555 _ => {}
556 }
557}
558
559fn next_text_tool_call_seq_from_json_messages(messages: &[serde_json::Value]) -> u64 {
560 let mut next_seq = 0;
561 for message in messages {
562 update_next_text_tool_call_seq(message, &mut next_seq);
563 }
564 next_seq
565}
566
567fn next_text_tool_call_seq_from_transcript(transcript: &VmValue) -> u64 {
568 let Some(dict) = transcript.as_dict() else {
569 return 0;
570 };
571 let Some(VmValue::List(messages)) = dict.get("messages") else {
572 return 0;
573 };
574 next_text_tool_call_seq_from_json_messages(
575 &messages
576 .iter()
577 .map(crate::llm::helpers::vm_value_to_json)
578 .collect::<Vec<_>>(),
579 )
580}
581
582pub fn current_actor_chain() -> Option<ActorChain> {
583 current_session_id().as_deref().and_then(actor_chain)
584}
585
586pub fn enter_current_session(id: impl Into<String>) -> CurrentSessionGuard {
587 let id = id.into();
588 if id.trim().is_empty() {
589 return CurrentSessionGuard { active: false };
590 }
591 push_current_session(id);
592 CurrentSessionGuard { active: true }
593}
594
595pub fn actor_chain(id: &str) -> Option<ActorChain> {
596 SESSIONS.with(|s| {
597 s.borrow()
598 .get(id)
599 .and_then(|state| state.actor_chain.clone())
600 })
601}
602
603pub fn set_actor_chain(id: &str, actor_chain: Option<ActorChain>) -> Result<bool, String> {
604 SESSIONS.with(|s| {
605 let mut map = s.borrow_mut();
606 let Some(state) = map.get_mut(id) else {
607 return Err(format!("agent session '{id}' does not exist"));
608 };
609 let changed = state.actor_chain != actor_chain;
610 state.actor_chain = actor_chain;
611 state.touch();
612 Ok(changed)
613 })
614}
615
616fn push_current_tool_call(id: String) {
617 if id.is_empty() {
618 return;
619 }
620 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().push(id));
621}
622
623fn pop_current_tool_call() {
624 CURRENT_TOOL_CALL_STACK.with(|stack| {
625 let _ = stack.borrow_mut().pop();
626 });
627}
628
629pub fn current_tool_call_id() -> Option<String> {
634 if let Ok(id) = CURRENT_TOOL_CALL_TASK.try_with(Clone::clone) {
635 if !id.trim().is_empty() {
636 return Some(id);
637 }
638 }
639 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow().last().cloned())
640}
641
642pub async fn scope_current_tool_call<F, T>(id: impl Into<String>, future: F) -> T
648where
649 F: Future<Output = T>,
650{
651 let id = id.into();
652 if id.trim().is_empty() {
653 future.await
654 } else {
655 CURRENT_TOOL_CALL_TASK.scope(id, future).await
656 }
657}
658
659pub fn enter_current_tool_call(id: impl Into<String>) -> CurrentToolCallGuard {
661 let id = id.into();
662 if id.trim().is_empty() {
663 return CurrentToolCallGuard { active: false };
664 }
665 push_current_tool_call(id);
666 CurrentToolCallGuard { active: true }
667}
668
669pub fn exists(id: &str) -> bool {
670 SESSIONS.with(|s| s.borrow().contains_key(id))
671}
672
673pub fn length(id: &str) -> Option<usize> {
674 SESSIONS.with(|s| {
675 s.borrow().get(id).map(|state| {
676 state
677 .transcript
678 .as_dict()
679 .and_then(|d| d.get("messages"))
680 .and_then(|v| match v {
681 VmValue::List(list) => Some(list.len()),
682 _ => None,
683 })
684 .unwrap_or(0)
685 })
686 })
687}
688
689pub fn scratchpad(id: &str) -> Option<VmValue> {
690 SESSIONS.with(|s| {
691 s.borrow()
692 .get(id)
693 .and_then(|state| state.scratchpad.clone())
694 })
695}
696
697pub fn scratchpad_version(id: &str) -> Option<u64> {
698 SESSIONS.with(|s| s.borrow().get(id).map(|state| state.scratchpad_version))
699}
700
701pub fn set_scratchpad(
702 id: &str,
703 scratchpad: VmValue,
704 source: impl Into<String>,
705 reason: Option<String>,
706 metadata: serde_json::Value,
707) -> Result<u64, String> {
708 validate_scratchpad_value(&scratchpad)?;
709 SESSIONS.with(|s| {
710 let mut map = s.borrow_mut();
711 let Some(state) = map.get_mut(id) else {
712 return Err(format!("agent session '{id}' does not exist"));
713 };
714 let version = state.scratchpad_version.saturating_add(1);
715 let event = scratchpad_transcript_event(
716 "set",
717 version,
718 Some(&scratchpad),
719 source.into(),
720 reason,
721 metadata,
722 );
723 append_event_to_state(state, event, "set_scratchpad")?;
724 state.scratchpad = Some(scratchpad);
725 state.scratchpad_version = version;
726 state.touch();
727 Ok(version)
728 })
729}
730
731pub fn clear_scratchpad(
732 id: &str,
733 source: impl Into<String>,
734 reason: Option<String>,
735 metadata: serde_json::Value,
736) -> Result<u64, String> {
737 SESSIONS.with(|s| {
738 let mut map = s.borrow_mut();
739 let Some(state) = map.get_mut(id) else {
740 return Err(format!("agent session '{id}' does not exist"));
741 };
742 let version = state.scratchpad_version.saturating_add(1);
743 let event =
744 scratchpad_transcript_event("clear", version, None, source.into(), reason, metadata);
745 append_event_to_state(state, event, "clear_scratchpad")?;
746 state.scratchpad = None;
747 state.scratchpad_version = version;
748 state.touch();
749 Ok(version)
750 })
751}
752
753fn validate_scratchpad_value(value: &VmValue) -> Result<(), String> {
754 if !matches!(value, VmValue::Dict(_)) {
755 return Err("agent session scratchpad must be a dict".to_string());
756 }
757 let json = crate::llm::helpers::vm_value_to_json(value);
758 let approx_bytes = serde_json::to_vec(&json)
759 .map(|bytes| bytes.len())
760 .unwrap_or(usize::MAX);
761 if approx_bytes > MAX_SCRATCHPAD_BYTES {
762 return Err(format!(
763 "agent session scratchpad is {approx_bytes} bytes; max is {MAX_SCRATCHPAD_BYTES}"
764 ));
765 }
766 Ok(())
767}
768
769fn scratchpad_transcript_event(
770 action: &str,
771 version: u64,
772 scratchpad: Option<&VmValue>,
773 source: String,
774 reason: Option<String>,
775 metadata: serde_json::Value,
776) -> VmValue {
777 let scratchpad_json = scratchpad.map(crate::llm::helpers::vm_value_to_json);
778 let approx_bytes = scratchpad_json
779 .as_ref()
780 .and_then(|value| serde_json::to_vec(value).ok().map(|bytes| bytes.len()))
781 .unwrap_or(0);
782 let event_metadata = serde_json::json!({
783 "action": action,
784 "version": version,
785 "source": normalize_scratchpad_source(source),
786 "reason": reason.unwrap_or_default(),
787 "approx_bytes": approx_bytes,
788 "counts": scratchpad_json
789 .as_ref()
790 .map(scratchpad_counts_json)
791 .unwrap_or_else(|| serde_json::json!({})),
792 "metadata": metadata,
793 });
794 let content = format!("Agent scratchpad {action}");
795 crate::llm::helpers::transcript_event(
796 "agent_scratchpad",
797 "system",
798 "internal",
799 &content,
800 Some(event_metadata),
801 )
802}
803
804fn normalize_scratchpad_source(source: String) -> String {
805 let trimmed = source.trim();
806 if trimmed.is_empty() {
807 "harn.agent_scratchpad".to_string()
808 } else {
809 trimmed.to_string()
810 }
811}
812
813fn scratchpad_counts_json(value: &serde_json::Value) -> serde_json::Value {
814 serde_json::json!({
815 "goals": scratchpad_array_len(value, "goals"),
816 "open_items": scratchpad_array_len(value, "open_items"),
817 "facts": scratchpad_array_len(value, "facts"),
818 "refs": scratchpad_array_len(value, "refs"),
819 })
820}
821
822fn scratchpad_array_len(value: &serde_json::Value, key: &str) -> usize {
823 value
824 .get(key)
825 .and_then(serde_json::Value::as_array)
826 .map(Vec::len)
827 .unwrap_or(0)
828}
829
830pub fn snapshot(id: &str) -> Option<VmValue> {
831 SESSIONS.with(|s| s.borrow().get(id).map(session_snapshot))
832}
833
834pub fn transcript(id: &str) -> Option<VmValue> {
836 SESSIONS.with(|s| {
837 s.borrow()
838 .get(id)
839 .map(|state| transcript_with_session_metadata(state.transcript.clone(), state))
840 })
841}
842
843pub fn open_or_create(id: Option<String>) -> String {
852 open_or_create_with_actor_chain(id, None)
853}
854
855pub fn open_or_create_with_actor_chain(
856 id: Option<String>,
857 requested_actor_chain: Option<ActorChain>,
858) -> String {
859 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
860 let parent_session = current_session_id();
861 let inherited_actor_chain = requested_actor_chain
862 .clone()
863 .or_else(|| parent_session.as_deref().and_then(actor_chain));
864 let mut was_new = false;
865 SESSIONS.with(|s| {
866 let mut map = s.borrow_mut();
867 if let Some(state) = map.get_mut(&resolved) {
868 if let Some(actor_chain) = requested_actor_chain.clone() {
869 state.actor_chain = Some(actor_chain);
870 }
871 state.touch();
872 return;
873 }
874 was_new = true;
875 let cap = SESSION_CAP.with(|c| c.get());
876 if map.len() >= cap {
877 if let Some(victim) = map
878 .iter()
879 .min_by_key(|(_, state)| state.last_accessed)
880 .map(|(id, _)| id.clone())
881 {
882 map.remove(&victim);
883 }
884 }
885 let mut state = SessionState::new(resolved.clone());
886 state.actor_chain = inherited_actor_chain.clone();
887 map.insert(resolved.clone(), state);
888 });
889 if was_new {
890 if let Some(parent) = parent_session.as_deref() {
891 crate::agent_events::mirror_session_sinks(parent, &resolved);
892 }
893 try_register_event_log(&resolved);
894 }
895 resolved
896}
897
898pub fn open_child_session(parent_id: &str, id: Option<String>) -> String {
899 open_child_session_with_actor(parent_id, id, None)
900}
901
902pub fn open_child_session_with_actor(
903 parent_id: &str,
904 id: Option<String>,
905 actor: Option<&str>,
906) -> String {
907 let actor_chain = actor_chain(parent_id).map(|chain| match actor {
908 Some(actor) if !actor.trim().is_empty() => chain.pushed(actor.trim()),
909 _ => chain,
910 });
911 let resolved = open_or_create_with_actor_chain(id, actor_chain);
912 link_child_session(parent_id, &resolved);
913 resolved
914}
915
916pub fn link_child_session(parent_id: &str, child_id: &str) {
917 link_child_session_with_branch(parent_id, child_id, None);
918}
919
920pub fn link_child_session_with_branch(
921 parent_id: &str,
922 child_id: &str,
923 branched_at_event_index: Option<usize>,
924) {
925 if parent_id == child_id {
926 return;
927 }
928 open_or_create(Some(parent_id.to_string()));
929 open_or_create(Some(child_id.to_string()));
930 SESSIONS.with(|s| {
931 let mut map = s.borrow_mut();
932 update_lineage(&mut map, parent_id, child_id, branched_at_event_index);
933 });
934}
935
936pub fn parent_id(id: &str) -> Option<String> {
937 SESSIONS.with(|s| s.borrow().get(id).and_then(|state| state.parent_id.clone()))
938}
939
940pub fn child_ids(id: &str) -> Vec<String> {
941 SESSIONS.with(|s| {
942 s.borrow()
943 .get(id)
944 .map(|state| state.child_ids.clone())
945 .unwrap_or_default()
946 })
947}
948
949pub fn ancestry(id: &str) -> Option<SessionAncestry> {
950 SESSIONS.with(|s| {
951 let map = s.borrow();
952 let state = map.get(id)?;
953 let mut root_id = state.id.clone();
954 let mut cursor = state.parent_id.clone();
955 let mut seen = HashSet::from([state.id.clone()]);
956 while let Some(parent_id) = cursor {
957 if !seen.insert(parent_id.clone()) {
958 break;
959 }
960 root_id = parent_id.clone();
961 cursor = map
962 .get(&parent_id)
963 .and_then(|parent| parent.parent_id.clone());
964 }
965 Some(SessionAncestry {
966 parent_id: state.parent_id.clone(),
967 child_ids: state.child_ids.clone(),
968 root_id,
969 })
970 })
971}
972
973pub fn live_clients(id: &str) -> Option<Vec<LiveSessionClient>> {
974 SESSIONS.with(|s| {
975 s.borrow()
976 .get(id)
977 .map(|state| state.live_clients.values().cloned().collect())
978 })
979}
980
981pub fn attach_live_client(id: &str, request: AttachLiveClient) -> Result<LiveClientChange, String> {
982 SESSIONS.with(|s| {
983 let mut map = s.borrow_mut();
984 let Some(state) = map.get_mut(id) else {
985 return Err(format!("agent session '{id}' does not exist"));
986 };
987 let client_id = validate_live_client_id(request.client_id)?;
988 let now = crate::orchestration::now_rfc3339();
989 let previous_clients = state.live_clients.clone();
990 let previous_controller_id = state.live_controller_id.clone();
991
992 if request.mode == LiveClientMode::Controller {
993 let conflicting_controller = previous_controller_id
994 .as_ref()
995 .filter(|controller_id| *controller_id != &client_id)
996 .filter(|controller_id| state.live_clients.contains_key(*controller_id));
997 if let Some(previous) = conflicting_controller {
998 if !request.takeover {
999 return Err(format!("live session already has controller '{previous}'"));
1000 }
1001 if let Some(previous_client) = state.live_clients.get_mut(previous) {
1002 previous_client.mode = LiveClientMode::Observer;
1003 previous_client.prompt_injection = false;
1004 previous_client.permission_routing = false;
1005 previous_client.last_seen_at = now.clone();
1006 }
1007 }
1008 state.live_controller_id = Some(client_id.clone());
1009 } else if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1010 state.live_controller_id = None;
1011 }
1012
1013 let attached_at = state
1014 .live_clients
1015 .get(&client_id)
1016 .map(|client| client.attached_at.clone())
1017 .unwrap_or_else(|| now.clone());
1018 let client = LiveSessionClient {
1019 client_id: client_id.clone(),
1020 mode: request.mode,
1021 attached_at,
1022 last_seen_at: now,
1023 prompt_injection: request.prompt_injection,
1024 permission_routing: request.permission_routing,
1025 metadata: request.metadata,
1026 };
1027 state.live_clients.insert(client_id, client.clone());
1028 state.touch();
1029 let active_controller_id = state.live_controller_id.clone();
1030 append_live_client_event(
1031 state,
1032 "attached",
1033 Some(&client),
1034 previous_controller_id.as_deref(),
1035 active_controller_id.as_deref(),
1036 serde_json::Value::Null,
1037 )
1038 .inspect_err(|_error| {
1039 state.live_clients = previous_clients;
1040 state.live_controller_id = previous_controller_id.clone();
1041 })?;
1042 Ok(live_client_change(
1043 Some(client),
1044 previous_controller_id,
1045 state,
1046 ))
1047 })
1048}
1049
1050pub fn takeover_live_client(
1051 id: &str,
1052 client_id: impl Into<String>,
1053 metadata: serde_json::Value,
1054) -> Result<LiveClientChange, String> {
1055 attach_live_client(
1056 id,
1057 AttachLiveClient {
1058 client_id: client_id.into(),
1059 mode: LiveClientMode::Controller,
1060 takeover: true,
1061 prompt_injection: true,
1062 permission_routing: true,
1063 metadata,
1064 },
1065 )
1066}
1067
1068pub fn detach_live_client(
1069 id: &str,
1070 client_id: impl Into<String>,
1071 reason: Option<String>,
1072 metadata: serde_json::Value,
1073) -> Result<LiveClientChange, String> {
1074 SESSIONS.with(|s| {
1075 let mut map = s.borrow_mut();
1076 let Some(state) = map.get_mut(id) else {
1077 return Err(format!("agent session '{id}' does not exist"));
1078 };
1079 let client_id = validate_live_client_id(client_id.into())?;
1080 let previous_clients = state.live_clients.clone();
1081 let previous_controller_id = state.live_controller_id.clone();
1082 let Some(mut client) = state.live_clients.remove(&client_id) else {
1083 return Err(format!("live client '{client_id}' is not attached"));
1084 };
1085 client.last_seen_at = crate::orchestration::now_rfc3339();
1086 if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1087 state.live_controller_id = None;
1088 }
1089 state.touch();
1090 let active_controller_id = state.live_controller_id.clone();
1091 append_live_client_event(
1092 state,
1093 "detached",
1094 Some(&client),
1095 previous_controller_id.as_deref(),
1096 active_controller_id.as_deref(),
1097 serde_json::json!({
1098 "reason": reason.unwrap_or_else(|| "client_detached".to_string()),
1099 "metadata": metadata,
1100 }),
1101 )
1102 .inspect_err(|_error| {
1103 state.live_clients = previous_clients;
1104 state.live_controller_id = previous_controller_id.clone();
1105 })?;
1106 Ok(live_client_change(None, previous_controller_id, state))
1107 })
1108}
1109
1110pub fn heartbeat_live_client(
1111 id: &str,
1112 client_id: impl Into<String>,
1113 metadata: serde_json::Value,
1114) -> Result<LiveClientChange, String> {
1115 SESSIONS.with(|s| {
1116 let mut map = s.borrow_mut();
1117 let Some(state) = map.get_mut(id) else {
1118 return Err(format!("agent session '{id}' does not exist"));
1119 };
1120 let client_id = validate_live_client_id(client_id.into())?;
1121 let previous_clients = state.live_clients.clone();
1122 let previous_controller_id = state.live_controller_id.clone();
1123 let Some(client) = state.live_clients.get_mut(&client_id) else {
1124 return Err(format!("live client '{client_id}' is not attached"));
1125 };
1126 client.last_seen_at = crate::orchestration::now_rfc3339();
1127 if !metadata.is_null() {
1128 client.metadata = metadata.clone();
1129 }
1130 let client = client.clone();
1131 state.touch();
1132 let active_controller_id = state.live_controller_id.clone();
1133 append_live_client_event(
1134 state,
1135 "heartbeat",
1136 Some(&client),
1137 previous_controller_id.as_deref(),
1138 active_controller_id.as_deref(),
1139 serde_json::json!({ "metadata": metadata }),
1140 )
1141 .inspect_err(|_error| {
1142 state.live_clients = previous_clients;
1143 state.live_controller_id = previous_controller_id.clone();
1144 })?;
1145 Ok(live_client_change(
1146 Some(client),
1147 previous_controller_id,
1148 state,
1149 ))
1150 })
1151}
1152
1153pub fn inject_prompt_from_live_client(
1154 id: &str,
1155 client_id: impl Into<String>,
1156 content: VmValue,
1157 metadata: serde_json::Value,
1158) -> Result<(), String> {
1159 let client_id = validate_live_client_id(client_id.into())?;
1160 ensure_live_controller(id, &client_id, LiveControllerCapability::PromptInjection)?;
1161 let mut message = BTreeMap::new();
1162 message.put_str("role", "user");
1163 message.insert("content".to_string(), content);
1164 message.insert(
1165 "metadata".to_string(),
1166 crate::stdlib::json_to_vm_value(&serde_json::json!({
1167 "live_session": {
1168 "client_id": client_id,
1169 "mode": "controller",
1170 "source": "live_session_attach",
1171 "metadata": metadata,
1172 }
1173 })),
1174 );
1175 inject_message(id, VmValue::dict(message))
1176}
1177
1178pub fn route_live_permission_request(
1179 id: &str,
1180 client_id: impl Into<String>,
1181 request: serde_json::Value,
1182 metadata: serde_json::Value,
1183) -> Result<serde_json::Value, String> {
1184 let client_id = validate_live_client_id(client_id.into())?;
1185 let client =
1186 ensure_live_controller(id, &client_id, LiveControllerCapability::PermissionRouting)?;
1187 let request_id = request
1188 .get("id")
1189 .or_else(|| request.get("request_id"))
1190 .and_then(serde_json::Value::as_str)
1191 .filter(|id| !id.trim().is_empty())
1192 .unwrap_or("permission_request");
1193 let event_metadata = serde_json::json!({
1194 "action": "permission_routed",
1195 "client": live_client_json(&client),
1196 "request_id": request_id,
1197 "request": request,
1198 "metadata": metadata,
1199 });
1200 let event = crate::llm::helpers::transcript_event(
1201 LIVE_CLIENT_PERMISSION_EVENT_KIND,
1202 "system",
1203 "internal",
1204 "Live session permission request routed",
1205 Some(event_metadata.clone()),
1206 );
1207 append_event(id, event)?;
1208 Ok(event_metadata)
1209}
1210
1211enum LiveControllerCapability {
1212 PromptInjection,
1213 PermissionRouting,
1214}
1215
1216fn ensure_live_controller(
1217 id: &str,
1218 client_id: &str,
1219 capability: LiveControllerCapability,
1220) -> Result<LiveSessionClient, String> {
1221 SESSIONS.with(|s| {
1222 let map = s.borrow();
1223 let Some(state) = map.get(id) else {
1224 return Err(format!("agent session '{id}' does not exist"));
1225 };
1226 if state.live_controller_id.as_deref() != Some(client_id) {
1227 return Err(format!(
1228 "live client '{client_id}' is not the active controller"
1229 ));
1230 }
1231 let Some(client) = state.live_clients.get(client_id) else {
1232 return Err(format!("live client '{client_id}' is not attached"));
1233 };
1234 match capability {
1235 LiveControllerCapability::PromptInjection if !client.prompt_injection => Err(format!(
1236 "live client '{client_id}' cannot inject prompts for this session"
1237 )),
1238 LiveControllerCapability::PermissionRouting if !client.permission_routing => Err(
1239 format!("live client '{client_id}' cannot route permissions for this session"),
1240 ),
1241 _ => Ok(client.clone()),
1242 }
1243 })
1244}
1245
1246fn append_live_client_event(
1247 state: &mut SessionState,
1248 action: &str,
1249 client: Option<&LiveSessionClient>,
1250 previous_controller_id: Option<&str>,
1251 active_controller_id: Option<&str>,
1252 extra: serde_json::Value,
1253) -> Result<(), String> {
1254 let metadata = serde_json::json!({
1255 "action": action,
1256 "session_id": state.id,
1257 "client": client.map(live_client_json),
1258 "previous_controller_id": previous_controller_id,
1259 "active_controller_id": active_controller_id,
1260 "clients": state
1261 .live_clients
1262 .values()
1263 .map(live_client_json)
1264 .collect::<Vec<_>>(),
1265 "extra": extra,
1266 });
1267 let event = crate::llm::helpers::transcript_event(
1268 LIVE_CLIENT_EVENT_KIND,
1269 "system",
1270 "internal",
1271 "Live session client lifecycle changed",
1272 Some(metadata),
1273 );
1274 append_event_to_state(state, event, "live_client")
1275}
1276
1277fn live_client_change(
1278 client: Option<LiveSessionClient>,
1279 previous_controller_id: Option<String>,
1280 state: &SessionState,
1281) -> LiveClientChange {
1282 LiveClientChange {
1283 client,
1284 previous_controller_id,
1285 active_controller_id: state.live_controller_id.clone(),
1286 clients: state.live_clients.values().cloned().collect(),
1287 }
1288}
1289
1290fn validate_live_client_id(id: impl Into<String>) -> Result<String, String> {
1291 let id = id.into();
1292 let trimmed = id.trim();
1293 if trimmed.is_empty() {
1294 return Err("live client id cannot be empty".to_string());
1295 }
1296 Ok(trimmed.to_string())
1297}
1298
1299pub fn live_client_json(client: &LiveSessionClient) -> serde_json::Value {
1300 serde_json::json!({
1301 "client_id": client.client_id,
1302 "mode": client.mode.as_str(),
1303 "attached_at": client.attached_at,
1304 "last_seen_at": client.last_seen_at,
1305 "prompt_injection": client.prompt_injection,
1306 "permission_routing": client.permission_routing,
1307 "metadata": client.metadata,
1308 })
1309}
1310
1311pub fn live_client_change_json(change: &LiveClientChange) -> serde_json::Value {
1312 serde_json::json!({
1313 "client": change.client.as_ref().map(live_client_json),
1314 "previous_controller_id": change.previous_controller_id,
1315 "active_controller_id": change.active_controller_id,
1316 "clients": change
1317 .clients
1318 .iter()
1319 .map(live_client_json)
1320 .collect::<Vec<_>>(),
1321 })
1322}
1323
1324fn try_register_event_log(session_id: &str) {
1328 if let Some(log) = crate::event_log::active_event_log() {
1329 crate::agent_events::register_sink(
1330 session_id,
1331 crate::agent_events::EventLogSink::new(log, session_id),
1332 );
1333 return;
1334 }
1335 let Ok(dir) = std::env::var("HARN_EVENT_LOG_DIR") else {
1336 return;
1337 };
1338 if dir.is_empty() {
1339 return;
1340 }
1341 let path = std::path::PathBuf::from(dir).join(format!("event_log-{session_id}.jsonl"));
1342 if let Ok(sink) = crate::agent_events::JsonlEventSink::open(&path) {
1343 crate::agent_events::register_sink(session_id, sink);
1344 }
1345}
1346
1347pub fn register_event_log_sink(session_id: &str) {
1348 try_register_event_log(session_id);
1349}
1350
1351pub fn close(id: &str) {
1352 SESSIONS.with(|s| {
1353 s.borrow_mut().remove(id);
1354 });
1355 crate::orchestration::agent_inbox::clear_session(id);
1359 crate::agent_events::clear_session_sinks(id);
1360}
1361
1362pub fn close_with_status(
1363 id: &str,
1364 reason: impl Into<String>,
1365 status: impl Into<String>,
1366 metadata: serde_json::Value,
1367) -> bool {
1368 if !exists(id) {
1369 return false;
1370 }
1371 let reason = reason.into();
1372 let status = status.into();
1373 let event_metadata = serde_json::json!({
1374 "reason": reason,
1375 "status": status,
1376 "metadata": metadata,
1377 });
1378 let transcript_event = crate::llm::helpers::transcript_event(
1379 "agent_session_closed",
1380 "system",
1381 "internal",
1382 "Agent session closed",
1383 Some(event_metadata),
1384 );
1385 let _ = append_event(id, transcript_event);
1386 crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::SessionClosed {
1387 session_id: id.to_string(),
1388 reason,
1389 status,
1390 metadata,
1391 });
1392 close(id);
1393 true
1394}
1395
1396pub fn reset_transcript(id: &str) -> bool {
1397 SESSIONS.with(|s| {
1398 let mut map = s.borrow_mut();
1399 let Some(state) = map.get_mut(id) else {
1400 return false;
1401 };
1402 state.transcript = empty_transcript(id);
1403 state.tool_format = None;
1404 state.system_prompt = None;
1405 state.scratchpad = None;
1406 state.scratchpad_version = 0;
1407 state.last_transcript_budget_action = None;
1408 state.completed_turn_checkpoints.clear();
1409 state.redo_stack.clear();
1410 state.text_tool_call_seq = 0;
1411 state.touch();
1412 true
1413 })
1414}
1415
1416pub fn fork(src_id: &str, dst_id: Option<String>) -> Option<String> {
1423 let (
1424 src_transcript,
1425 src_tool_format,
1426 src_system_prompt,
1427 src_pinned_model,
1428 src_pinned_reasoning_policy,
1429 src_actor_chain,
1430 src_workspace_anchor,
1431 src_workspace_policy,
1432 src_scratchpad,
1433 src_scratchpad_version,
1434 src_transcript_budget_policy,
1435 src_last_transcript_budget_action,
1436 src_text_tool_call_seq,
1437 dst,
1438 ) = SESSIONS.with(|s| {
1439 let mut map = s.borrow_mut();
1440 let src = map.get_mut(src_id)?;
1441 src.touch();
1442 let dst = dst_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1443 let forked_transcript = clone_transcript_with_id(&src.transcript, &dst);
1444 Some((
1445 forked_transcript,
1446 src.tool_format.clone(),
1447 src.system_prompt.clone(),
1448 src.pinned_model.clone(),
1449 src.pinned_reasoning_policy.clone(),
1450 src.actor_chain.clone(),
1451 src.workspace_anchor.clone(),
1452 src.workspace_policy.clone(),
1453 src.scratchpad.clone(),
1454 src.scratchpad_version,
1455 src.transcript_budget_policy.clone(),
1456 src.last_transcript_budget_action.clone(),
1457 src.text_tool_call_seq,
1458 dst,
1459 ))
1460 })?;
1461 open_or_create(Some(dst.clone()));
1463 SESSIONS.with(|s| {
1464 let mut map = s.borrow_mut();
1465 if let Some(state) = map.get_mut(&dst) {
1466 state.transcript = src_transcript;
1467 state.tool_format = src_tool_format;
1468 state.system_prompt = src_system_prompt;
1469 state.pinned_model = src_pinned_model;
1470 state.pinned_reasoning_policy = src_pinned_reasoning_policy;
1471 state.actor_chain = src_actor_chain;
1472 state.workspace_anchor = src_workspace_anchor;
1473 state.workspace_policy = src_workspace_policy;
1474 state.scratchpad = src_scratchpad;
1475 state.scratchpad_version = src_scratchpad_version;
1476 state.transcript_budget_policy = src_transcript_budget_policy;
1477 state.last_transcript_budget_action = src_last_transcript_budget_action;
1478 state.text_tool_call_seq = src_text_tool_call_seq;
1479 state.touch();
1480 }
1481 update_lineage(&mut map, src_id, &dst, None);
1482 });
1483 let budget_ok = SESSIONS.with(|s| {
1484 let mut map = s.borrow_mut();
1485 let Some(state) = map.get_mut(&dst) else {
1486 return false;
1487 };
1488 let candidate = state.transcript.clone();
1489 apply_transcript_with_budget(state, candidate, "fork").is_ok()
1490 });
1491 if !budget_ok {
1492 close(&dst);
1493 return None;
1494 }
1495 if exists(&dst) {
1499 Some(dst)
1500 } else {
1501 None
1502 }
1503}
1504
1505pub fn fork_at(src_id: &str, keep_first: usize, dst_id: Option<String>) -> Option<String> {
1516 let branched_at_event_index = SESSIONS.with(|s| {
1517 let map = s.borrow();
1518 let src = map.get(src_id)?;
1519 Some(branch_event_index(&src.transcript, keep_first))
1520 })?;
1521 let new_id = fork(src_id, dst_id)?;
1522 link_child_session_with_branch(src_id, &new_id, Some(branched_at_event_index));
1523 let _ = truncate(&new_id, keep_first);
1524 Some(new_id)
1525}
1526
1527pub fn truncate(id: &str, keep_first: usize) -> Option<SessionTruncateResult> {
1531 SESSIONS.with(|s| {
1532 let mut map = s.borrow_mut();
1533 let state = map.get_mut(id)?;
1534 let result = truncate_state(state, keep_first)?;
1535 Some(result)
1536 })
1537}
1538
1539fn truncate_state(state: &mut SessionState, keep_first: usize) -> Option<SessionTruncateResult> {
1540 let dict = state
1541 .transcript
1542 .as_dict()
1543 .cloned()
1544 .unwrap_or_else(crate::value::DictMap::new);
1545 let messages: Vec<VmValue> = match dict.get("messages") {
1546 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1547 _ => Vec::new(),
1548 };
1549 let existing_events = match dict.get("events") {
1550 Some(VmValue::List(list)) => Some(list.iter().cloned().collect::<Vec<_>>()),
1551 _ => None,
1552 };
1553 let kept_turn_count = keep_first.min(messages.len());
1554 let removed_turn_count = messages.len().saturating_sub(kept_turn_count);
1555 let mut new_tip_turn_id = existing_events
1556 .as_ref()
1557 .map(|events| turn_event_id_for_count(events, kept_turn_count))
1558 .unwrap_or_else(|| {
1559 let events = crate::llm::helpers::transcript_events_from_messages(&messages);
1560 turn_event_id_for_count(&events, kept_turn_count)
1561 });
1562
1563 if removed_turn_count > 0 {
1564 let retained: Vec<VmValue> = messages.into_iter().take(kept_turn_count).collect();
1565 let retained_events = match existing_events {
1566 Some(events) => {
1567 let keep_event_count = event_prefix_len_for_messages(&events, kept_turn_count);
1568 events.into_iter().take(keep_event_count).collect()
1569 }
1570 None => crate::llm::helpers::transcript_events_from_messages(&retained),
1571 };
1572 new_tip_turn_id = turn_event_id_for_count(&retained_events, kept_turn_count);
1573 let mut next = dict;
1574 next.insert(
1575 crate::value::intern_key("events"),
1576 VmValue::List(std::sync::Arc::new(retained_events)),
1577 );
1578 next.insert(
1579 crate::value::intern_key("messages"),
1580 VmValue::List(std::sync::Arc::new(retained)),
1581 );
1582 next.remove("summary");
1583 apply_transcript_with_budget(state, VmValue::dict(next), "truncate").ok()?;
1584 }
1585 state.touch();
1586 Some(SessionTruncateResult {
1587 kept_turn_count,
1588 removed_turn_count,
1589 new_tip_turn_id,
1590 })
1591}
1592
1593pub fn pop_last_if_assistant(id: &str) -> Result<bool, String> {
1600 SESSIONS.with(|s| {
1601 let mut map = s.borrow_mut();
1602 let Some(state) = map.get_mut(id) else {
1603 return Err(format!(
1604 "pop_last_if_assistant: unknown session id '{id}'"
1605 ));
1606 };
1607 let messages: Vec<VmValue> = match state.transcript.as_dict() {
1608 Some(dict) => match dict.get("messages") {
1609 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1610 _ => Vec::new(),
1611 },
1612 None => Vec::new(),
1613 };
1614 if messages.is_empty() {
1615 return Ok(false);
1616 }
1617 let trailing_role = messages
1618 .last()
1619 .and_then(|m| m.as_dict())
1620 .and_then(|d| d.get("role"))
1621 .map(|v| v.display())
1622 .unwrap_or_default();
1623 if trailing_role != "assistant" {
1624 return Err(format!(
1625 "pop_last_if_assistant: trailing message role is '{trailing_role}', expected 'assistant'"
1626 ));
1627 }
1628 let keep = messages.len() - 1;
1629 truncate_state(state, keep);
1630 Ok(true)
1631 })
1632}
1633
1634pub fn trim(id: &str, keep_last: usize) -> Option<usize> {
1635 SESSIONS.with(|s| {
1636 let mut map = s.borrow_mut();
1637 let state = map.get_mut(id)?;
1638 let dict = state.transcript.as_dict()?.clone();
1639 let messages: Vec<VmValue> = match dict.get("messages") {
1640 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1641 _ => Vec::new(),
1642 };
1643 let start = messages.len().saturating_sub(keep_last);
1644 let retained: Vec<VmValue> = messages.into_iter().skip(start).collect();
1645 let kept = retained.len();
1646 let mut next = dict;
1647 next.insert(
1648 crate::value::intern_key("events"),
1649 VmValue::List(std::sync::Arc::new(
1650 crate::llm::helpers::transcript_events_from_messages(&retained),
1651 )),
1652 );
1653 next.insert(
1654 crate::value::intern_key("messages"),
1655 VmValue::List(std::sync::Arc::new(retained)),
1656 );
1657 apply_transcript_with_budget(state, VmValue::dict(next), "trim").ok()?;
1658 Some(kept)
1659 })
1660}
1661
1662pub fn inject_message(id: &str, message: VmValue) -> Result<(), String> {
1665 let Some(msg_dict) = message.as_dict().cloned() else {
1666 return Err("agent_session_inject: message must be a dict".into());
1667 };
1668 let role_ok = matches!(msg_dict.get("role"), Some(VmValue::String(_)));
1669 if !role_ok {
1670 return Err(
1671 "agent_session_inject: message must have a string `role` (user|assistant|tool_result|system)"
1672 .into(),
1673 );
1674 }
1675 SESSIONS.with(|s| {
1676 let mut map = s.borrow_mut();
1677 let Some(state) = map.get_mut(id) else {
1678 return Err(format!("agent_session_inject: unknown session id '{id}'"));
1679 };
1680 let dict = state
1681 .transcript
1682 .as_dict()
1683 .cloned()
1684 .unwrap_or_else(crate::value::DictMap::new);
1685 let mut messages: Vec<VmValue> = match dict.get("messages") {
1686 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1687 _ => Vec::new(),
1688 };
1689 let mut events: Vec<VmValue> = match dict.get("events") {
1690 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1691 _ => crate::llm::helpers::transcript_events_from_messages(&messages),
1692 };
1693 let new_message = VmValue::dict(msg_dict);
1694 let message_index = messages.len();
1695 events.push(crate::llm::helpers::transcript_event_from_message(
1696 &new_message,
1697 ));
1698 messages.push(new_message);
1699 let mut next = dict;
1700 next.insert(
1701 crate::value::intern_key("events"),
1702 VmValue::List(std::sync::Arc::new(events)),
1703 );
1704 next.insert(
1705 crate::value::intern_key("messages"),
1706 VmValue::List(std::sync::Arc::new(messages)),
1707 );
1708 let persisted_message = next
1709 .get("messages")
1710 .and_then(|value| match value {
1711 VmValue::List(list) => list.get(message_index).cloned(),
1712 _ => None,
1713 })
1714 .unwrap_or(VmValue::Nil);
1715 apply_transcript_with_budget(state, VmValue::dict(next), "inject_message")?;
1716 emit_identified_user_message_event(id, &persisted_message);
1717 emit_llm_message_event(id, message_index, &persisted_message);
1718 Ok(())
1719 })
1720}
1721
1722fn emit_identified_user_message_event(session_id: &str, message: &VmValue) {
1723 let message_json = crate::llm::helpers::vm_value_to_json(message);
1724 let role = message_json.get("role").and_then(|value| value.as_str());
1725 if role != Some("user") {
1726 return;
1727 }
1728 let Some(message_id) = message_json
1729 .get("messageId")
1730 .or_else(|| message_json.get("message_id"))
1731 .and_then(|value| value.as_str())
1732 .filter(|value| !value.trim().is_empty())
1733 else {
1734 return;
1735 };
1736 let content = message_json
1737 .get("content")
1738 .map(user_message_content_blocks)
1739 .unwrap_or_default();
1740 crate::agent_events::emit_event(&crate::agent_events::AgentEvent::UserMessage {
1741 session_id: session_id.to_string(),
1742 message_id: message_id.to_string(),
1743 content,
1744 });
1745}
1746
1747fn user_message_content_blocks(content: &serde_json::Value) -> Vec<serde_json::Value> {
1748 match content {
1749 serde_json::Value::Array(items) => items.clone(),
1750 serde_json::Value::String(text) => vec![serde_json::json!({
1751 "type": "text",
1752 "text": text,
1753 })],
1754 other => vec![serde_json::json!({
1755 "type": "text",
1756 "text": other.to_string(),
1757 })],
1758 }
1759}
1760
1761fn emit_llm_message_event(session_id: &str, message_index: usize, message: &VmValue) {
1762 let mut fields = serde_json::Map::new();
1763 fields.insert(
1764 "session_id".to_string(),
1765 serde_json::Value::String(session_id.to_string()),
1766 );
1767 fields.insert(
1768 "message_index".to_string(),
1769 serde_json::json!(message_index),
1770 );
1771 let message_json = crate::llm::helpers::vm_value_to_json(message);
1772 if let Some(role) = message_json.get("role").and_then(|value| value.as_str()) {
1773 fields.insert(
1774 "role".to_string(),
1775 serde_json::Value::String(role.to_string()),
1776 );
1777 }
1778 if let Some(content) = message_json.get("content") {
1779 fields.insert("content".to_string(), content.clone());
1780 }
1781 fields.insert("message".to_string(), message_json);
1782 crate::llm::append_observability_sidecar_entry("message", fields);
1783}
1784
1785pub fn seed_from_messages(
1791 id: Option<String>,
1792 messages: &[serde_json::Value],
1793 metadata: serde_json::Value,
1794 system_prompt: Option<String>,
1795 tool_format: Option<String>,
1796) -> Result<String, String> {
1797 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1798 if exists(&resolved) {
1799 return Err(format!("agent session '{resolved}' already exists"));
1800 }
1801 open_or_create(Some(resolved.clone()));
1802 SESSIONS.with(|s| {
1803 let mut map = s.borrow_mut();
1804 let Some(state) = map.get_mut(&resolved) else {
1805 return Err(format!("failed to create agent session '{resolved}'"));
1806 };
1807 state.tool_format = tool_format.filter(|value| !value.trim().is_empty());
1808 state.system_prompt = system_prompt.filter(|value| !value.trim().is_empty());
1809
1810 let mut metadata = metadata
1811 .as_object()
1812 .cloned()
1813 .unwrap_or_else(serde_json::Map::new);
1814 if let Some(tool_format) = state.tool_format.as_ref() {
1815 metadata.insert(
1816 "tool_format".to_string(),
1817 serde_json::Value::String(tool_format.clone()),
1818 );
1819 metadata.insert(
1820 "tool_mode_locked".to_string(),
1821 serde_json::Value::Bool(true),
1822 );
1823 }
1824 if let Some(system_prompt) = state.system_prompt.as_ref() {
1825 metadata.insert(
1826 "system_prompt".to_string(),
1827 crate::llm::helpers::system_prompt_metadata(system_prompt),
1828 );
1829 }
1830 let text_tool_call_seq = next_text_tool_call_seq_from_json_messages(messages);
1831 let vm_messages = crate::llm::helpers::json_messages_to_vm(messages);
1832 let candidate = crate::llm::helpers::new_transcript_with(
1833 Some(resolved.clone()),
1834 vm_messages,
1835 None,
1836 Some(crate::stdlib::json_to_vm_value(&serde_json::Value::Object(
1837 metadata,
1838 ))),
1839 );
1840 apply_transcript_with_budget(state, candidate, "seed_from_messages")?;
1841 state.text_tool_call_seq = text_tool_call_seq;
1842 Ok(resolved)
1843 })
1844}
1845
1846pub fn messages_json(id: &str) -> Vec<serde_json::Value> {
1850 SESSIONS.with(|s| {
1851 let map = s.borrow();
1852 let Some(state) = map.get(id) else {
1853 return Vec::new();
1854 };
1855 let Some(dict) = state.transcript.as_dict() else {
1856 return Vec::new();
1857 };
1858 match dict.get("messages") {
1859 Some(VmValue::List(list)) => list
1860 .iter()
1861 .map(crate::llm::helpers::vm_value_to_json)
1862 .collect(),
1863 _ => Vec::new(),
1864 }
1865 })
1866}
1867
1868#[derive(Clone, Debug, Default)]
1869pub struct SessionPromptState {
1870 pub messages: Vec<serde_json::Value>,
1871 pub summary: Option<String>,
1872}
1873
1874fn summary_message_json(summary: &str) -> serde_json::Value {
1875 serde_json::json!({
1876 "role": "user",
1877 "content": summary,
1878 })
1879}
1880
1881fn messages_begin_with_summary(messages: &[serde_json::Value], summary: &str) -> bool {
1882 messages.first().is_some_and(|message| {
1883 message.get("role").and_then(|value| value.as_str()) == Some("user")
1884 && message.get("content").and_then(|value| value.as_str()) == Some(summary)
1885 })
1886}
1887
1888pub fn prompt_state_json(id: &str) -> SessionPromptState {
1896 SESSIONS.with(|s| {
1897 let map = s.borrow();
1898 let Some(state) = map.get(id) else {
1899 return SessionPromptState::default();
1900 };
1901 let Some(dict) = state.transcript.as_dict() else {
1902 return SessionPromptState::default();
1903 };
1904 let mut messages = match dict.get("messages") {
1905 Some(VmValue::List(list)) => list
1906 .iter()
1907 .map(crate::llm::helpers::vm_value_to_json)
1908 .collect::<Vec<_>>(),
1909 _ => Vec::new(),
1910 };
1911 let summary = dict.get("summary").and_then(|value| match value {
1912 VmValue::String(text) if !text.trim().is_empty() => Some(text.to_string()),
1913 _ => None,
1914 });
1915 if let Some(summary_text) = summary.as_deref() {
1916 if !messages_begin_with_summary(&messages, summary_text) {
1917 messages.insert(0, summary_message_json(summary_text));
1918 }
1919 }
1920 SessionPromptState { messages, summary }
1921 })
1922}
1923
1924pub fn store_transcript(id: &str, transcript: VmValue) -> Result<(), String> {
1927 SESSIONS.with(|s| {
1928 let mut map = s.borrow_mut();
1929 let Some(state) = map.get_mut(id) else {
1930 return Err(format!(
1931 "agent_session_store_transcript: unknown session id '{id}'"
1932 ));
1933 };
1934 let transcript = transcript_with_session_metadata(transcript, state);
1935 let text_tool_call_seq = next_text_tool_call_seq_from_transcript(&transcript);
1936 apply_transcript_with_budget(state, transcript, "store_transcript")?;
1937 state.text_tool_call_seq = state.text_tool_call_seq.max(text_tool_call_seq);
1938 Ok(())
1939 })
1940}
1941
1942fn checkpoint_summary(checkpoint: &SessionTurnCheckpoint) -> SessionCheckpointSummary {
1943 SessionCheckpointSummary {
1944 checkpoint_id: checkpoint.checkpoint_id.clone(),
1945 before_message_count: checkpoint.before_message_count,
1946 after_message_count: checkpoint.after_message_count,
1947 fs_snapshot_ids: checkpoint.fs_snapshot_ids.clone(),
1948 }
1949}
1950
1951fn checkpoint_error_status(error: SessionCheckpointError) -> &'static str {
1952 match error {
1953 SessionCheckpointError::UnknownSession => "unknown_session",
1954 SessionCheckpointError::NoCheckpoint => "no_checkpoint",
1955 SessionCheckpointError::NoRedo => "no_redo",
1956 }
1957}
1958
1959pub fn checkpoint_status_name(error: SessionCheckpointError) -> &'static str {
1960 checkpoint_error_status(error)
1961}
1962
1963pub fn invalidate_redo(id: &str) -> bool {
1966 SESSIONS.with(|s| {
1967 let mut map = s.borrow_mut();
1968 let Some(state) = map.get_mut(id) else {
1969 return false;
1970 };
1971 let had_redo = !state.redo_stack.is_empty();
1972 state.redo_stack.clear();
1973 state.touch();
1974 had_redo
1975 })
1976}
1977
1978pub fn record_completed_turn_checkpoint(
1985 id: &str,
1986 before_transcript: VmValue,
1987 fs_snapshot_ids: Vec<String>,
1988) -> Result<Option<SessionCheckpointSummary>, SessionCheckpointError> {
1989 SESSIONS.with(|s| {
1990 let mut map = s.borrow_mut();
1991 let Some(state) = map.get_mut(id) else {
1992 return Err(SessionCheckpointError::UnknownSession);
1993 };
1994 let after_transcript = transcript_with_session_metadata(state.transcript.clone(), state);
1995 let before_message_count = transcript_message_count(&before_transcript);
1996 let after_message_count = transcript_message_count(&after_transcript);
1997 if crate::values_equal(&before_transcript, &after_transcript) && fs_snapshot_ids.is_empty()
1998 {
1999 return Ok(None);
2000 }
2001 let checkpoint = SessionTurnCheckpoint {
2002 checkpoint_id: format!("turn_{}", uuid::Uuid::now_v7().simple()),
2003 completed_at: crate::orchestration::now_rfc3339(),
2004 before_message_count,
2005 after_message_count,
2006 before_transcript,
2007 after_transcript,
2008 fs_snapshot_ids,
2009 };
2010 state.redo_stack.clear();
2011 state.completed_turn_checkpoints.push(checkpoint.clone());
2012 state.touch();
2013 Ok(Some(checkpoint_summary(&checkpoint)))
2014 })
2015}
2016
2017pub fn rollback_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2018 SESSIONS.with(|s| {
2019 let map = s.borrow();
2020 let Some(state) = map.get(id) else {
2021 return Err(SessionCheckpointError::UnknownSession);
2022 };
2023 state
2024 .completed_turn_checkpoints
2025 .last()
2026 .map(checkpoint_summary)
2027 .ok_or(SessionCheckpointError::NoCheckpoint)
2028 })
2029}
2030
2031pub fn redo_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2032 SESSIONS.with(|s| {
2033 let map = s.borrow();
2034 let Some(state) = map.get(id) else {
2035 return Err(SessionCheckpointError::UnknownSession);
2036 };
2037 state
2038 .redo_stack
2039 .last()
2040 .map(|entry| {
2041 let mut summary = checkpoint_summary(&entry.checkpoint);
2042 summary.fs_snapshot_ids = entry.redo_fs_snapshot_ids.clone();
2043 summary
2044 })
2045 .ok_or(SessionCheckpointError::NoRedo)
2046 })
2047}
2048
2049pub fn rollback_last_completed_turn(
2050 id: &str,
2051 redo_fs_snapshot_ids: Vec<String>,
2052) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2053 SESSIONS.with(|s| {
2054 let mut map = s.borrow_mut();
2055 let Some(state) = map.get_mut(id) else {
2056 return Err(SessionCheckpointError::UnknownSession);
2057 };
2058 let Some(checkpoint) = state.completed_turn_checkpoints.pop() else {
2059 return Err(SessionCheckpointError::NoCheckpoint);
2060 };
2061 state.transcript = checkpoint.before_transcript.clone();
2062 state.redo_stack.push(SessionRedoEntry {
2063 checkpoint: checkpoint.clone(),
2064 redo_fs_snapshot_ids: redo_fs_snapshot_ids.clone(),
2065 });
2066 state.touch();
2067 Ok(SessionCheckpointOutcome {
2068 status: "rolled_back",
2069 checkpoint: checkpoint_summary(&checkpoint),
2070 redo_fs_snapshot_ids,
2071 })
2072 })
2073}
2074
2075pub fn redo_last_rollback(id: &str) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2076 SESSIONS.with(|s| {
2077 let mut map = s.borrow_mut();
2078 let Some(state) = map.get_mut(id) else {
2079 return Err(SessionCheckpointError::UnknownSession);
2080 };
2081 let Some(entry) = state.redo_stack.pop() else {
2082 return Err(SessionCheckpointError::NoRedo);
2083 };
2084 let checkpoint = entry.checkpoint;
2085 state.transcript = checkpoint.after_transcript.clone();
2086 state.completed_turn_checkpoints.push(checkpoint.clone());
2087 state.touch();
2088 Ok(SessionCheckpointOutcome {
2089 status: "redone",
2090 checkpoint: checkpoint_summary(&checkpoint),
2091 redo_fs_snapshot_ids: entry.redo_fs_snapshot_ids,
2092 })
2093 })
2094}
2095
2096pub fn prune_invalid_reminder_events(id: &str) -> usize {
2100 SESSIONS.with(|s| {
2101 let mut map = s.borrow_mut();
2102 let Some(state) = map.get_mut(id) else {
2103 return 0;
2104 };
2105 let Some(dict) = state.transcript.as_dict().cloned() else {
2106 return 0;
2107 };
2108 let Some(VmValue::List(events)) = dict.get("events") else {
2109 return 0;
2110 };
2111 let mut pruned = 0_usize;
2112 let mut kept = Vec::with_capacity(events.len());
2113 for event in events.iter().cloned() {
2114 let is_reminder = event
2115 .as_dict()
2116 .and_then(|event| event.get("kind"))
2117 .map(VmValue::display)
2118 .as_deref()
2119 == Some(crate::llm::helpers::SYSTEM_REMINDER_EVENT_KIND);
2120 if !is_reminder {
2121 kept.push(event);
2122 continue;
2123 }
2124 let valid = crate::llm::helpers::reminder_from_event(&event)
2125 .is_some_and(|reminder| !reminder.body.trim().is_empty());
2126 if valid {
2127 kept.push(event);
2128 } else {
2129 pruned += 1;
2130 }
2131 }
2132 if pruned > 0 {
2133 let mut next = dict;
2134 next.insert(
2135 crate::value::intern_key("events"),
2136 VmValue::List(std::sync::Arc::new(kept)),
2137 );
2138 let _ = apply_transcript_with_budget(
2139 state,
2140 VmValue::dict(next),
2141 "prune_invalid_reminder_events",
2142 );
2143 state.touch();
2144 }
2145 pruned
2146 })
2147}
2148
2149pub fn apply_reminder_post_turn(id: &str, turn: i64) -> Result<serde_json::Value, String> {
2154 let report = SESSIONS.with(|s| {
2155 let mut map = s.borrow_mut();
2156 let Some(state) = map.get_mut(id) else {
2157 return Err(format!(
2158 "agent_session_apply_reminder_post_turn: unknown session id '{id}'"
2159 ));
2160 };
2161 let report = crate::llm::helpers::apply_reminder_post_turn(&state.transcript, turn);
2162 if report.decremented_count > 0 || !report.expired.is_empty() {
2163 if let Some(next) = report.transcript.clone() {
2164 apply_transcript_with_budget(state, next, "apply_reminder_post_turn")?;
2165 }
2166 state.touch();
2167 }
2168 Ok(report)
2169 })?;
2170
2171 for reminder in &report.expired {
2172 let mut payload = crate::llm::helpers::reminder_lifecycle_payload(Some(id), reminder);
2173 if let Some(obj) = payload.as_object_mut() {
2174 obj.insert(
2175 "transcript_id".to_string(),
2176 serde_json::Value::String(id.to_string()),
2177 );
2178 obj.insert(
2179 "reason".to_string(),
2180 serde_json::Value::String("ttl".to_string()),
2181 );
2182 obj.insert(
2183 "ttl_turns_before".to_string(),
2184 serde_json::json!(&reminder.ttl_turns),
2185 );
2186 obj.insert("expired_at_turn".to_string(), serde_json::json!(turn));
2187 }
2188 crate::llm::helpers::emit_reminder_lifecycle_event(
2189 crate::llm::helpers::REMINDER_EXPIRED_EVENT_KIND,
2190 payload,
2191 );
2192 }
2193
2194 Ok(serde_json::json!({
2195 "expired_count": report.expired.len(),
2196 "decremented_count": report.decremented_count,
2197 "remaining_count": report.remaining_count,
2198 }))
2199}
2200
2201pub fn inject_reminder(
2206 id: &str,
2207 reminder: crate::llm::helpers::SystemReminder,
2208) -> Result<ReminderInjectionReport, String> {
2209 let reminder_id = reminder.id.clone();
2210 let dedupe_key = reminder.dedupe_key.clone();
2211 let mut deduped_reminder_ids = Vec::new();
2212 SESSIONS.with(|s| {
2213 let mut map = s.borrow_mut();
2214 let Some(state) = map.get_mut(id) else {
2215 return Err(format!(
2216 "agent_session_inject_reminder: unknown session id '{id}'"
2217 ));
2218 };
2219 let dict = state
2220 .transcript
2221 .as_dict()
2222 .cloned()
2223 .unwrap_or_else(crate::value::DictMap::new);
2224 let mut events: Vec<VmValue> = match dict.get("events") {
2225 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2226 _ => dict
2227 .get("messages")
2228 .and_then(|value| match value {
2229 VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
2230 _ => None,
2231 })
2232 .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
2233 .unwrap_or_default(),
2234 };
2235 if let Some(expected_key) = dedupe_key.as_deref() {
2236 events.retain(|event| {
2237 let Some(existing) = crate::llm::helpers::reminder_from_event(event) else {
2238 return true;
2239 };
2240 if existing.dedupe_key.as_deref() == Some(expected_key) {
2241 deduped_reminder_ids.push(existing.id);
2242 false
2243 } else {
2244 true
2245 }
2246 });
2247 }
2248 events.push(crate::llm::helpers::transcript_reminder_event(&reminder));
2249 let mut next = dict;
2250 next.insert(
2251 crate::value::intern_key("events"),
2252 VmValue::List(std::sync::Arc::new(events)),
2253 );
2254 apply_transcript_with_budget(state, VmValue::dict(next), "inject_reminder")?;
2255 state.touch();
2256 Ok(())
2257 })?;
2258
2259 if !deduped_reminder_ids.is_empty() {
2260 let dropped_count = deduped_reminder_ids.len();
2261 crate::llm::helpers::emit_reminder_lifecycle_event(
2262 crate::llm::helpers::REMINDER_DEDUPED_EVENT_KIND,
2263 serde_json::json!({
2264 "session_id": id,
2265 "transcript_id": id,
2266 "reminder_id": &reminder_id,
2267 "replacing_id": &reminder_id,
2268 "replaced_id": deduped_reminder_ids.first(),
2269 "replaced_ids": &deduped_reminder_ids,
2270 "dedupe_key": &dedupe_key,
2271 "dropped_reminder_ids": &deduped_reminder_ids,
2272 "dropped_count": dropped_count,
2273 }),
2274 );
2275 }
2276
2277 crate::llm::helpers::emit_reminder_lifecycle_event(
2278 crate::llm::helpers::REMINDER_INJECTED_EVENT_KIND,
2279 crate::llm::helpers::reminder_lifecycle_payload(Some(id), &reminder),
2280 );
2281
2282 Ok(ReminderInjectionReport {
2283 reminder_id,
2284 deduped_count: deduped_reminder_ids.len(),
2285 })
2286}
2287
2288pub fn append_event(id: &str, event: VmValue) -> Result<(), String> {
2294 let Some(event_dict) = event.as_dict() else {
2295 return Err("agent_session_append_event: event must be a dict".into());
2296 };
2297 let kind_ok = matches!(event_dict.get("kind"), Some(VmValue::String(_)));
2298 if !kind_ok {
2299 return Err("agent_session_append_event: event must have a string `kind`".into());
2300 }
2301 SESSIONS.with(|s| {
2302 let mut map = s.borrow_mut();
2303 let Some(state) = map.get_mut(id) else {
2304 return Err(format!(
2305 "agent_session_append_event: unknown session id '{id}'"
2306 ));
2307 };
2308 append_event_to_state(state, event, "append_event")?;
2309 Ok(())
2310 })
2311}
2312
2313fn append_event_to_state(
2314 state: &mut SessionState,
2315 event: VmValue,
2316 action: &str,
2317) -> Result<(), String> {
2318 let dict = state
2319 .transcript
2320 .as_dict()
2321 .cloned()
2322 .unwrap_or_else(crate::value::DictMap::new);
2323 let mut events: Vec<VmValue> = match dict.get("events") {
2324 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2325 _ => dict
2326 .get("messages")
2327 .and_then(|value| match value {
2328 VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
2329 _ => None,
2330 })
2331 .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
2332 .unwrap_or_default(),
2333 };
2334 events.push(event);
2335 let mut next = dict;
2336 next.insert(
2337 crate::value::intern_key("events"),
2338 VmValue::List(std::sync::Arc::new(events)),
2339 );
2340 apply_transcript_with_budget(state, VmValue::dict(next), action)
2341}
2342
2343pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
2346 replace_messages_with_summary(id, messages, None)
2347}
2348
2349pub fn replace_messages_with_summary(
2354 id: &str,
2355 messages: &[serde_json::Value],
2356 summary: Option<&str>,
2357) -> Result<(), String> {
2358 SESSIONS.with(|s| {
2359 let mut map = s.borrow_mut();
2360 let Some(state) = map.get_mut(id) else {
2361 return Err(format!(
2362 "agent_session_replace_messages: unknown session id '{id}'"
2363 ));
2364 };
2365 let dict = state
2366 .transcript
2367 .as_dict()
2368 .cloned()
2369 .unwrap_or_else(crate::value::DictMap::new);
2370 let vm_messages: Vec<VmValue> = messages
2371 .iter()
2372 .map(crate::stdlib::json_to_vm_value)
2373 .collect();
2374 let mut next = dict;
2375 next.insert(
2376 crate::value::intern_key("events"),
2377 VmValue::List(std::sync::Arc::new(
2378 crate::llm::helpers::transcript_events_from_messages(&vm_messages),
2379 )),
2380 );
2381 next.insert(
2382 crate::value::intern_key("messages"),
2383 VmValue::List(std::sync::Arc::new(vm_messages)),
2384 );
2385 if let Some(summary) = summary {
2386 next.put_str("summary", summary);
2387 } else {
2388 next.remove("summary");
2389 }
2390 apply_transcript_with_budget(state, VmValue::dict(next), "replace_messages")?;
2391 Ok(())
2392 })
2393}
2394
2395pub fn append_subscriber(id: &str, callback: VmValue) {
2396 open_or_create(Some(id.to_string()));
2397 SESSIONS.with(|s| {
2398 if let Some(state) = s.borrow_mut().get_mut(id) {
2399 state.subscribers.push(callback);
2400 state.touch();
2401 }
2402 });
2403}
2404
2405pub fn subscribers_for(id: &str) -> Vec<VmValue> {
2406 SESSIONS.with(|s| {
2407 s.borrow()
2408 .get(id)
2409 .map(|state| state.subscribers.clone())
2410 .unwrap_or_default()
2411 })
2412}
2413
2414pub fn subscriber_count(id: &str) -> usize {
2415 SESSIONS.with(|s| {
2416 s.borrow()
2417 .get(id)
2418 .map(|state| state.subscribers.len())
2419 .unwrap_or(0)
2420 })
2421}
2422
2423pub fn set_active_skills(id: &str, skills: Vec<String>) {
2427 SESSIONS.with(|s| {
2428 if let Some(state) = s.borrow_mut().get_mut(id) {
2429 state.active_skills = skills;
2430 state.touch();
2431 }
2432 });
2433}
2434
2435pub fn active_skills(id: &str) -> Vec<String> {
2439 SESSIONS.with(|s| {
2440 s.borrow()
2441 .get(id)
2442 .map(|state| state.active_skills.clone())
2443 .unwrap_or_default()
2444 })
2445}
2446
2447pub fn claim_tool_format(id: &str, tool_format: &str) -> Result<(), String> {
2453 let tool_format = tool_format.trim();
2454 if tool_format.is_empty() {
2455 return Ok(());
2456 }
2457 SESSIONS.with(|s| {
2458 let mut map = s.borrow_mut();
2459 let Some(state) = map.get_mut(id) else {
2460 return Err(format!("agent session '{id}' does not exist"));
2461 };
2462 match state.tool_format.as_deref() {
2463 Some(existing) if existing != tool_format => Err(format!(
2464 "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."
2465 )),
2466 Some(_) => {
2467 state.touch();
2468 Ok(())
2469 }
2470 None => {
2471 state.tool_format = Some(tool_format.to_string());
2472 state.touch();
2473 Ok(())
2474 }
2475 }
2476 })
2477}
2478
2479pub fn tool_format(id: &str) -> Option<String> {
2480 SESSIONS.with(|s| {
2481 s.borrow()
2482 .get(id)
2483 .and_then(|state| state.tool_format.clone())
2484 })
2485}
2486
2487pub fn record_system_prompt(id: &str, system_prompt: &str) -> Result<(), String> {
2488 let system_prompt = system_prompt.trim();
2489 if system_prompt.is_empty() {
2490 return Ok(());
2491 }
2492 assert_cache_stable_system_prompt(system_prompt);
2493 SESSIONS.with(|s| {
2494 let mut map = s.borrow_mut();
2495 let Some(state) = map.get_mut(id) else {
2496 return Err(format!("agent session '{id}' does not exist"));
2497 };
2498 let changed = state.system_prompt.as_deref() != Some(system_prompt);
2499 state.system_prompt = Some(system_prompt.to_string());
2500 let dict = state
2501 .transcript
2502 .as_dict()
2503 .cloned()
2504 .unwrap_or_else(crate::value::DictMap::new);
2505 let mut next = dict;
2506 apply_system_prompt_metadata(&mut next, system_prompt);
2507 if changed {
2508 let mut events: Vec<VmValue> = match next.get("events") {
2509 Some(VmValue::List(list)) => list.iter().cloned().collect(),
2510 _ => Vec::new(),
2511 };
2512 events.push(crate::llm::helpers::transcript_event(
2513 "system_prompt",
2514 "system",
2515 "internal",
2516 "",
2517 Some(crate::llm::helpers::system_prompt_event_metadata(
2518 system_prompt,
2519 )),
2520 ));
2521 next.insert(
2522 crate::value::intern_key("events"),
2523 VmValue::List(std::sync::Arc::new(events)),
2524 );
2525 }
2526 apply_transcript_with_budget(state, VmValue::dict(next), "record_system_prompt")?;
2527 Ok(())
2528 })
2529}
2530
2531pub fn system_prompt(id: &str) -> Option<String> {
2532 SESSIONS.with(|s| {
2533 s.borrow()
2534 .get(id)
2535 .and_then(|state| state.system_prompt.clone())
2536 })
2537}
2538
2539#[cfg(debug_assertions)]
2540fn forbidden_workspace_prompt_token(system_prompt: &str) -> Option<&'static str> {
2541 let mut remaining = system_prompt;
2542 while let Some(index) = remaining.find("{{") {
2543 let candidate = remaining[index + 2..].trim_start();
2544 if candidate.starts_with("workspace_") {
2545 return Some("workspace_");
2546 }
2547 if candidate.starts_with("project_") {
2548 return Some("project_");
2549 }
2550 remaining = candidate;
2551 }
2552 None
2553}
2554
2555#[cfg(debug_assertions)]
2556fn assert_cache_stable_system_prompt(system_prompt: &str) {
2557 if let Some(prefix) = forbidden_workspace_prompt_token(system_prompt) {
2558 panic!(
2559 "{CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC}: session system prompts must not interpolate `{{{{{prefix}...` tokens; move workspace/project context into the workspace-anchor reminder"
2560 );
2561 }
2562}
2563
2564#[cfg(not(debug_assertions))]
2565fn assert_cache_stable_system_prompt(_system_prompt: &str) {}
2566
2567pub fn set_pinned_model(id: &str, model: Option<String>) -> Result<bool, String> {
2572 let normalized = model
2573 .map(|value| value.trim().to_string())
2574 .filter(|value| !value.is_empty());
2575 SESSIONS.with(|s| {
2576 let mut map = s.borrow_mut();
2577 let Some(state) = map.get_mut(id) else {
2578 return Err(format!("agent session '{id}' does not exist"));
2579 };
2580 let changed = state.pinned_model != normalized;
2581 state.pinned_model = normalized;
2582 state.touch();
2583 Ok(changed)
2584 })
2585}
2586
2587pub fn pinned_model(id: &str) -> Option<String> {
2591 SESSIONS.with(|s| {
2592 s.borrow()
2593 .get(id)
2594 .and_then(|state| state.pinned_model.clone())
2595 })
2596}
2597
2598pub fn set_pinned_reasoning_policy(id: &str, policy: Option<String>) -> Result<bool, String> {
2600 let normalized = match policy {
2601 Some(value) => crate::llm::reasoning_policy::normalize_policy_selector(&value)?,
2602 None => None,
2603 };
2604 SESSIONS.with(|s| {
2605 let mut map = s.borrow_mut();
2606 let Some(state) = map.get_mut(id) else {
2607 return Err(format!("agent session '{id}' does not exist"));
2608 };
2609 let changed = state.pinned_reasoning_policy != normalized;
2610 state.pinned_reasoning_policy = normalized;
2611 state.touch();
2612 Ok(changed)
2613 })
2614}
2615
2616pub fn pinned_reasoning_policy(id: &str) -> Option<String> {
2618 SESSIONS.with(|s| {
2619 s.borrow()
2620 .get(id)
2621 .and_then(|state| state.pinned_reasoning_policy.clone())
2622 })
2623}
2624
2625pub fn set_workspace_anchor(id: &str, anchor: Option<WorkspaceAnchor>) -> Result<bool, String> {
2629 SESSIONS.with(|s| {
2630 let mut map = s.borrow_mut();
2631 let Some(state) = map.get_mut(id) else {
2632 return Err(format!("agent session '{id}' does not exist"));
2633 };
2634 let changed = state.workspace_anchor != anchor;
2635 state.workspace_anchor = anchor;
2636 if changed {
2637 state.redo_stack.clear();
2638 crate::llm::permissions::clear_session_grants(id);
2639 }
2640 state.touch();
2641 Ok(changed)
2642 })
2643}
2644
2645pub fn workspace_anchor(id: &str) -> Option<WorkspaceAnchor> {
2647 SESSIONS.with(|s| {
2648 s.borrow()
2649 .get(id)
2650 .and_then(|state| state.workspace_anchor.clone())
2651 })
2652}
2653
2654#[derive(Clone, Debug, PartialEq, Eq)]
2658pub struct ReanchorOutcome {
2659 pub previous: Option<WorkspaceAnchor>,
2660 pub current: WorkspaceAnchor,
2661 pub changed: bool,
2662}
2663
2664pub fn reanchor_session(
2669 id: &str,
2670 new_anchor: WorkspaceAnchor,
2671 carry_transcript: bool,
2672 compacted: bool,
2673 reason: Option<String>,
2674) -> Result<ReanchorOutcome, String> {
2675 let outcome = SESSIONS.with(|s| {
2676 let mut map = s.borrow_mut();
2677 let Some(state) = map.get_mut(id) else {
2678 return Err(format!("agent session '{id}' does not exist"));
2679 };
2680 let previous = state.workspace_anchor.clone();
2681 let changed = previous.as_ref() != Some(&new_anchor);
2682 state.workspace_anchor = Some(new_anchor.clone());
2683 if changed {
2684 crate::llm::permissions::clear_session_grants(id);
2685 }
2686 state.touch();
2687 Ok(ReanchorOutcome {
2688 previous,
2689 current: new_anchor,
2690 changed,
2691 })
2692 })?;
2693 if !outcome.changed {
2694 return Ok(outcome);
2695 }
2696 let previous_json = outcome.previous.as_ref().map(WorkspaceAnchor::to_json);
2697 let current_json = outcome.current.to_json();
2698 let event_metadata = serde_json::json!({
2699 "previous": previous_json,
2700 "current": current_json,
2701 "carry_transcript": carry_transcript,
2702 "compacted": compacted,
2703 "reason": reason,
2704 });
2705 let event = crate::llm::helpers::transcript_event(
2706 "AnchorChanged",
2707 "system",
2708 "internal",
2709 "",
2710 Some(event_metadata),
2711 );
2712 let _ = append_event(id, event);
2713 crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::AnchorChanged {
2714 session_id: id.to_string(),
2715 previous: previous_json,
2716 current: current_json,
2717 carry_transcript,
2718 compacted,
2719 reason,
2720 });
2721 Ok(outcome)
2722}
2723
2724pub fn set_workspace_policy(id: &str, policy: WorkspacePolicy) -> Result<bool, String> {
2727 SESSIONS.with(|s| {
2728 let mut map = s.borrow_mut();
2729 let Some(state) = map.get_mut(id) else {
2730 return Err(format!("agent session '{id}' does not exist"));
2731 };
2732 let changed = state.workspace_policy != policy;
2733 state.workspace_policy = policy;
2734 if changed {
2735 state.redo_stack.clear();
2736 }
2737 state.touch();
2738 Ok(changed)
2739 })
2740}
2741
2742pub fn workspace_policy(id: &str) -> Option<WorkspacePolicy> {
2744 SESSIONS.with(|s| {
2745 s.borrow()
2746 .get(id)
2747 .map(|state| state.workspace_policy.clone())
2748 })
2749}
2750
2751pub fn add_workspace_root(
2755 id: &str,
2756 root: &str,
2757 mount_mode: Option<MountMode>,
2758 reason: Option<String>,
2759) -> Result<String, String> {
2760 let normalized_root = validate_workspace_root_path(root)?;
2761 let mounted_at = crate::orchestration::now_rfc3339();
2762 SESSIONS.with(|s| {
2763 let mut map = s.borrow_mut();
2764 let Some(state) = map.get_mut(id) else {
2765 return Err(format!("agent session '{id}' does not exist"));
2766 };
2767 let default_mount_mode = state.workspace_policy.default_mount_mode;
2768 let Some(anchor) = state.workspace_anchor.as_mut() else {
2769 return Err(format!("agent session '{id}' has no workspace anchor"));
2770 };
2771 let resolved_mount_mode = mount_mode.unwrap_or(default_mount_mode);
2772 if let Some(existing) = anchor
2773 .additional_roots
2774 .iter_mut()
2775 .find(|entry| entry.path == normalized_root)
2776 {
2777 let changed =
2778 existing.mount_mode != resolved_mount_mode || existing.mounted_at != mounted_at;
2779 existing.mount_mode = resolved_mount_mode;
2780 existing.mounted_at = mounted_at.clone();
2781 if changed {
2782 state.redo_stack.clear();
2783 }
2784 } else {
2785 anchor.additional_roots.push(MountedRoot {
2786 path: normalized_root.clone(),
2787 mount_mode: resolved_mount_mode,
2788 mounted_at: mounted_at.clone(),
2789 });
2790 state.redo_stack.clear();
2791 }
2792 let event = crate::llm::helpers::transcript_event(
2793 "RootMounted",
2794 "system",
2795 "internal",
2796 "",
2797 Some(serde_json::json!({
2798 "path": normalized_root.to_string_lossy(),
2799 "mount_mode": resolved_mount_mode.as_str(),
2800 "mounted_at": mounted_at.clone(),
2801 "reason": reason,
2802 })),
2803 );
2804 append_event_to_state(state, event, "add_workspace_root")?;
2805 crate::llm::permissions::clear_session_grants(id);
2806 state.touch();
2807 Ok(mounted_at.clone())
2808 })
2809}
2810
2811pub fn remove_workspace_root(id: &str, root: &str) -> Result<bool, String> {
2814 let normalized_root = normalize_workspace_root_path(root);
2815 SESSIONS.with(|s| {
2816 let mut map = s.borrow_mut();
2817 let Some(state) = map.get_mut(id) else {
2818 return Err(format!("agent session '{id}' does not exist"));
2819 };
2820 let Some(anchor) = state.workspace_anchor.as_mut() else {
2821 return Err(format!("agent session '{id}' has no workspace anchor"));
2822 };
2823 let before = anchor.additional_roots.len();
2824 anchor
2825 .additional_roots
2826 .retain(|entry| entry.path != normalized_root);
2827 let removed = anchor.additional_roots.len() != before;
2828 if removed {
2829 state.redo_stack.clear();
2830 crate::llm::permissions::clear_session_grants(id);
2831 }
2832 state.touch();
2833 Ok(removed)
2834 })
2835}
2836
2837pub fn list_workspace_roots(id: &str) -> Result<(PathBuf, Vec<MountedRoot>), String> {
2838 SESSIONS.with(|s| {
2839 let map = s.borrow();
2840 let Some(state) = map.get(id) else {
2841 return Err(format!("agent session '{id}' does not exist"));
2842 };
2843 let Some(anchor) = state.workspace_anchor.as_ref() else {
2844 return Err(format!("agent session '{id}' has no workspace anchor"));
2845 };
2846 Ok((anchor.primary.clone(), anchor.additional_roots.clone()))
2847 })
2848}
2849
2850fn validate_workspace_root_path(root: &str) -> Result<PathBuf, String> {
2851 let normalized = normalize_workspace_root_path(root);
2852 let canonical = std::fs::canonicalize(&normalized)
2853 .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
2854 let metadata = std::fs::metadata(&canonical)
2855 .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
2856 if !metadata.is_dir() {
2857 return Err(format!("workspace root '{root}' must be a directory"));
2858 }
2859 std::fs::read_dir(&canonical)
2860 .map_err(|error| format!("workspace root '{root}' must be readable: {error}"))?;
2861 Ok(canonical)
2862}
2863
2864fn normalize_workspace_root_path(root: &str) -> PathBuf {
2865 let absolute = crate::stdlib::process::normalize_context_path(Path::new(root));
2866 std::fs::canonicalize(&absolute).unwrap_or(absolute)
2867}
2868
2869fn empty_transcript(id: &str) -> VmValue {
2870 use crate::llm::helpers::new_transcript_with;
2871 new_transcript_with(Some(id.to_string()), Vec::new(), None, None)
2872}
2873
2874fn clone_transcript_with_id(transcript: &VmValue, new_id: &str) -> VmValue {
2875 let Some(dict) = transcript.as_dict() else {
2876 return empty_transcript(new_id);
2877 };
2878 let mut next = dict.clone();
2879 next.put_str("id", new_id);
2880 VmValue::dict(next)
2881}
2882
2883fn clone_transcript_with_parent(transcript: &VmValue, parent_id: &str) -> VmValue {
2884 let Some(dict) = transcript.as_dict() else {
2885 return transcript.clone();
2886 };
2887 let mut next = dict.clone();
2888 let metadata = match next.get("metadata") {
2889 Some(VmValue::Dict(metadata)) => {
2890 let mut metadata = metadata.as_ref().clone();
2891 metadata.put_str("parent_session_id", parent_id);
2892 VmValue::dict(metadata)
2893 }
2894 _ => VmValue::dict(BTreeMap::from([(
2895 "parent_session_id".to_string(),
2896 VmValue::String(arcstr::ArcStr::from(parent_id.to_string())),
2897 )])),
2898 };
2899 next.insert(crate::value::intern_key("metadata"), metadata);
2900 VmValue::dict(next)
2901}
2902
2903fn apply_system_prompt_metadata(next: &mut crate::value::DictMap, system_prompt: &str) {
2904 let mut metadata = match next.get("metadata") {
2905 Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
2906 _ => crate::value::DictMap::new(),
2907 };
2908 metadata.insert(
2909 crate::value::intern_key("system_prompt"),
2910 crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
2911 system_prompt,
2912 )),
2913 );
2914 next.insert(
2915 crate::value::intern_key("metadata"),
2916 VmValue::dict(metadata),
2917 );
2918}
2919
2920fn transcript_with_session_metadata(transcript: VmValue, state: &SessionState) -> VmValue {
2921 let Some(dict) = transcript.as_dict() else {
2922 return transcript;
2923 };
2924 let mut next = dict.clone();
2925 let mut metadata = match next.get("metadata") {
2926 Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
2927 _ => crate::value::DictMap::new(),
2928 };
2929 if let Some(tool_format) = state.tool_format.as_ref() {
2930 metadata.put_str("tool_format", tool_format.clone());
2931 metadata.insert(
2932 crate::value::intern_key("tool_mode_locked"),
2933 VmValue::Bool(true),
2934 );
2935 }
2936 if let Some(system_prompt) = state.system_prompt.as_ref() {
2937 metadata.insert(
2938 crate::value::intern_key("system_prompt"),
2939 crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
2940 system_prompt,
2941 )),
2942 );
2943 }
2944 if let Some(actor_chain) = state.actor_chain.as_ref() {
2945 metadata.insert(
2946 crate::value::intern_key("actor_chain"),
2947 actor_chain.to_vm_value(),
2948 );
2949 } else {
2950 metadata.remove("actor_chain");
2951 }
2952 if let Some(anchor) = state.workspace_anchor.as_ref() {
2953 metadata.insert(
2954 crate::value::intern_key(WORKSPACE_ANCHOR_METADATA_KEY),
2955 anchor.to_vm_value(),
2956 );
2957 } else {
2958 metadata.remove(WORKSPACE_ANCHOR_METADATA_KEY);
2959 }
2960 if let Some(scratchpad) = state.scratchpad.as_ref() {
2961 metadata.insert(
2962 crate::value::intern_key("agent_scratchpad"),
2963 scratchpad.clone(),
2964 );
2965 metadata.insert(
2966 crate::value::intern_key("agent_scratchpad_version"),
2967 VmValue::Int(state.scratchpad_version as i64),
2968 );
2969 } else {
2970 metadata.remove("agent_scratchpad");
2971 metadata.remove("agent_scratchpad_version");
2972 }
2973 if let Some(last_action) = state.last_transcript_budget_action.as_ref() {
2974 let usage = transcript_usage(
2975 &VmValue::dict(next.clone()),
2976 state.transcript_budget_policy.max_approx_bytes.is_some(),
2977 );
2978 metadata.insert(
2979 crate::value::intern_key("transcript_budget"),
2980 crate::stdlib::json_to_vm_value(&serde_json::json!({
2981 "policy": transcript_budget_policy_json(&state.transcript_budget_policy.normalized()),
2982 "usage": transcript_budget_usage_json(&usage),
2983 "last_action": last_action,
2984 })),
2985 );
2986 }
2987 if !metadata.is_empty() {
2988 next.insert(
2989 crate::value::intern_key("metadata"),
2990 VmValue::dict(metadata),
2991 );
2992 }
2993 VmValue::dict(next)
2994}
2995
2996fn session_snapshot(state: &SessionState) -> VmValue {
3008 let transcript = transcript_with_session_metadata(state.transcript.clone(), state);
3009 let Some(dict) = transcript.as_dict() else {
3010 return state.transcript.clone();
3011 };
3012 let mut next = dict.clone();
3013 let length = next
3014 .get("messages")
3015 .and_then(|value| match value {
3016 VmValue::List(list) => Some(list.len() as i64),
3017 _ => None,
3018 })
3019 .unwrap_or(0);
3020 next.insert(crate::value::intern_key("length"), VmValue::Int(length));
3021 next.put_str("created_at", state.created_at.clone());
3022 next.insert(
3023 crate::value::intern_key("parent_id"),
3024 state
3025 .parent_id
3026 .as_ref()
3027 .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3028 .unwrap_or(VmValue::Nil),
3029 );
3030 next.insert(
3031 crate::value::intern_key("child_ids"),
3032 VmValue::List(std::sync::Arc::new(
3033 state
3034 .child_ids
3035 .iter()
3036 .cloned()
3037 .map(|id| VmValue::String(arcstr::ArcStr::from(id)))
3038 .collect(),
3039 )),
3040 );
3041 next.insert(
3042 crate::value::intern_key("branched_at_event_index"),
3043 state
3044 .branched_at_event_index
3045 .map(|index| VmValue::Int(index as i64))
3046 .unwrap_or(VmValue::Nil),
3047 );
3048 next.insert(
3049 crate::value::intern_key("system_prompt"),
3050 state
3051 .system_prompt
3052 .as_ref()
3053 .map(|prompt| VmValue::String(arcstr::ArcStr::from(prompt.clone())))
3054 .unwrap_or(VmValue::Nil),
3055 );
3056 next.insert(
3057 crate::value::intern_key("tool_format"),
3058 state
3059 .tool_format
3060 .as_ref()
3061 .map(|format| VmValue::String(arcstr::ArcStr::from(format.clone())))
3062 .unwrap_or(VmValue::Nil),
3063 );
3064 next.insert(
3065 crate::value::intern_key("pinned_model"),
3066 state
3067 .pinned_model
3068 .as_ref()
3069 .map(|model| VmValue::String(arcstr::ArcStr::from(model.clone())))
3070 .unwrap_or(VmValue::Nil),
3071 );
3072 next.insert(
3073 crate::value::intern_key("pinned_reasoning_policy"),
3074 state
3075 .pinned_reasoning_policy
3076 .as_ref()
3077 .map(|policy| VmValue::String(arcstr::ArcStr::from(policy.clone())))
3078 .unwrap_or(VmValue::Nil),
3079 );
3080 next.insert(
3081 crate::value::intern_key("actor_chain"),
3082 state
3083 .actor_chain
3084 .as_ref()
3085 .map(ActorChain::to_vm_value)
3086 .unwrap_or(VmValue::Nil),
3087 );
3088 next.insert(
3089 crate::value::intern_key("scratchpad"),
3090 state.scratchpad.clone().unwrap_or(VmValue::Nil),
3091 );
3092 next.insert(
3093 crate::value::intern_key("scratchpad_version"),
3094 VmValue::Int(state.scratchpad_version as i64),
3095 );
3096 next.insert(
3097 crate::value::intern_key("workspace_anchor"),
3098 state
3099 .workspace_anchor
3100 .as_ref()
3101 .map(WorkspaceAnchor::to_vm_value)
3102 .unwrap_or(VmValue::Nil),
3103 );
3104 next.insert(
3105 crate::value::intern_key("workspace_policy"),
3106 state.workspace_policy.to_vm_value(),
3107 );
3108 next.insert(
3109 crate::value::intern_key("live_clients"),
3110 crate::stdlib::json_to_vm_value(&serde_json::Value::Array(
3111 state.live_clients.values().map(live_client_json).collect(),
3112 )),
3113 );
3114 next.insert(
3115 crate::value::intern_key("live_controller_id"),
3116 state
3117 .live_controller_id
3118 .as_ref()
3119 .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3120 .unwrap_or(VmValue::Nil),
3121 );
3122 next.insert(
3123 crate::value::intern_key("completed_turn_checkpoint_count"),
3124 VmValue::Int(state.completed_turn_checkpoints.len() as i64),
3125 );
3126 next.insert(
3127 crate::value::intern_key("redo_checkpoint_count"),
3128 VmValue::Int(state.redo_stack.len() as i64),
3129 );
3130 VmValue::dict(next)
3131}
3132
3133fn update_lineage(
3134 map: &mut HashMap<String, SessionState>,
3135 parent_id: &str,
3136 child_id: &str,
3137 branched_at_event_index: Option<usize>,
3138) {
3139 let old_parent_id = map.get(child_id).and_then(|child| child.parent_id.clone());
3140 if let Some(old_parent_id) = old_parent_id.filter(|old_parent_id| old_parent_id != parent_id) {
3141 if let Some(old_parent) = map.get_mut(&old_parent_id) {
3142 old_parent.child_ids.retain(|id| id != child_id);
3143 old_parent.touch();
3144 }
3145 }
3146 if let Some(parent) = map.get_mut(parent_id) {
3147 parent.touch();
3148 if !parent.child_ids.iter().any(|id| id == child_id) {
3149 parent.child_ids.push(child_id.to_string());
3150 }
3151 }
3152 if let Some(child) = map.get_mut(child_id) {
3153 child.touch();
3154 child.parent_id = Some(parent_id.to_string());
3155 child.branched_at_event_index = branched_at_event_index;
3156 child.transcript = clone_transcript_with_parent(&child.transcript, parent_id);
3157 }
3158}
3159
3160fn branch_event_index(transcript: &VmValue, keep_first: usize) -> usize {
3161 if keep_first == 0 {
3162 return 0;
3163 }
3164 let Some(dict) = transcript.as_dict() else {
3165 return keep_first;
3166 };
3167 let Some(VmValue::List(events)) = dict.get("events") else {
3168 return keep_first;
3169 };
3170 event_prefix_len_for_messages(events, keep_first)
3171}
3172
3173fn event_kind(event: &VmValue) -> Option<String> {
3174 event
3175 .as_dict()
3176 .and_then(|dict| dict.get("kind"))
3177 .map(VmValue::display)
3178}
3179
3180fn event_id(event: &VmValue) -> Option<String> {
3181 event
3182 .as_dict()
3183 .and_then(|dict| dict.get("id"))
3184 .map(VmValue::display)
3185}
3186
3187fn is_turn_event(event: &VmValue) -> bool {
3188 matches!(
3189 event_kind(event).as_deref(),
3190 Some("message" | "tool_result")
3191 )
3192}
3193
3194fn event_prefix_len_for_messages(events: &[VmValue], keep_first: usize) -> usize {
3195 if keep_first == 0 {
3196 return 0;
3197 }
3198 let mut retained_messages = 0usize;
3199 for (index, event) in events.iter().enumerate() {
3200 if is_turn_event(event) {
3201 retained_messages += 1;
3202 if retained_messages == keep_first {
3203 return index + 1;
3204 }
3205 }
3206 }
3207 events.len()
3208}
3209
3210fn turn_event_id_for_count(events: &[VmValue], keep_first: usize) -> Option<String> {
3211 if keep_first == 0 {
3212 return None;
3213 }
3214 let mut retained_messages = 0usize;
3215 for event in events {
3216 if is_turn_event(event) {
3217 retained_messages += 1;
3218 if retained_messages == keep_first {
3219 return event_id(event);
3220 }
3221 }
3222 }
3223 None
3224}
3225
3226#[cfg(test)]
3227#[path = "agent_sessions_tests.rs"]
3228mod tests;