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