1use crate::value::VmDictExt;
20use std::cell::{Cell, RefCell};
21use std::collections::{BTreeMap, HashMap, HashSet};
22use std::future::Future;
23use std::path::{Path, PathBuf};
24use std::time::Instant;
25
26use crate::actor_chain::ActorChain;
27use crate::agent_events::{
28 AgentEvent, AttachmentFlavor, AttachmentRendering, HostInjectionProvenance, InjectionDelivery,
29 SanitizationAction, SanitizationVerdict, ToolCallStatus,
30};
31use crate::agent_transcript_budget::{
32 apply_transcript_with_budget, transcript_budget_policy_json, transcript_budget_usage_json,
33 transcript_message_count, transcript_usage,
34};
35use crate::runtime_limits::RuntimeLimits;
36use crate::security::TrustLevel;
37use crate::tool_annotations::ToolKind;
38use crate::value::VmValue;
39use crate::workspace_anchor::{
40 MountMode, MountedRoot, WorkspaceAnchor, WorkspacePolicy, WORKSPACE_ANCHOR_METADATA_KEY,
41};
42
43mod changed_paths;
44pub use changed_paths::{
45 clear_all_session_changed_paths, clear_session_changed_paths, record_session_changed_path,
46 session_changed_paths, take_session_changed_paths,
47};
48mod journal;
49pub(crate) use journal::has_journal;
50pub(crate) use journal::{clear_journal, install_journal, next_journal_event, pop_journal_event};
51const LIVE_CLIENT_EVENT_KIND: &str = "live_session_client";
52const LIVE_CLIENT_PERMISSION_EVENT_KIND: &str = "live_session_permission_route";
53
54pub const DEFAULT_SESSION_CAP: usize = RuntimeLimits::DEFAULT.max_agent_sessions;
57
58pub const DEFAULT_TRANSCRIPT_MESSAGE_CAP: usize = 4096;
62
63pub const DEFAULT_TRANSCRIPT_EVENT_CAP: usize = 32768;
66pub const MAX_SCRATCHPAD_BYTES: usize = 16 * 1024;
67#[cfg(debug_assertions)]
68const CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC: &str = "HARN-CACHE-001";
69
70#[derive(Clone, Debug, PartialEq, Eq)]
71pub enum TranscriptBudgetRecovery {
72 Reject,
73 Trim { keep_last: usize },
74 Compact { keep_last: usize },
75}
76
77#[derive(Clone, Debug, PartialEq, Eq)]
78pub struct SessionTranscriptBudgetPolicy {
79 pub max_messages: usize,
80 pub max_events: usize,
81 pub max_approx_bytes: Option<usize>,
82 pub recovery: TranscriptBudgetRecovery,
83}
84
85impl SessionTranscriptBudgetPolicy {
86 pub fn reject(max_messages: usize, max_events: usize) -> Self {
87 Self {
88 max_messages: max_messages.max(1),
89 max_events: max_events.max(1),
90 max_approx_bytes: None,
91 recovery: TranscriptBudgetRecovery::Reject,
92 }
93 }
94
95 pub fn trim(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
96 Self {
97 max_messages: max_messages.max(1),
98 max_events: max_events.max(1),
99 max_approx_bytes: None,
100 recovery: TranscriptBudgetRecovery::Trim { keep_last },
101 }
102 }
103
104 pub fn compact(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
105 Self {
106 max_messages: max_messages.max(1),
107 max_events: max_events.max(1),
108 max_approx_bytes: None,
109 recovery: TranscriptBudgetRecovery::Compact { keep_last },
110 }
111 }
112
113 pub fn with_max_approx_bytes(mut self, max_approx_bytes: Option<usize>) -> Self {
114 self.max_approx_bytes = max_approx_bytes.map(|limit| limit.max(1));
115 self
116 }
117
118 pub(crate) fn normalized(&self) -> Self {
119 Self {
120 max_messages: self.max_messages.max(1),
121 max_events: self.max_events.max(1),
122 max_approx_bytes: self.max_approx_bytes.map(|limit| limit.max(1)),
123 recovery: self.recovery.clone(),
124 }
125 }
126}
127
128impl Default for SessionTranscriptBudgetPolicy {
129 fn default() -> Self {
130 Self::reject(DEFAULT_TRANSCRIPT_MESSAGE_CAP, DEFAULT_TRANSCRIPT_EVENT_CAP)
131 }
132}
133
134pub struct SessionState {
135 pub id: String,
136 pub transcript: VmValue,
137 pub subscribers: Vec<VmValue>,
138 pub created_at: String,
139 pub last_accessed: Instant,
140 pub parent_id: Option<String>,
141 pub child_ids: Vec<String>,
142 pub branched_at_event_index: Option<usize>,
143 pub actor_chain: Option<ActorChain>,
144 pub active_skills: Vec<String>,
150 pub tool_format: Option<String>,
154 pub system_prompt: Option<String>,
158 pub pinned_model: Option<String>,
164 pub pinned_reasoning_policy: Option<String>,
170 pub workspace_policy: WorkspacePolicy,
173 pub workspace_anchor: Option<WorkspaceAnchor>,
179 pub scratchpad: Option<VmValue>,
182 pub scratchpad_version: u64,
183 pub transcript_budget_policy: SessionTranscriptBudgetPolicy,
184 pub last_transcript_budget_action: Option<serde_json::Value>,
185 pub live_clients: BTreeMap<String, LiveSessionClient>,
186 pub live_controller_id: Option<String>,
187 pub completed_turn_checkpoints: Vec<SessionTurnCheckpoint>,
188 pub redo_stack: Vec<SessionRedoEntry>,
189 pub text_tool_call_seq: u64,
190 pub taint: Vec<crate::security::TaintRecord>,
194 pub(crate) transcript_journal: Option<crate::agent_session_journal::JournalState>,
198}
199
200impl SessionState {
201 fn new(id: String) -> Self {
202 let now = Instant::now();
203 let transcript = empty_transcript(&id);
204 Self {
205 id,
206 transcript,
207 subscribers: Vec::new(),
208 created_at: crate::orchestration::now_unix_seconds_text(),
209 last_accessed: now,
210 parent_id: None,
211 child_ids: Vec::new(),
212 branched_at_event_index: None,
213 actor_chain: None,
214 active_skills: Vec::new(),
215 tool_format: None,
216 system_prompt: None,
217 pinned_model: None,
218 pinned_reasoning_policy: None,
219 workspace_policy: WorkspacePolicy::default(),
220 workspace_anchor: None,
221 scratchpad: None,
222 scratchpad_version: 0,
223 transcript_budget_policy: default_transcript_budget_policy(),
224 last_transcript_budget_action: None,
225 live_clients: BTreeMap::new(),
226 live_controller_id: None,
227 completed_turn_checkpoints: Vec::new(),
228 redo_stack: Vec::new(),
229 text_tool_call_seq: 0,
230 taint: Vec::new(),
231 transcript_journal: None,
232 }
233 }
234
235 fn touch(&mut self) {
236 self.last_accessed = Instant::now();
237 }
238
239 pub(crate) fn replace_transcript(&mut self, transcript: VmValue) {
240 if !crate::values_equal(&self.transcript, &transcript) {
241 self.redo_stack.clear();
242 }
243 self.transcript = transcript;
244 self.touch();
245 }
246}
247
248pub(crate) fn push_session_taint(id: &str, record: crate::security::TaintRecord) {
249 SESSIONS.with(|sessions| {
250 if let Some(state) = sessions.borrow_mut().get_mut(id) {
251 state.taint.push(record);
252 state.touch();
253 }
254 });
255}
256
257pub(crate) fn session_taint_snapshot(id: &str) -> Vec<crate::security::TaintRecord> {
258 SESSIONS.with(|sessions| {
259 sessions
260 .borrow()
261 .get(id)
262 .map(|state| state.taint.clone())
263 .unwrap_or_default()
264 })
265}
266
267#[derive(Clone, Debug, PartialEq, Eq)]
268pub enum LiveClientMode {
269 Observer,
270 Controller,
271}
272
273mod host_injection;
274mod live_clients;
275mod metadata;
276mod text_tool_call_seq;
277mod transcript_lifecycle;
278mod types;
279
280pub(crate) use text_tool_call_seq::next_text_tool_call_seq_for_parse;
281use text_tool_call_seq::{
282 next_text_tool_call_seq_from_json_messages, next_text_tool_call_seq_from_transcript,
283};
284
285pub use host_injection::*;
286pub use live_clients::*;
287pub use metadata::*;
288use metadata::{
289 branch_event_index, clone_transcript_with_id, empty_transcript, session_snapshot,
290 transcript_with_session_metadata, update_lineage,
291};
292pub use transcript_lifecycle::*;
293pub use types::*;
294
295thread_local! {
296 static SESSIONS: RefCell<HashMap<String, SessionState>> = RefCell::new(HashMap::new());
297 static SESSION_CAP: Cell<usize> = const { Cell::new(DEFAULT_SESSION_CAP) };
298 static DEFAULT_TRANSCRIPT_BUDGET_POLICY: RefCell<SessionTranscriptBudgetPolicy> =
299 RefCell::new(SessionTranscriptBudgetPolicy::default());
300 static CURRENT_SESSION_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
301 static CURRENT_TOOL_CALL_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
302}
303
304tokio::task_local! {
305 static CURRENT_TOOL_CALL_TASK: String;
306}
307pub struct CurrentSessionGuard {
308 active: bool,
309}
310
311impl Drop for CurrentSessionGuard {
312 fn drop(&mut self) {
313 if self.active {
314 pop_current_session();
315 }
316 }
317}
318
319pub struct CurrentToolCallGuard {
325 active: bool,
326}
327
328impl Drop for CurrentToolCallGuard {
329 fn drop(&mut self) {
330 if self.active {
331 pop_current_tool_call();
332 }
333 }
334}
335
336pub fn set_session_cap(cap: usize) {
339 SESSION_CAP.with(|c| c.set(cap.max(1)));
340}
341
342pub fn session_cap() -> usize {
343 SESSION_CAP.with(|c| c.get())
344}
345
346pub fn set_default_transcript_budget_policy(policy: SessionTranscriptBudgetPolicy) {
347 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| {
348 *cell.borrow_mut() = policy.normalized();
349 });
350}
351
352pub fn reset_default_transcript_budget_policy() {
353 set_default_transcript_budget_policy(SessionTranscriptBudgetPolicy::default());
354}
355
356pub fn default_transcript_budget_policy() -> SessionTranscriptBudgetPolicy {
357 DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| cell.borrow().clone())
358}
359
360pub fn transcript_budget_policy(id: &str) -> Option<SessionTranscriptBudgetPolicy> {
361 SESSIONS.with(|s| {
362 s.borrow()
363 .get(id)
364 .map(|state| state.transcript_budget_policy.clone())
365 })
366}
367
368pub fn set_transcript_budget_policy(
369 id: &str,
370 policy: SessionTranscriptBudgetPolicy,
371) -> Result<(), String> {
372 SESSIONS.with(|s| {
373 let mut map = s.borrow_mut();
374 let Some(state) = map.get_mut(id) else {
375 return Err(format!("agent session '{id}' does not exist"));
376 };
377 let previous = state.transcript_budget_policy.clone();
378 let previous_action = state.last_transcript_budget_action.clone();
379 state.transcript_budget_policy = policy.normalized();
380 let candidate = state.transcript.clone();
381 if let Err(error) = apply_transcript_with_budget(state, candidate, "policy_update") {
382 state.transcript_budget_policy = previous;
383 state.last_transcript_budget_action = previous_action;
384 return Err(error);
385 }
386 Ok(())
387 })
388}
389
390pub fn reset_session_store() {
392 let mut owned_session_ids: HashSet<String> =
393 SESSIONS.with(|s| s.borrow_mut().drain().map(|(id, _)| id).collect());
394 CURRENT_SESSION_STACK.with(|stack| {
395 owned_session_ids.extend(stack.borrow_mut().drain(..));
396 });
397 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().clear());
398 for session_id in owned_session_ids {
399 clear_session_changed_paths(&session_id);
400 }
401 reset_default_transcript_budget_policy();
402}
403
404pub(crate) fn push_current_session(id: String) {
405 if id.is_empty() {
406 return;
407 }
408 CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().push(id));
409}
410
411pub(crate) fn swap_current_session_stack(replacement: Vec<String>) -> Vec<String> {
412 CURRENT_SESSION_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), replacement))
413}
414
415pub(crate) fn pop_current_session() {
416 CURRENT_SESSION_STACK.with(|stack| {
417 let _ = stack.borrow_mut().pop();
418 });
419}
420
421pub fn current_session_id() -> Option<String> {
422 CURRENT_SESSION_STACK.with(|stack| stack.borrow().last().cloned())
423}
424
425pub fn current_actor_chain() -> Option<ActorChain> {
426 current_session_id().as_deref().and_then(actor_chain)
427}
428
429pub fn enter_current_session(id: impl Into<String>) -> CurrentSessionGuard {
430 let id = id.into();
431 if id.trim().is_empty() {
432 return CurrentSessionGuard { active: false };
433 }
434 push_current_session(id);
435 CurrentSessionGuard { active: true }
436}
437
438pub fn actor_chain(id: &str) -> Option<ActorChain> {
439 SESSIONS.with(|s| {
440 s.borrow()
441 .get(id)
442 .and_then(|state| state.actor_chain.clone())
443 })
444}
445
446pub fn set_actor_chain(id: &str, actor_chain: Option<ActorChain>) -> Result<bool, String> {
447 SESSIONS.with(|s| {
448 let mut map = s.borrow_mut();
449 let Some(state) = map.get_mut(id) else {
450 return Err(format!("agent session '{id}' does not exist"));
451 };
452 let changed = state.actor_chain != actor_chain;
453 state.actor_chain = actor_chain;
454 state.touch();
455 Ok(changed)
456 })
457}
458
459fn push_current_tool_call(id: String) {
460 if id.is_empty() {
461 return;
462 }
463 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().push(id));
464}
465
466fn pop_current_tool_call() {
467 CURRENT_TOOL_CALL_STACK.with(|stack| {
468 let _ = stack.borrow_mut().pop();
469 });
470}
471
472pub fn current_tool_call_id() -> Option<String> {
477 if let Ok(id) = CURRENT_TOOL_CALL_TASK.try_with(Clone::clone) {
478 if !id.trim().is_empty() {
479 return Some(id);
480 }
481 }
482 CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow().last().cloned())
483}
484
485pub async fn scope_current_tool_call<F, T>(id: impl Into<String>, future: F) -> T
491where
492 F: Future<Output = T>,
493{
494 let id = id.into();
495 if id.trim().is_empty() {
496 future.await
497 } else {
498 CURRENT_TOOL_CALL_TASK.scope(id, future).await
499 }
500}
501
502pub fn enter_current_tool_call(id: impl Into<String>) -> CurrentToolCallGuard {
504 let id = id.into();
505 if id.trim().is_empty() {
506 return CurrentToolCallGuard { active: false };
507 }
508 push_current_tool_call(id);
509 CurrentToolCallGuard { active: true }
510}
511
512pub fn exists(id: &str) -> bool {
513 SESSIONS.with(|s| s.borrow().contains_key(id))
514}
515
516pub fn length(id: &str) -> Option<usize> {
517 SESSIONS.with(|s| {
518 s.borrow().get(id).map(|state| {
519 state
520 .transcript
521 .as_dict()
522 .and_then(|d| d.get("messages"))
523 .and_then(|v| match v {
524 VmValue::List(list) => Some(list.len()),
525 _ => None,
526 })
527 .unwrap_or(0)
528 })
529 })
530}
531
532pub fn scratchpad(id: &str) -> Option<VmValue> {
533 SESSIONS.with(|s| {
534 s.borrow()
535 .get(id)
536 .and_then(|state| state.scratchpad.clone())
537 })
538}
539
540pub fn scratchpad_version(id: &str) -> Option<u64> {
541 SESSIONS.with(|s| s.borrow().get(id).map(|state| state.scratchpad_version))
542}
543
544pub fn set_scratchpad(
545 id: &str,
546 scratchpad: VmValue,
547 source: impl Into<String>,
548 reason: Option<String>,
549 metadata: serde_json::Value,
550) -> Result<u64, String> {
551 validate_scratchpad_value(&scratchpad)?;
552 SESSIONS.with(|s| {
553 let mut map = s.borrow_mut();
554 let Some(state) = map.get_mut(id) else {
555 return Err(format!("agent session '{id}' does not exist"));
556 };
557 let version = state.scratchpad_version.saturating_add(1);
558 let event = scratchpad_transcript_event(
559 "set",
560 version,
561 Some(&scratchpad),
562 source.into(),
563 reason,
564 metadata,
565 );
566 append_event_to_state(state, event, "set_scratchpad")?;
567 state.scratchpad = Some(scratchpad);
568 state.scratchpad_version = version;
569 state.touch();
570 Ok(version)
571 })
572}
573
574pub fn clear_scratchpad(
575 id: &str,
576 source: impl Into<String>,
577 reason: Option<String>,
578 metadata: serde_json::Value,
579) -> Result<u64, String> {
580 SESSIONS.with(|s| {
581 let mut map = s.borrow_mut();
582 let Some(state) = map.get_mut(id) else {
583 return Err(format!("agent session '{id}' does not exist"));
584 };
585 let version = state.scratchpad_version.saturating_add(1);
586 let event =
587 scratchpad_transcript_event("clear", version, None, source.into(), reason, metadata);
588 append_event_to_state(state, event, "clear_scratchpad")?;
589 state.scratchpad = None;
590 state.scratchpad_version = version;
591 state.touch();
592 Ok(version)
593 })
594}
595
596fn validate_scratchpad_value(value: &VmValue) -> Result<(), String> {
597 if !matches!(value, VmValue::Dict(_)) {
598 return Err("agent session scratchpad must be a dict".to_string());
599 }
600 let json = crate::llm::helpers::vm_value_to_json(value);
601 let approx_bytes = serde_json::to_vec(&json)
602 .map(|bytes| bytes.len())
603 .unwrap_or(usize::MAX);
604 if approx_bytes > MAX_SCRATCHPAD_BYTES {
605 return Err(format!(
606 "agent session scratchpad is {approx_bytes} bytes; max is {MAX_SCRATCHPAD_BYTES}"
607 ));
608 }
609 Ok(())
610}
611
612fn scratchpad_transcript_event(
613 action: &str,
614 version: u64,
615 scratchpad: Option<&VmValue>,
616 source: String,
617 reason: Option<String>,
618 metadata: serde_json::Value,
619) -> VmValue {
620 let scratchpad_json = scratchpad.map(crate::llm::helpers::vm_value_to_json);
621 let approx_bytes = scratchpad_json
622 .as_ref()
623 .and_then(|value| serde_json::to_vec(value).ok().map(|bytes| bytes.len()))
624 .unwrap_or(0);
625 let event_metadata = serde_json::json!({
626 "action": action,
627 "version": version,
628 "source": normalize_scratchpad_source(source),
629 "reason": reason.unwrap_or_default(),
630 "approx_bytes": approx_bytes,
631 "counts": scratchpad_json
632 .as_ref()
633 .map(scratchpad_counts_json)
634 .unwrap_or_else(|| serde_json::json!({})),
635 "metadata": metadata,
636 });
637 let content = format!("Agent scratchpad {action}");
638 crate::llm::helpers::transcript_event(
639 "agent_scratchpad",
640 "system",
641 "internal",
642 &content,
643 Some(event_metadata),
644 )
645}
646
647fn normalize_scratchpad_source(source: String) -> String {
648 let trimmed = source.trim();
649 if trimmed.is_empty() {
650 "harn.agent_scratchpad".to_string()
651 } else {
652 trimmed.to_string()
653 }
654}
655
656fn scratchpad_counts_json(value: &serde_json::Value) -> serde_json::Value {
657 serde_json::json!({
658 "goals": scratchpad_array_len(value, "goals"),
659 "open_items": scratchpad_array_len(value, "open_items"),
660 "facts": scratchpad_array_len(value, "facts"),
661 "refs": scratchpad_array_len(value, "refs"),
662 })
663}
664
665fn scratchpad_array_len(value: &serde_json::Value, key: &str) -> usize {
666 value
667 .get(key)
668 .and_then(serde_json::Value::as_array)
669 .map(Vec::len)
670 .unwrap_or(0)
671}
672
673pub fn snapshot(id: &str) -> Option<VmValue> {
674 SESSIONS.with(|s| s.borrow().get(id).map(session_snapshot))
675}
676
677pub fn transcript(id: &str) -> Option<VmValue> {
679 SESSIONS.with(|s| {
680 s.borrow()
681 .get(id)
682 .map(|state| transcript_with_session_metadata(state.transcript.clone(), state))
683 })
684}
685
686pub fn next_message_index(id: &str) -> Option<usize> {
692 SESSIONS.with(|s| {
693 let map = s.borrow();
694 let state = map.get(id)?;
695 let messages = state
696 .transcript
697 .as_dict()
698 .and_then(|dict| dict.get("messages"))
699 .and_then(|value| match value {
700 VmValue::List(list) => Some(list.len()),
701 _ => None,
702 })
703 .unwrap_or(0);
704 Some(messages)
705 })
706}
707
708pub fn open_or_create(id: Option<String>) -> String {
717 open_or_create_with_actor_chain(id, None)
718}
719
720pub fn open_or_create_with_actor_chain(
721 id: Option<String>,
722 requested_actor_chain: Option<ActorChain>,
723) -> String {
724 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
725 let parent_session = current_session_id();
726 let inherited_actor_chain = requested_actor_chain
727 .clone()
728 .or_else(|| parent_session.as_deref().and_then(actor_chain));
729 let mut was_new = false;
730 let mut evicted = None;
731 SESSIONS.with(|s| {
732 let mut map = s.borrow_mut();
733 if let Some(state) = map.get_mut(&resolved) {
734 if let Some(actor_chain) = requested_actor_chain.clone() {
735 state.actor_chain = Some(actor_chain);
736 }
737 state.touch();
738 return;
739 }
740 was_new = true;
741 let cap = SESSION_CAP.with(|c| c.get());
742 if map.len() >= cap {
743 if let Some(victim) = map
744 .iter()
745 .min_by_key(|(_, state)| state.last_accessed)
746 .map(|(id, _)| id.clone())
747 {
748 map.remove(&victim);
749 evicted = Some(victim);
750 }
751 }
752 let mut state = SessionState::new(resolved.clone());
753 state.actor_chain = inherited_actor_chain.clone();
754 map.insert(resolved.clone(), state);
755 });
756 if let Some(evicted) = evicted {
757 clear_session_changed_paths(&evicted);
758 }
759 if was_new {
760 clear_session_changed_paths(&resolved);
763 if let Some(parent) = parent_session.as_deref() {
764 crate::agent_events::mirror_session_sinks(parent, &resolved);
765 }
766 try_register_event_log(&resolved);
767 }
768 resolved
769}
770
771pub fn open_child_session(parent_id: &str, id: Option<String>) -> String {
772 open_child_session_with_actor(parent_id, id, None)
773}
774
775pub fn open_child_session_with_actor(
776 parent_id: &str,
777 id: Option<String>,
778 actor: Option<&str>,
779) -> String {
780 let actor_chain = actor_chain(parent_id).map(|chain| match actor {
781 Some(actor) if !actor.trim().is_empty() => chain.pushed(actor.trim()),
782 _ => chain,
783 });
784 let resolved = open_or_create_with_actor_chain(id, actor_chain);
785 link_child_session(parent_id, &resolved);
786 resolved
787}
788
789pub fn link_child_session(parent_id: &str, child_id: &str) {
790 link_child_session_with_branch(parent_id, child_id, None);
791}
792
793pub fn link_child_session_with_branch(
794 parent_id: &str,
795 child_id: &str,
796 branched_at_event_index: Option<usize>,
797) {
798 if parent_id == child_id {
799 return;
800 }
801 open_or_create(Some(parent_id.to_string()));
802 open_or_create(Some(child_id.to_string()));
803 SESSIONS.with(|s| {
804 let mut map = s.borrow_mut();
805 update_lineage(&mut map, parent_id, child_id, branched_at_event_index);
806 });
807}
808
809pub fn parent_id(id: &str) -> Option<String> {
810 SESSIONS.with(|s| s.borrow().get(id).and_then(|state| state.parent_id.clone()))
811}
812
813pub fn child_ids(id: &str) -> Vec<String> {
814 SESSIONS.with(|s| {
815 s.borrow()
816 .get(id)
817 .map(|state| state.child_ids.clone())
818 .unwrap_or_default()
819 })
820}
821
822pub fn ancestry(id: &str) -> Option<SessionAncestry> {
823 SESSIONS.with(|s| {
824 let map = s.borrow();
825 let state = map.get(id)?;
826 let mut root_id = state.id.clone();
827 let mut cursor = state.parent_id.clone();
828 let mut seen = HashSet::from([state.id.clone()]);
829 while let Some(parent_id) = cursor {
830 if !seen.insert(parent_id.clone()) {
831 break;
832 }
833 root_id = parent_id.clone();
834 cursor = map
835 .get(&parent_id)
836 .and_then(|parent| parent.parent_id.clone());
837 }
838 Some(SessionAncestry {
839 parent_id: state.parent_id.clone(),
840 child_ids: state.child_ids.clone(),
841 root_id,
842 })
843 })
844}
845
846fn try_register_event_log(session_id: &str) {
850 if let Some(log) = crate::event_log::active_event_log() {
851 crate::agent_events::register_sink(
852 session_id,
853 crate::agent_events::EventLogSink::new(log, session_id),
854 );
855 return;
856 }
857 let Ok(dir) = std::env::var("HARN_EVENT_LOG_DIR") else {
858 return;
859 };
860 if dir.is_empty() {
861 return;
862 }
863 let path = std::path::PathBuf::from(dir).join(format!("event_log-{session_id}.jsonl"));
864 if let Ok(sink) = crate::agent_events::JsonlEventSink::open(&path) {
865 crate::agent_events::register_sink(session_id, sink);
866 }
867}
868
869pub fn register_event_log_sink(session_id: &str) {
870 try_register_event_log(session_id);
871}
872
873pub fn close(id: &str) {
874 let removed = SESSIONS.with(|s| s.borrow_mut().remove(id).is_some());
875 if removed {
876 clear_session_changed_paths(id);
877 }
878 crate::orchestration::agent_inbox::clear_session(id);
882 crate::agent_events::clear_session_sinks(id);
883}
884
885pub fn close_with_status(
886 id: &str,
887 reason: impl Into<String>,
888 status: impl Into<String>,
889 metadata: serde_json::Value,
890) -> bool {
891 if !exists(id) {
892 return false;
893 }
894 let reason = reason.into();
895 let status = status.into();
896 let event_metadata = serde_json::json!({
897 "reason": reason,
898 "status": status,
899 "metadata": metadata,
900 });
901 let transcript_event = crate::llm::helpers::transcript_event(
902 "agent_session_closed",
903 "system",
904 "internal",
905 "Agent session closed",
906 Some(event_metadata),
907 );
908 let _ = append_event(id, transcript_event);
909 crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::SessionClosed {
910 session_id: id.to_string(),
911 reason,
912 status,
913 metadata,
914 });
915 close(id);
916 true
917}
918
919pub fn reset_transcript(id: &str) -> bool {
920 SESSIONS.with(|s| {
921 let mut map = s.borrow_mut();
922 let Some(state) = map.get_mut(id) else {
923 return false;
924 };
925 state.transcript = empty_transcript(id);
926 state.tool_format = None;
927 state.system_prompt = None;
928 state.scratchpad = None;
929 state.scratchpad_version = 0;
930 state.last_transcript_budget_action = None;
931 state.completed_turn_checkpoints.clear();
932 state.redo_stack.clear();
933 state.text_tool_call_seq = 0;
934 state.touch();
935 true
936 })
937}
938
939pub fn fork(src_id: &str, dst_id: Option<String>) -> Option<String> {
946 let (
947 src_transcript,
948 src_tool_format,
949 src_system_prompt,
950 src_pinned_model,
951 src_pinned_reasoning_policy,
952 src_actor_chain,
953 src_workspace_anchor,
954 src_workspace_policy,
955 src_scratchpad,
956 src_scratchpad_version,
957 src_transcript_budget_policy,
958 src_last_transcript_budget_action,
959 src_text_tool_call_seq,
960 src_taint,
961 dst,
962 ) = SESSIONS.with(|s| {
963 let mut map = s.borrow_mut();
964 let src = map.get_mut(src_id)?;
965 src.touch();
966 let dst = dst_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
967 let forked_transcript = clone_transcript_with_id(&src.transcript, &dst);
968 Some((
969 forked_transcript,
970 src.tool_format.clone(),
971 src.system_prompt.clone(),
972 src.pinned_model.clone(),
973 src.pinned_reasoning_policy.clone(),
974 src.actor_chain.clone(),
975 src.workspace_anchor.clone(),
976 src.workspace_policy.clone(),
977 src.scratchpad.clone(),
978 src.scratchpad_version,
979 src.transcript_budget_policy.clone(),
980 src.last_transcript_budget_action.clone(),
981 src.text_tool_call_seq,
982 src.taint.clone(),
983 dst,
984 ))
985 })?;
986 open_or_create(Some(dst.clone()));
988 SESSIONS.with(|s| {
989 let mut map = s.borrow_mut();
990 if let Some(state) = map.get_mut(&dst) {
991 state.transcript = src_transcript;
992 state.tool_format = src_tool_format;
993 state.system_prompt = src_system_prompt;
994 state.pinned_model = src_pinned_model;
995 state.pinned_reasoning_policy = src_pinned_reasoning_policy;
996 state.actor_chain = src_actor_chain;
997 state.workspace_anchor = src_workspace_anchor;
998 state.workspace_policy = src_workspace_policy;
999 state.scratchpad = src_scratchpad;
1000 state.scratchpad_version = src_scratchpad_version;
1001 state.transcript_budget_policy = src_transcript_budget_policy;
1002 state.last_transcript_budget_action = src_last_transcript_budget_action;
1003 state.text_tool_call_seq = src_text_tool_call_seq;
1004 state.taint = src_taint;
1005 state.touch();
1006 }
1007 update_lineage(&mut map, src_id, &dst, None);
1008 });
1009 let budget_ok = SESSIONS.with(|s| {
1010 let mut map = s.borrow_mut();
1011 let Some(state) = map.get_mut(&dst) else {
1012 return false;
1013 };
1014 let candidate = state.transcript.clone();
1015 apply_transcript_with_budget(state, candidate, "fork").is_ok()
1016 });
1017 if !budget_ok {
1018 SESSIONS.with(|s| {
1024 let mut map = s.borrow_mut();
1025 if let Some(parent) = map.get_mut(src_id) {
1026 parent.child_ids.retain(|id| id != &dst);
1027 }
1028 });
1029 close(&dst);
1030 return None;
1031 }
1032 if exists(&dst) {
1036 Some(dst)
1037 } else {
1038 None
1039 }
1040}
1041
1042pub fn fork_at(src_id: &str, keep_first: usize, dst_id: Option<String>) -> Option<String> {
1053 let branched_at_event_index = SESSIONS.with(|s| {
1054 let map = s.borrow();
1055 let src = map.get(src_id)?;
1056 Some(branch_event_index(&src.transcript, keep_first))
1057 })?;
1058 let new_id = fork(src_id, dst_id)?;
1059 link_child_session_with_branch(src_id, &new_id, Some(branched_at_event_index));
1060 truncate(&new_id, keep_first).ok().flatten()?;
1061 Some(new_id)
1062}
1063
1064mod truncation;
1065use truncation::truncate_state;
1066pub use truncation::{trim, truncate};
1067
1068mod pop_last_assistant;
1069pub use pop_last_assistant::pop_last_if_assistant;
1070mod restore_message_event_ids;
1071pub(crate) use restore_message_event_ids::restore_message_event_ids;
1072
1073pub fn inject_message(id: &str, message: VmValue) -> Result<usize, String> {
1081 let Some(msg_dict) = message.as_dict().cloned() else {
1082 return Err("agent_session_inject: message must be a dict".into());
1083 };
1084 let role_ok = matches!(msg_dict.get("role"), Some(VmValue::String(_)));
1085 if !role_ok {
1086 return Err(
1087 "agent_session_inject: message must have a string `role` (user|assistant|tool_result|system)"
1088 .into(),
1089 );
1090 }
1091 SESSIONS.with(|s| {
1092 let mut map = s.borrow_mut();
1093 let Some(state) = map.get_mut(id) else {
1094 return Err(format!("agent_session_inject: unknown session id '{id}'"));
1095 };
1096 let dict = state
1097 .transcript
1098 .as_dict()
1099 .cloned()
1100 .unwrap_or_else(crate::value::DictMap::new);
1101 let mut messages: Vec<VmValue> = match dict.get("messages") {
1102 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1103 _ => Vec::new(),
1104 };
1105 let mut events: Vec<VmValue> = match dict.get("events") {
1106 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1107 _ => crate::llm::helpers::transcript_events_from_messages(&messages),
1108 };
1109 let new_message = VmValue::dict(msg_dict);
1110 let message_index = messages.len();
1111 let transcript_event = crate::llm::helpers::transcript_event_from_message(&new_message);
1112 events.push(transcript_event.clone());
1113 messages.push(new_message);
1114 let mut next = dict;
1115 next.insert(
1116 crate::value::intern_key("events"),
1117 VmValue::List(std::sync::Arc::new(events)),
1118 );
1119 next.insert(
1120 crate::value::intern_key("messages"),
1121 VmValue::List(std::sync::Arc::new(messages)),
1122 );
1123 let persisted_message = next
1124 .get("messages")
1125 .and_then(|value| match value {
1126 VmValue::List(list) => list.get(message_index).cloned(),
1127 _ => None,
1128 })
1129 .unwrap_or(VmValue::Nil);
1130 apply_transcript_with_budget(state, VmValue::dict(next), "inject_message")?;
1131 crate::agent_session_journal::enqueue_message(
1132 &mut state.transcript_journal,
1133 crate::llm::helpers::vm_value_to_json(&transcript_event),
1134 crate::llm::helpers::vm_value_to_json(&persisted_message),
1135 );
1136 emit_identified_user_message_event(id, &persisted_message);
1137 emit_llm_message_event(id, message_index, &persisted_message);
1138 Ok(message_index)
1139 })
1140}
1141
1142#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
1143#[serde(rename_all = "snake_case")]
1144pub enum HostInjectionKind {
1145 HostToolResult,
1146 HostAttachment,
1147}
1148
1149pub fn seed_from_messages(
1155 id: Option<String>,
1156 messages: &[serde_json::Value],
1157 metadata: serde_json::Value,
1158 system_prompt: Option<String>,
1159 tool_format: Option<String>,
1160) -> Result<String, String> {
1161 let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1162 if exists(&resolved) {
1163 return Err(format!("agent session '{resolved}' already exists"));
1164 }
1165 open_or_create(Some(resolved.clone()));
1166 SESSIONS.with(|s| {
1167 let mut map = s.borrow_mut();
1168 let Some(state) = map.get_mut(&resolved) else {
1169 return Err(format!("failed to create agent session '{resolved}'"));
1170 };
1171 state.tool_format = tool_format.filter(|value| !value.trim().is_empty());
1172 state.system_prompt = system_prompt.filter(|value| !value.trim().is_empty());
1173
1174 let mut metadata = metadata
1175 .as_object()
1176 .cloned()
1177 .unwrap_or_else(serde_json::Map::new);
1178 if let Some(tool_format) = state.tool_format.as_ref() {
1179 metadata.insert(
1180 "tool_format".to_string(),
1181 serde_json::Value::String(tool_format.clone()),
1182 );
1183 metadata.insert(
1184 "tool_mode_locked".to_string(),
1185 serde_json::Value::Bool(true),
1186 );
1187 }
1188 if let Some(system_prompt) = state.system_prompt.as_ref() {
1189 metadata.insert(
1190 "system_prompt".to_string(),
1191 crate::llm::helpers::system_prompt_metadata(system_prompt),
1192 );
1193 }
1194 let text_tool_call_seq = next_text_tool_call_seq_from_json_messages(messages);
1195 let vm_messages = crate::llm::helpers::json_messages_to_vm(messages);
1196 let candidate = crate::llm::helpers::new_transcript_with(
1197 Some(resolved.clone()),
1198 vm_messages,
1199 None,
1200 Some(crate::stdlib::json_to_vm_value(&serde_json::Value::Object(
1201 metadata,
1202 ))),
1203 );
1204 apply_transcript_with_budget(state, candidate, "seed_from_messages")?;
1205 state.text_tool_call_seq = text_tool_call_seq;
1206 Ok(resolved)
1207 })
1208}
1209
1210pub fn append_event(id: &str, event: VmValue) -> Result<(), String> {
1219 let Some(event_dict) = event.as_dict() else {
1220 return Err("agent_session_append_event: event must be a dict".into());
1221 };
1222 let kind_ok = matches!(event_dict.get("kind"), Some(VmValue::String(_)));
1223 if !kind_ok {
1224 return Err("agent_session_append_event: event must have a string `kind`".into());
1225 }
1226 SESSIONS.with(|s| {
1227 let mut map = s.borrow_mut();
1228 let Some(state) = map.get_mut(id) else {
1229 return Err(format!(
1230 "agent_session_append_event: unknown session id '{id}'"
1231 ));
1232 };
1233 append_event_to_state(state, event, "append_event")?;
1234 Ok(())
1235 })
1236}
1237
1238fn append_event_to_state(
1239 state: &mut SessionState,
1240 event: VmValue,
1241 action: &str,
1242) -> Result<(), String> {
1243 let journal_event = crate::llm::helpers::vm_value_to_json(&event);
1244 let dict = state
1245 .transcript
1246 .as_dict()
1247 .cloned()
1248 .unwrap_or_else(crate::value::DictMap::new);
1249 let mut events: Vec<VmValue> = match dict.get("events") {
1250 Some(VmValue::List(list)) => list.iter().cloned().collect(),
1251 _ => dict
1252 .get("messages")
1253 .and_then(|value| match value {
1254 VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
1255 _ => None,
1256 })
1257 .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
1258 .unwrap_or_default(),
1259 };
1260 events.push(event);
1261 let mut next = dict;
1262 next.insert(
1263 crate::value::intern_key("events"),
1264 VmValue::List(std::sync::Arc::new(events)),
1265 );
1266 apply_transcript_with_budget(state, VmValue::dict(next), action)?;
1267 crate::agent_session_journal::enqueue_audit_event(&mut state.transcript_journal, journal_event);
1268 Ok(())
1269}
1270
1271pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
1274 replace_messages_with_summary(id, messages, None)
1275}
1276
1277pub fn replace_messages_with_summary(
1282 id: &str,
1283 messages: &[serde_json::Value],
1284 summary: Option<&str>,
1285) -> Result<(), String> {
1286 SESSIONS.with(|s| {
1287 let mut map = s.borrow_mut();
1288 let Some(state) = map.get_mut(id) else {
1289 return Err(format!(
1290 "agent_session_replace_messages: unknown session id '{id}'"
1291 ));
1292 };
1293 let dict = state
1294 .transcript
1295 .as_dict()
1296 .cloned()
1297 .unwrap_or_else(crate::value::DictMap::new);
1298 let vm_messages: Vec<VmValue> = messages
1299 .iter()
1300 .map(crate::stdlib::json_to_vm_value)
1301 .collect();
1302 let replacement_events = crate::llm::helpers::transcript_events_from_messages(&vm_messages);
1303 let source_event_ids = replacement_events
1304 .iter()
1305 .map(|event| {
1306 event
1307 .as_dict()
1308 .and_then(|event| event.get("id"))
1309 .map(VmValue::display)
1310 })
1311 .collect();
1312 let mut next = dict;
1313 next.insert(
1314 crate::value::intern_key("events"),
1315 VmValue::List(std::sync::Arc::new(replacement_events)),
1316 );
1317 next.insert(
1318 crate::value::intern_key("messages"),
1319 VmValue::List(std::sync::Arc::new(vm_messages)),
1320 );
1321 if let Some(summary) = summary {
1322 next.put_str("summary", summary);
1323 } else {
1324 next.remove("summary");
1325 }
1326 apply_transcript_with_budget(state, VmValue::dict(next), "replace_messages")?;
1327 crate::agent_session_journal::enqueue_messages_replaced(
1328 &mut state.transcript_journal,
1329 messages.to_vec(),
1330 summary.map(str::to_string),
1331 source_event_ids,
1332 );
1333 Ok(())
1334 })
1335}
1336
1337pub fn append_subscriber(id: &str, callback: VmValue) {
1338 open_or_create(Some(id.to_string()));
1339 SESSIONS.with(|s| {
1340 if let Some(state) = s.borrow_mut().get_mut(id) {
1341 state.subscribers.push(callback);
1342 state.touch();
1343 }
1344 });
1345}
1346
1347pub fn subscribers_for(id: &str) -> Vec<VmValue> {
1348 SESSIONS.with(|s| {
1349 s.borrow()
1350 .get(id)
1351 .map(|state| state.subscribers.clone())
1352 .unwrap_or_default()
1353 })
1354}
1355
1356pub fn subscriber_count(id: &str) -> usize {
1357 SESSIONS.with(|s| {
1358 s.borrow()
1359 .get(id)
1360 .map(|state| state.subscribers.len())
1361 .unwrap_or(0)
1362 })
1363}
1364
1365#[cfg(test)]
1369#[path = "agent_sessions_tests.rs"]
1370mod tests;