Skip to main content

harn_vm/
agent_sessions.rs

1//! First-class session storage.
2//!
3//! A session owns three things:
4//!
5//! 1. A transcript dict (messages, events, summary, metadata, …).
6//! 2. Closure subscribers that fire on agent-loop events for this session.
7//! 3. Its own lifecycle (open, reset, fork, trim, compact, close).
8//!
9//! Storage is thread-local because session lifecycle and subscriber dispatch
10//! are owned by the current-thread agent-loop worker. The subscribers register,
11//! fire, and unregister on that same thread, keeping ordering deterministic.
12//!
13//! Lifecycle is explicit. Builtins (`agent_session_open`,
14//! `_reset`, `_fork`, `_fork_at`, `_close`, `_trim`, `_compact`,
15//! `_inject`, `_exists`, `_length`, `_snapshot`, `_ancestry`) drive
16//! the store directly — there is no "policy" config dict that
17//! performs lifecycle as a side effect.
18
19use 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
54/// Default cap on concurrent sessions per VM thread. Beyond this the
55/// least-recently-accessed session is evicted on the next `open`.
56pub const DEFAULT_SESSION_CAP: usize = RuntimeLimits::DEFAULT.max_agent_sessions;
57
58/// Default cap on retained prompt-visible messages per session. The
59/// limit is intentionally high enough for normal long-running agents
60/// while still bounding accidental unbounded growth.
61pub const DEFAULT_TRANSCRIPT_MESSAGE_CAP: usize = 4096;
62
63/// Default cap on retained transcript audit events per session. Events
64/// include message-derived entries plus orchestration lifecycle records.
65pub 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    /// Names of skills that were active at the end of the most recent
145    /// `agent_loop` run on this session. Empty when no skills were
146    /// matched, when the skill system wasn't used, or when the
147    /// deactivation phase cleared them. Re-entering the session
148    /// restores these as the initial active set before matching runs.
149    pub active_skills: Vec<String>,
150    /// Tool-calling protocol claimed by the first agent loop that uses
151    /// this session. A transcript is only replayable under the same
152    /// contract that produced its prompt/history.
153    pub tool_format: Option<String>,
154    /// Stable session-level system prompt material. This is transcript
155    /// metadata, not a replay message: providers receive it through
156    /// their system/developer instruction channel on each call.
157    pub system_prompt: Option<String>,
158    /// Session-pinned model selector. When set, `llm_call` invocations
159    /// that do not pass an explicit `model:` option resolve to this
160    /// selector instead of `HARN_LLM_MODEL` / provider defaults. Mid-
161    /// session swap is exposed over ACP via `session/set_config_option`
162    /// (configId="model").
163    pub pinned_model: Option<String>,
164    /// Session-pinned high-level reasoning policy. When set, `llm_call`
165    /// invocations that do not pass explicit `thinking` or
166    /// `reasoning_effort` options resolve this provider-aware policy into
167    /// the route's native thinking shape. Exposed over ACP as
168    /// `session/set_config_option(configId="thought_level")`.
169    pub pinned_reasoning_policy: Option<String>,
170    /// Session-local workspace defaults. Persona and host policy layers
171    /// can update this without rewriting the current anchor.
172    pub workspace_policy: WorkspacePolicy,
173    /// Typed workspace anchor for the session. Primary path plus any
174    /// additional mounted roots; consumed by permission matchers, the
175    /// bundle exporter, and host-side cross-project handoff flows
176    /// (epic #2208). `None` until a host opens the session with one or
177    /// the ACP `reanchor` / `add_root` primitives populate it.
178    pub workspace_anchor: Option<WorkspaceAnchor>,
179    /// Small session-local working memory rendered into agent prompts by the
180    /// Harn stdlib agent loop. This is live state, not a replayed message.
181    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    /// Durable trust-boundary evidence consulted by the agent dispatch gate.
191    /// It lives with the transcript so pre-loop injections and resumed loops
192    /// cannot lose security provenance.
193    pub taint: Vec<crate::security::TaintRecord>,
194    /// Pending canonical transcript mutations for the active agent run. This
195    /// shares the session record's lifecycle so reset and eviction cannot
196    /// leave a second ambient journal behind.
197    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
313/// RAII guard that scopes the active tool-call id for the running thread.
314///
315/// Set on entry to a tool dispatch and dropped on exit, so any hostlib
316/// builtin invoked under it (e.g. `tools/write_file`) can resolve the
317/// owning tool call without threading the id through every parameter.
318pub 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
330/// Set the per-thread session cap. Primarily for tests; production VMs
331/// inherit the default.
332pub 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
384/// Clear the session store. Wired into `reset_llm_state` for test isolation.
385pub 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
528/// Return the active tool-call id for the current thread, if any.
529///
530/// Hostlib builtins consult this to attribute side-effect snapshots to
531/// the owning ACP `toolCallId` without callers passing it explicitly.
532pub 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
541/// Scope the active tool-call id to one async task.
542///
543/// Parallel tool dispatch runs sibling calls on the same OS thread, so
544/// thread-local guards alone cannot preserve attribution across `.await`
545/// points. Tokio task-local scoping follows the future instead.
546pub 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
558/// Scope the active tool-call id for the duration of the returned guard.
559pub 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
733/// Session-only fields stay on `agent_session_snapshot`.
734pub 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
742/// Open a session, or create it if missing. Returns the resolved id.
743///
744/// Newly-created sessions auto-register an event-log-backed sink when a
745/// generalized [`crate::event_log::EventLog`] has been installed for the
746/// current VM thread. For legacy env-driven workflows that still point
747/// `HARN_EVENT_LOG_DIR` at a directory, we preserve the older JSONL sink
748/// as a compatibility fallback. Re-opening an existing session does not
749/// re-register — sinks are per-session, owned by the first opener.
750pub 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
872/// Auto-register a persistent sink for a newly-created session.
873/// Silent no-op on failure — a broken observability sink must never
874/// prevent a session from starting.
875fn 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    // Cross-thread per-session state must be released too, otherwise
904    // pending inbox entries can be delivered to a future session that
905    // happens to reuse the same id.
906    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
964/// Copy `src`'s transcript into a new session id. Subscribers are NOT
965/// copied — a fork is a conversation branch, not an event fanout.
966///
967/// Touches `src`'s `last_accessed` before evicting, so the fork
968/// operation itself can't make `src` look stale and kick it out of
969/// the LRU just to make room for the new fork.
970pub 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    // Ensure cap is respected when inserting the fork.
1012    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        // The lineage edge was established before the budget check ran
1044        // (update_lineage rewrites dst's transcript with the parent
1045        // metadata that the check budgets). Undo it so a rejected fork
1046        // never leaves a dangling child_id pointing at the session we
1047        // are about to close.
1048        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    // open_or_create evicts BEFORE inserting, so the dst slot is
1058    // guaranteed once we get here. The existence check is cheap
1059    // insurance against a future refactor that breaks that invariant.
1060    if exists(&dst) {
1061        Some(dst)
1062    } else {
1063        None
1064    }
1065}
1066
1067/// Fork `src_id` and truncate the destination transcript to the
1068/// first `keep_first` messages (#105 — branch-replay). Pairs with the
1069/// scrubber: the host picks an event index, rebuilds a message count,
1070/// and calls this to spawn a live sibling session that resumes from
1071/// the rebuilt state. Subscribers are not carried over (same as
1072/// `fork`), so sibling events don't double-fan into the parent's
1073/// consumers.
1074///
1075/// Returns the new session id on success, `None` if `src_id` doesn't
1076/// exist.
1077pub 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
1089/// Truncate the session transcript to the first `keep_first`
1090/// messages (opposite of `trim`, which keeps the last N). Returns
1091/// counts and the retained tip event id when the session exists.
1092pub 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
1188/// Append a message dict to the session transcript. The message must
1189/// have at least a string `role`; anything else is merged verbatim.
1190pub 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
1259/// Create a new session from a reconstructed message list.
1260///
1261/// This is intentionally an all-at-once write instead of repeated
1262/// `inject_message` calls: importing a transcript should not re-emit
1263/// each historic turn into the active observability sidecar.
1264pub 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
1320/// Load the messages vec (as JSON) for this session, for use as prefix
1321/// to an agent_loop run. Returns an empty vec if the session doesn't
1322/// exist or has no messages.
1323/// Append a transcript event to the session without mutating its
1324/// message list. Used for orchestration-side lineage events (sub-agent
1325/// spawn/completion, workflow hooks, etc.) that should survive
1326/// persistence/replay without being replayed back into the model as
1327/// conversational messages.
1328pub 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
1381/// Replace the transcript's message list wholesale. Used by the
1382/// in-loop compaction path, which operates on JSON messages.
1383pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
1384    replace_messages_with_summary(id, messages, None)
1385}
1386
1387/// Replace the transcript's message list and optionally update the
1388/// `summary` field on the persisted transcript. The compaction path
1389/// uses this to publish the human-readable rollup line that
1390/// `transcript_summary(transcript)` exposes to host code.
1391pub 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/// Persist the set of active skill names for session resume. Called at
1476/// the end of an agent_loop run; the next `open_or_create` for this id
1477/// reads them back via [`active_skills`].
1478#[cfg(test)]
1479#[path = "agent_sessions_tests.rs"]
1480mod tests;