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