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_unix_seconds_text(),
209            last_accessed: now,
210            parent_id: None,
211            child_ids: Vec::new(),
212            branched_at_event_index: None,
213            actor_chain: None,
214            active_skills: Vec::new(),
215            tool_format: None,
216            system_prompt: None,
217            pinned_model: None,
218            pinned_reasoning_policy: None,
219            workspace_policy: WorkspacePolicy::default(),
220            workspace_anchor: None,
221            scratchpad: None,
222            scratchpad_version: 0,
223            transcript_budget_policy: default_transcript_budget_policy(),
224            last_transcript_budget_action: None,
225            live_clients: BTreeMap::new(),
226            live_controller_id: None,
227            completed_turn_checkpoints: Vec::new(),
228            redo_stack: Vec::new(),
229            text_tool_call_seq: 0,
230            taint: Vec::new(),
231            transcript_journal: None,
232        }
233    }
234
235    fn touch(&mut self) {
236        self.last_accessed = Instant::now();
237    }
238
239    pub(crate) fn replace_transcript(&mut self, transcript: VmValue) {
240        if !crate::values_equal(&self.transcript, &transcript) {
241            self.redo_stack.clear();
242        }
243        self.transcript = transcript;
244        self.touch();
245    }
246}
247
248pub(crate) fn push_session_taint(id: &str, record: crate::security::TaintRecord) {
249    SESSIONS.with(|sessions| {
250        if let Some(state) = sessions.borrow_mut().get_mut(id) {
251            state.taint.push(record);
252            state.touch();
253        }
254    });
255}
256
257pub(crate) fn session_taint_snapshot(id: &str) -> Vec<crate::security::TaintRecord> {
258    SESSIONS.with(|sessions| {
259        sessions
260            .borrow()
261            .get(id)
262            .map(|state| state.taint.clone())
263            .unwrap_or_default()
264    })
265}
266
267#[derive(Clone, Debug, PartialEq, Eq)]
268pub enum LiveClientMode {
269    Observer,
270    Controller,
271}
272
273mod host_injection;
274mod live_clients;
275mod metadata;
276mod text_tool_call_seq;
277mod transcript_lifecycle;
278mod types;
279
280pub(crate) use text_tool_call_seq::next_text_tool_call_seq_for_parse;
281use text_tool_call_seq::{
282    next_text_tool_call_seq_from_json_messages, next_text_tool_call_seq_from_transcript,
283};
284
285pub use host_injection::*;
286pub use live_clients::*;
287pub use metadata::*;
288use metadata::{
289    branch_event_index, clone_transcript_with_id, empty_transcript, session_snapshot,
290    transcript_with_session_metadata, update_lineage,
291};
292pub use transcript_lifecycle::*;
293pub use types::*;
294
295thread_local! {
296    static SESSIONS: RefCell<HashMap<String, SessionState>> = RefCell::new(HashMap::new());
297    static SESSION_CAP: Cell<usize> = const { Cell::new(DEFAULT_SESSION_CAP) };
298    static DEFAULT_TRANSCRIPT_BUDGET_POLICY: RefCell<SessionTranscriptBudgetPolicy> =
299        RefCell::new(SessionTranscriptBudgetPolicy::default());
300    static CURRENT_SESSION_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
301    static CURRENT_TOOL_CALL_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
302}
303
304tokio::task_local! {
305    static CURRENT_TOOL_CALL_TASK: String;
306}
307pub struct CurrentSessionGuard {
308    active: bool,
309}
310
311impl Drop for CurrentSessionGuard {
312    fn drop(&mut self) {
313        if self.active {
314            pop_current_session();
315        }
316    }
317}
318
319/// RAII guard that scopes the active tool-call id for the running thread.
320///
321/// Set on entry to a tool dispatch and dropped on exit, so any hostlib
322/// builtin invoked under it (e.g. `tools/write_file`) can resolve the
323/// owning tool call without threading the id through every parameter.
324pub struct CurrentToolCallGuard {
325    active: bool,
326}
327
328impl Drop for CurrentToolCallGuard {
329    fn drop(&mut self) {
330        if self.active {
331            pop_current_tool_call();
332        }
333    }
334}
335
336/// Set the per-thread session cap. Primarily for tests; production VMs
337/// inherit the default.
338pub fn set_session_cap(cap: usize) {
339    SESSION_CAP.with(|c| c.set(cap.max(1)));
340}
341
342pub fn session_cap() -> usize {
343    SESSION_CAP.with(|c| c.get())
344}
345
346pub fn set_default_transcript_budget_policy(policy: SessionTranscriptBudgetPolicy) {
347    DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| {
348        *cell.borrow_mut() = policy.normalized();
349    });
350}
351
352pub fn reset_default_transcript_budget_policy() {
353    set_default_transcript_budget_policy(SessionTranscriptBudgetPolicy::default());
354}
355
356pub fn default_transcript_budget_policy() -> SessionTranscriptBudgetPolicy {
357    DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| cell.borrow().clone())
358}
359
360pub fn transcript_budget_policy(id: &str) -> Option<SessionTranscriptBudgetPolicy> {
361    SESSIONS.with(|s| {
362        s.borrow()
363            .get(id)
364            .map(|state| state.transcript_budget_policy.clone())
365    })
366}
367
368pub fn set_transcript_budget_policy(
369    id: &str,
370    policy: SessionTranscriptBudgetPolicy,
371) -> Result<(), String> {
372    SESSIONS.with(|s| {
373        let mut map = s.borrow_mut();
374        let Some(state) = map.get_mut(id) else {
375            return Err(format!("agent session '{id}' does not exist"));
376        };
377        let previous = state.transcript_budget_policy.clone();
378        let previous_action = state.last_transcript_budget_action.clone();
379        state.transcript_budget_policy = policy.normalized();
380        let candidate = state.transcript.clone();
381        if let Err(error) = apply_transcript_with_budget(state, candidate, "policy_update") {
382            state.transcript_budget_policy = previous;
383            state.last_transcript_budget_action = previous_action;
384            return Err(error);
385        }
386        Ok(())
387    })
388}
389
390/// Clear the calling thread's session store and its process-global sidecars.
391pub fn reset_session_store() {
392    let mut owned_session_ids: HashSet<String> =
393        SESSIONS.with(|s| s.borrow_mut().drain().map(|(id, _)| id).collect());
394    CURRENT_SESSION_STACK.with(|stack| {
395        owned_session_ids.extend(stack.borrow_mut().drain(..));
396    });
397    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().clear());
398    for session_id in owned_session_ids {
399        clear_session_changed_paths(&session_id);
400    }
401    reset_default_transcript_budget_policy();
402}
403
404pub(crate) fn push_current_session(id: String) {
405    if id.is_empty() {
406        return;
407    }
408    CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().push(id));
409}
410
411pub(crate) fn swap_current_session_stack(replacement: Vec<String>) -> Vec<String> {
412    CURRENT_SESSION_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), replacement))
413}
414
415pub(crate) fn pop_current_session() {
416    CURRENT_SESSION_STACK.with(|stack| {
417        let _ = stack.borrow_mut().pop();
418    });
419}
420
421pub fn current_session_id() -> Option<String> {
422    CURRENT_SESSION_STACK.with(|stack| stack.borrow().last().cloned())
423}
424
425pub fn current_actor_chain() -> Option<ActorChain> {
426    current_session_id().as_deref().and_then(actor_chain)
427}
428
429pub fn enter_current_session(id: impl Into<String>) -> CurrentSessionGuard {
430    let id = id.into();
431    if id.trim().is_empty() {
432        return CurrentSessionGuard { active: false };
433    }
434    push_current_session(id);
435    CurrentSessionGuard { active: true }
436}
437
438pub fn actor_chain(id: &str) -> Option<ActorChain> {
439    SESSIONS.with(|s| {
440        s.borrow()
441            .get(id)
442            .and_then(|state| state.actor_chain.clone())
443    })
444}
445
446pub fn set_actor_chain(id: &str, actor_chain: Option<ActorChain>) -> Result<bool, String> {
447    SESSIONS.with(|s| {
448        let mut map = s.borrow_mut();
449        let Some(state) = map.get_mut(id) else {
450            return Err(format!("agent session '{id}' does not exist"));
451        };
452        let changed = state.actor_chain != actor_chain;
453        state.actor_chain = actor_chain;
454        state.touch();
455        Ok(changed)
456    })
457}
458
459fn push_current_tool_call(id: String) {
460    if id.is_empty() {
461        return;
462    }
463    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().push(id));
464}
465
466fn pop_current_tool_call() {
467    CURRENT_TOOL_CALL_STACK.with(|stack| {
468        let _ = stack.borrow_mut().pop();
469    });
470}
471
472/// Return the active tool-call id for the current thread, if any.
473///
474/// Hostlib builtins consult this to attribute side-effect snapshots to
475/// the owning ACP `toolCallId` without callers passing it explicitly.
476pub fn current_tool_call_id() -> Option<String> {
477    if let Ok(id) = CURRENT_TOOL_CALL_TASK.try_with(Clone::clone) {
478        if !id.trim().is_empty() {
479            return Some(id);
480        }
481    }
482    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow().last().cloned())
483}
484
485/// Scope the active tool-call id to one async task.
486///
487/// Parallel tool dispatch runs sibling calls on the same OS thread, so
488/// thread-local guards alone cannot preserve attribution across `.await`
489/// points. Tokio task-local scoping follows the future instead.
490pub async fn scope_current_tool_call<F, T>(id: impl Into<String>, future: F) -> T
491where
492    F: Future<Output = T>,
493{
494    let id = id.into();
495    if id.trim().is_empty() {
496        future.await
497    } else {
498        CURRENT_TOOL_CALL_TASK.scope(id, future).await
499    }
500}
501
502/// Scope the active tool-call id for the duration of the returned guard.
503pub fn enter_current_tool_call(id: impl Into<String>) -> CurrentToolCallGuard {
504    let id = id.into();
505    if id.trim().is_empty() {
506        return CurrentToolCallGuard { active: false };
507    }
508    push_current_tool_call(id);
509    CurrentToolCallGuard { active: true }
510}
511
512pub fn exists(id: &str) -> bool {
513    SESSIONS.with(|s| s.borrow().contains_key(id))
514}
515
516pub fn length(id: &str) -> Option<usize> {
517    SESSIONS.with(|s| {
518        s.borrow().get(id).map(|state| {
519            state
520                .transcript
521                .as_dict()
522                .and_then(|d| d.get("messages"))
523                .and_then(|v| match v {
524                    VmValue::List(list) => Some(list.len()),
525                    _ => None,
526                })
527                .unwrap_or(0)
528        })
529    })
530}
531
532pub fn scratchpad(id: &str) -> Option<VmValue> {
533    SESSIONS.with(|s| {
534        s.borrow()
535            .get(id)
536            .and_then(|state| state.scratchpad.clone())
537    })
538}
539
540pub fn scratchpad_version(id: &str) -> Option<u64> {
541    SESSIONS.with(|s| s.borrow().get(id).map(|state| state.scratchpad_version))
542}
543
544pub fn set_scratchpad(
545    id: &str,
546    scratchpad: VmValue,
547    source: impl Into<String>,
548    reason: Option<String>,
549    metadata: serde_json::Value,
550) -> Result<u64, String> {
551    validate_scratchpad_value(&scratchpad)?;
552    SESSIONS.with(|s| {
553        let mut map = s.borrow_mut();
554        let Some(state) = map.get_mut(id) else {
555            return Err(format!("agent session '{id}' does not exist"));
556        };
557        let version = state.scratchpad_version.saturating_add(1);
558        let event = scratchpad_transcript_event(
559            "set",
560            version,
561            Some(&scratchpad),
562            source.into(),
563            reason,
564            metadata,
565        );
566        append_event_to_state(state, event, "set_scratchpad")?;
567        state.scratchpad = Some(scratchpad);
568        state.scratchpad_version = version;
569        state.touch();
570        Ok(version)
571    })
572}
573
574pub fn clear_scratchpad(
575    id: &str,
576    source: impl Into<String>,
577    reason: Option<String>,
578    metadata: serde_json::Value,
579) -> Result<u64, String> {
580    SESSIONS.with(|s| {
581        let mut map = s.borrow_mut();
582        let Some(state) = map.get_mut(id) else {
583            return Err(format!("agent session '{id}' does not exist"));
584        };
585        let version = state.scratchpad_version.saturating_add(1);
586        let event =
587            scratchpad_transcript_event("clear", version, None, source.into(), reason, metadata);
588        append_event_to_state(state, event, "clear_scratchpad")?;
589        state.scratchpad = None;
590        state.scratchpad_version = version;
591        state.touch();
592        Ok(version)
593    })
594}
595
596fn validate_scratchpad_value(value: &VmValue) -> Result<(), String> {
597    if !matches!(value, VmValue::Dict(_)) {
598        return Err("agent session scratchpad must be a dict".to_string());
599    }
600    let json = crate::llm::helpers::vm_value_to_json(value);
601    let approx_bytes = serde_json::to_vec(&json)
602        .map(|bytes| bytes.len())
603        .unwrap_or(usize::MAX);
604    if approx_bytes > MAX_SCRATCHPAD_BYTES {
605        return Err(format!(
606            "agent session scratchpad is {approx_bytes} bytes; max is {MAX_SCRATCHPAD_BYTES}"
607        ));
608    }
609    Ok(())
610}
611
612fn scratchpad_transcript_event(
613    action: &str,
614    version: u64,
615    scratchpad: Option<&VmValue>,
616    source: String,
617    reason: Option<String>,
618    metadata: serde_json::Value,
619) -> VmValue {
620    let scratchpad_json = scratchpad.map(crate::llm::helpers::vm_value_to_json);
621    let approx_bytes = scratchpad_json
622        .as_ref()
623        .and_then(|value| serde_json::to_vec(value).ok().map(|bytes| bytes.len()))
624        .unwrap_or(0);
625    let event_metadata = serde_json::json!({
626        "action": action,
627        "version": version,
628        "source": normalize_scratchpad_source(source),
629        "reason": reason.unwrap_or_default(),
630        "approx_bytes": approx_bytes,
631        "counts": scratchpad_json
632            .as_ref()
633            .map(scratchpad_counts_json)
634            .unwrap_or_else(|| serde_json::json!({})),
635        "metadata": metadata,
636    });
637    let content = format!("Agent scratchpad {action}");
638    crate::llm::helpers::transcript_event(
639        "agent_scratchpad",
640        "system",
641        "internal",
642        &content,
643        Some(event_metadata),
644    )
645}
646
647fn normalize_scratchpad_source(source: String) -> String {
648    let trimmed = source.trim();
649    if trimmed.is_empty() {
650        "harn.agent_scratchpad".to_string()
651    } else {
652        trimmed.to_string()
653    }
654}
655
656fn scratchpad_counts_json(value: &serde_json::Value) -> serde_json::Value {
657    serde_json::json!({
658        "goals": scratchpad_array_len(value, "goals"),
659        "open_items": scratchpad_array_len(value, "open_items"),
660        "facts": scratchpad_array_len(value, "facts"),
661        "refs": scratchpad_array_len(value, "refs"),
662    })
663}
664
665fn scratchpad_array_len(value: &serde_json::Value, key: &str) -> usize {
666    value
667        .get(key)
668        .and_then(serde_json::Value::as_array)
669        .map(Vec::len)
670        .unwrap_or(0)
671}
672
673pub fn snapshot(id: &str) -> Option<VmValue> {
674    SESSIONS.with(|s| s.borrow().get(id).map(session_snapshot))
675}
676
677/// Session-only fields stay on `agent_session_snapshot`.
678pub fn transcript(id: &str) -> Option<VmValue> {
679    SESSIONS.with(|s| {
680        s.borrow()
681            .get(id)
682            .map(|state| transcript_with_session_metadata(state.transcript.clone(), state))
683    })
684}
685
686/// Index the next message injected into this session will take.
687///
688/// Used to stamp a dispatch-time receipt with the assistant turn it belongs
689/// to: the calls being dispatched were parsed from the message immediately
690/// before this index. Returns `None` for an unknown session.
691pub fn next_message_index(id: &str) -> Option<usize> {
692    SESSIONS.with(|s| {
693        let map = s.borrow();
694        let state = map.get(id)?;
695        let messages = state
696            .transcript
697            .as_dict()
698            .and_then(|dict| dict.get("messages"))
699            .and_then(|value| match value {
700                VmValue::List(list) => Some(list.len()),
701                _ => None,
702            })
703            .unwrap_or(0);
704        Some(messages)
705    })
706}
707
708/// Open a session, or create it if missing. Returns the resolved id.
709///
710/// Newly-created sessions auto-register an event-log-backed sink when a
711/// generalized [`crate::event_log::EventLog`] has been installed for the
712/// current VM thread. For legacy env-driven workflows that still point
713/// `HARN_EVENT_LOG_DIR` at a directory, we preserve the older JSONL sink
714/// as a compatibility fallback. Re-opening an existing session does not
715/// re-register — sinks are per-session, owned by the first opener.
716pub fn open_or_create(id: Option<String>) -> String {
717    open_or_create_with_actor_chain(id, None)
718}
719
720pub fn open_or_create_with_actor_chain(
721    id: Option<String>,
722    requested_actor_chain: Option<ActorChain>,
723) -> String {
724    let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
725    let parent_session = current_session_id();
726    let inherited_actor_chain = requested_actor_chain
727        .clone()
728        .or_else(|| parent_session.as_deref().and_then(actor_chain));
729    let mut was_new = false;
730    let mut evicted = None;
731    SESSIONS.with(|s| {
732        let mut map = s.borrow_mut();
733        if let Some(state) = map.get_mut(&resolved) {
734            if let Some(actor_chain) = requested_actor_chain.clone() {
735                state.actor_chain = Some(actor_chain);
736            }
737            state.touch();
738            return;
739        }
740        was_new = true;
741        let cap = SESSION_CAP.with(|c| c.get());
742        if map.len() >= cap {
743            if let Some(victim) = map
744                .iter()
745                .min_by_key(|(_, state)| state.last_accessed)
746                .map(|(id, _)| id.clone())
747            {
748                map.remove(&victim);
749                evicted = Some(victim);
750            }
751        }
752        let mut state = SessionState::new(resolved.clone());
753        state.actor_chain = inherited_actor_chain.clone();
754        map.insert(resolved.clone(), state);
755    });
756    if let Some(evicted) = evicted {
757        clear_session_changed_paths(&evicted);
758    }
759    if was_new {
760        // A prior owner may have been abandoned before its receipt drained.
761        // Opening a fresh session with the same id starts a fresh receipt.
762        clear_session_changed_paths(&resolved);
763        if let Some(parent) = parent_session.as_deref() {
764            crate::agent_events::mirror_session_sinks(parent, &resolved);
765        }
766        try_register_event_log(&resolved);
767    }
768    resolved
769}
770
771pub fn open_child_session(parent_id: &str, id: Option<String>) -> String {
772    open_child_session_with_actor(parent_id, id, None)
773}
774
775pub fn open_child_session_with_actor(
776    parent_id: &str,
777    id: Option<String>,
778    actor: Option<&str>,
779) -> String {
780    let actor_chain = actor_chain(parent_id).map(|chain| match actor {
781        Some(actor) if !actor.trim().is_empty() => chain.pushed(actor.trim()),
782        _ => chain,
783    });
784    let resolved = open_or_create_with_actor_chain(id, actor_chain);
785    link_child_session(parent_id, &resolved);
786    resolved
787}
788
789pub fn link_child_session(parent_id: &str, child_id: &str) {
790    link_child_session_with_branch(parent_id, child_id, None);
791}
792
793pub fn link_child_session_with_branch(
794    parent_id: &str,
795    child_id: &str,
796    branched_at_event_index: Option<usize>,
797) {
798    if parent_id == child_id {
799        return;
800    }
801    open_or_create(Some(parent_id.to_string()));
802    open_or_create(Some(child_id.to_string()));
803    SESSIONS.with(|s| {
804        let mut map = s.borrow_mut();
805        update_lineage(&mut map, parent_id, child_id, branched_at_event_index);
806    });
807}
808
809pub fn parent_id(id: &str) -> Option<String> {
810    SESSIONS.with(|s| s.borrow().get(id).and_then(|state| state.parent_id.clone()))
811}
812
813pub fn child_ids(id: &str) -> Vec<String> {
814    SESSIONS.with(|s| {
815        s.borrow()
816            .get(id)
817            .map(|state| state.child_ids.clone())
818            .unwrap_or_default()
819    })
820}
821
822pub fn ancestry(id: &str) -> Option<SessionAncestry> {
823    SESSIONS.with(|s| {
824        let map = s.borrow();
825        let state = map.get(id)?;
826        let mut root_id = state.id.clone();
827        let mut cursor = state.parent_id.clone();
828        let mut seen = HashSet::from([state.id.clone()]);
829        while let Some(parent_id) = cursor {
830            if !seen.insert(parent_id.clone()) {
831                break;
832            }
833            root_id = parent_id.clone();
834            cursor = map
835                .get(&parent_id)
836                .and_then(|parent| parent.parent_id.clone());
837        }
838        Some(SessionAncestry {
839            parent_id: state.parent_id.clone(),
840            child_ids: state.child_ids.clone(),
841            root_id,
842        })
843    })
844}
845
846/// Auto-register a persistent sink for a newly-created session.
847/// Silent no-op on failure — a broken observability sink must never
848/// prevent a session from starting.
849fn try_register_event_log(session_id: &str) {
850    if let Some(log) = crate::event_log::active_event_log() {
851        crate::agent_events::register_sink(
852            session_id,
853            crate::agent_events::EventLogSink::new(log, session_id),
854        );
855        return;
856    }
857    let Ok(dir) = std::env::var("HARN_EVENT_LOG_DIR") else {
858        return;
859    };
860    if dir.is_empty() {
861        return;
862    }
863    let path = std::path::PathBuf::from(dir).join(format!("event_log-{session_id}.jsonl"));
864    if let Ok(sink) = crate::agent_events::JsonlEventSink::open(&path) {
865        crate::agent_events::register_sink(session_id, sink);
866    }
867}
868
869pub fn register_event_log_sink(session_id: &str) {
870    try_register_event_log(session_id);
871}
872
873pub fn close(id: &str) {
874    let removed = SESSIONS.with(|s| s.borrow_mut().remove(id).is_some());
875    if removed {
876        clear_session_changed_paths(id);
877    }
878    // Cross-thread per-session state must be released too, otherwise
879    // pending inbox entries can be delivered to a future session that
880    // happens to reuse the same id.
881    crate::orchestration::agent_inbox::clear_session(id);
882    crate::agent_events::clear_session_sinks(id);
883}
884
885pub fn close_with_status(
886    id: &str,
887    reason: impl Into<String>,
888    status: impl Into<String>,
889    metadata: serde_json::Value,
890) -> bool {
891    if !exists(id) {
892        return false;
893    }
894    let reason = reason.into();
895    let status = status.into();
896    let event_metadata = serde_json::json!({
897        "reason": reason,
898        "status": status,
899        "metadata": metadata,
900    });
901    let transcript_event = crate::llm::helpers::transcript_event(
902        "agent_session_closed",
903        "system",
904        "internal",
905        "Agent session closed",
906        Some(event_metadata),
907    );
908    let _ = append_event(id, transcript_event);
909    crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::SessionClosed {
910        session_id: id.to_string(),
911        reason,
912        status,
913        metadata,
914    });
915    close(id);
916    true
917}
918
919pub fn reset_transcript(id: &str) -> bool {
920    SESSIONS.with(|s| {
921        let mut map = s.borrow_mut();
922        let Some(state) = map.get_mut(id) else {
923            return false;
924        };
925        state.transcript = empty_transcript(id);
926        state.tool_format = None;
927        state.system_prompt = None;
928        state.scratchpad = None;
929        state.scratchpad_version = 0;
930        state.last_transcript_budget_action = None;
931        state.completed_turn_checkpoints.clear();
932        state.redo_stack.clear();
933        state.text_tool_call_seq = 0;
934        state.touch();
935        true
936    })
937}
938
939/// Copy `src`'s transcript into a new session id. Subscribers are NOT
940/// copied — a fork is a conversation branch, not an event fanout.
941///
942/// Touches `src`'s `last_accessed` before evicting, so the fork
943/// operation itself can't make `src` look stale and kick it out of
944/// the LRU just to make room for the new fork.
945pub fn fork(src_id: &str, dst_id: Option<String>) -> Option<String> {
946    let (
947        src_transcript,
948        src_tool_format,
949        src_system_prompt,
950        src_pinned_model,
951        src_pinned_reasoning_policy,
952        src_actor_chain,
953        src_workspace_anchor,
954        src_workspace_policy,
955        src_scratchpad,
956        src_scratchpad_version,
957        src_transcript_budget_policy,
958        src_last_transcript_budget_action,
959        src_text_tool_call_seq,
960        src_taint,
961        dst,
962    ) = SESSIONS.with(|s| {
963        let mut map = s.borrow_mut();
964        let src = map.get_mut(src_id)?;
965        src.touch();
966        let dst = dst_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
967        let forked_transcript = clone_transcript_with_id(&src.transcript, &dst);
968        Some((
969            forked_transcript,
970            src.tool_format.clone(),
971            src.system_prompt.clone(),
972            src.pinned_model.clone(),
973            src.pinned_reasoning_policy.clone(),
974            src.actor_chain.clone(),
975            src.workspace_anchor.clone(),
976            src.workspace_policy.clone(),
977            src.scratchpad.clone(),
978            src.scratchpad_version,
979            src.transcript_budget_policy.clone(),
980            src.last_transcript_budget_action.clone(),
981            src.text_tool_call_seq,
982            src.taint.clone(),
983            dst,
984        ))
985    })?;
986    // Ensure cap is respected when inserting the fork.
987    open_or_create(Some(dst.clone()));
988    SESSIONS.with(|s| {
989        let mut map = s.borrow_mut();
990        if let Some(state) = map.get_mut(&dst) {
991            state.transcript = src_transcript;
992            state.tool_format = src_tool_format;
993            state.system_prompt = src_system_prompt;
994            state.pinned_model = src_pinned_model;
995            state.pinned_reasoning_policy = src_pinned_reasoning_policy;
996            state.actor_chain = src_actor_chain;
997            state.workspace_anchor = src_workspace_anchor;
998            state.workspace_policy = src_workspace_policy;
999            state.scratchpad = src_scratchpad;
1000            state.scratchpad_version = src_scratchpad_version;
1001            state.transcript_budget_policy = src_transcript_budget_policy;
1002            state.last_transcript_budget_action = src_last_transcript_budget_action;
1003            state.text_tool_call_seq = src_text_tool_call_seq;
1004            state.taint = src_taint;
1005            state.touch();
1006        }
1007        update_lineage(&mut map, src_id, &dst, None);
1008    });
1009    let budget_ok = SESSIONS.with(|s| {
1010        let mut map = s.borrow_mut();
1011        let Some(state) = map.get_mut(&dst) else {
1012            return false;
1013        };
1014        let candidate = state.transcript.clone();
1015        apply_transcript_with_budget(state, candidate, "fork").is_ok()
1016    });
1017    if !budget_ok {
1018        // The lineage edge was established before the budget check ran
1019        // (update_lineage rewrites dst's transcript with the parent
1020        // metadata that the check budgets). Undo it so a rejected fork
1021        // never leaves a dangling child_id pointing at the session we
1022        // are about to close.
1023        SESSIONS.with(|s| {
1024            let mut map = s.borrow_mut();
1025            if let Some(parent) = map.get_mut(src_id) {
1026                parent.child_ids.retain(|id| id != &dst);
1027            }
1028        });
1029        close(&dst);
1030        return None;
1031    }
1032    // open_or_create evicts BEFORE inserting, so the dst slot is
1033    // guaranteed once we get here. The existence check is cheap
1034    // insurance against a future refactor that breaks that invariant.
1035    if exists(&dst) {
1036        Some(dst)
1037    } else {
1038        None
1039    }
1040}
1041
1042/// Fork `src_id` and truncate the destination transcript to the
1043/// first `keep_first` messages (#105 — branch-replay). Pairs with the
1044/// scrubber: the host picks an event index, rebuilds a message count,
1045/// and calls this to spawn a live sibling session that resumes from
1046/// the rebuilt state. Subscribers are not carried over (same as
1047/// `fork`), so sibling events don't double-fan into the parent's
1048/// consumers.
1049///
1050/// Returns the new session id on success, `None` if `src_id` doesn't
1051/// exist.
1052pub fn fork_at(src_id: &str, keep_first: usize, dst_id: Option<String>) -> Option<String> {
1053    let branched_at_event_index = SESSIONS.with(|s| {
1054        let map = s.borrow();
1055        let src = map.get(src_id)?;
1056        Some(branch_event_index(&src.transcript, keep_first))
1057    })?;
1058    let new_id = fork(src_id, dst_id)?;
1059    link_child_session_with_branch(src_id, &new_id, Some(branched_at_event_index));
1060    truncate(&new_id, keep_first).ok().flatten()?;
1061    Some(new_id)
1062}
1063
1064mod truncation;
1065use truncation::truncate_state;
1066pub use truncation::{trim, truncate};
1067
1068mod pop_last_assistant;
1069pub use pop_last_assistant::pop_last_if_assistant;
1070mod restore_message_event_ids;
1071pub(crate) use restore_message_event_ids::restore_message_event_ids;
1072
1073/// Append a message dict to the session transcript. The message must
1074/// have at least a string `role`; anything else is merged verbatim.
1075///
1076/// Returns the index the message took in the session's message list. Callers
1077/// that record a typed receipt for the message they just injected (tool
1078/// results, which must name the call they answer) use it to bind the receipt
1079/// to the exact message rather than to "whatever came next".
1080pub fn inject_message(id: &str, message: VmValue) -> Result<usize, String> {
1081    let Some(msg_dict) = message.as_dict().cloned() else {
1082        return Err("agent_session_inject: message must be a dict".into());
1083    };
1084    let role_ok = matches!(msg_dict.get("role"), Some(VmValue::String(_)));
1085    if !role_ok {
1086        return Err(
1087            "agent_session_inject: message must have a string `role` (user|assistant|tool_result|system)"
1088                .into(),
1089        );
1090    }
1091    SESSIONS.with(|s| {
1092        let mut map = s.borrow_mut();
1093        let Some(state) = map.get_mut(id) else {
1094            return Err(format!("agent_session_inject: unknown session id '{id}'"));
1095        };
1096        let dict = state
1097            .transcript
1098            .as_dict()
1099            .cloned()
1100            .unwrap_or_else(crate::value::DictMap::new);
1101        let mut messages: Vec<VmValue> = match dict.get("messages") {
1102            Some(VmValue::List(list)) => list.iter().cloned().collect(),
1103            _ => Vec::new(),
1104        };
1105        let mut events: Vec<VmValue> = match dict.get("events") {
1106            Some(VmValue::List(list)) => list.iter().cloned().collect(),
1107            _ => crate::llm::helpers::transcript_events_from_messages(&messages),
1108        };
1109        let new_message = VmValue::dict(msg_dict);
1110        let message_index = messages.len();
1111        let transcript_event = crate::llm::helpers::transcript_event_from_message(&new_message);
1112        events.push(transcript_event.clone());
1113        messages.push(new_message);
1114        let mut next = dict;
1115        next.insert(
1116            crate::value::intern_key("events"),
1117            VmValue::List(std::sync::Arc::new(events)),
1118        );
1119        next.insert(
1120            crate::value::intern_key("messages"),
1121            VmValue::List(std::sync::Arc::new(messages)),
1122        );
1123        let persisted_message = next
1124            .get("messages")
1125            .and_then(|value| match value {
1126                VmValue::List(list) => list.get(message_index).cloned(),
1127                _ => None,
1128            })
1129            .unwrap_or(VmValue::Nil);
1130        apply_transcript_with_budget(state, VmValue::dict(next), "inject_message")?;
1131        crate::agent_session_journal::enqueue_message(
1132            &mut state.transcript_journal,
1133            crate::llm::helpers::vm_value_to_json(&transcript_event),
1134            crate::llm::helpers::vm_value_to_json(&persisted_message),
1135        );
1136        emit_identified_user_message_event(id, &persisted_message);
1137        emit_llm_message_event(id, message_index, &persisted_message);
1138        Ok(message_index)
1139    })
1140}
1141
1142#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
1143#[serde(rename_all = "snake_case")]
1144pub enum HostInjectionKind {
1145    HostToolResult,
1146    HostAttachment,
1147}
1148
1149/// Create a new session from a reconstructed message list.
1150///
1151/// This is intentionally an all-at-once write instead of repeated
1152/// `inject_message` calls: importing a transcript should not re-emit
1153/// each historic turn into the active observability sidecar.
1154pub fn seed_from_messages(
1155    id: Option<String>,
1156    messages: &[serde_json::Value],
1157    metadata: serde_json::Value,
1158    system_prompt: Option<String>,
1159    tool_format: Option<String>,
1160) -> Result<String, String> {
1161    let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1162    if exists(&resolved) {
1163        return Err(format!("agent session '{resolved}' already exists"));
1164    }
1165    open_or_create(Some(resolved.clone()));
1166    SESSIONS.with(|s| {
1167        let mut map = s.borrow_mut();
1168        let Some(state) = map.get_mut(&resolved) else {
1169            return Err(format!("failed to create agent session '{resolved}'"));
1170        };
1171        state.tool_format = tool_format.filter(|value| !value.trim().is_empty());
1172        state.system_prompt = system_prompt.filter(|value| !value.trim().is_empty());
1173
1174        let mut metadata = metadata
1175            .as_object()
1176            .cloned()
1177            .unwrap_or_else(serde_json::Map::new);
1178        if let Some(tool_format) = state.tool_format.as_ref() {
1179            metadata.insert(
1180                "tool_format".to_string(),
1181                serde_json::Value::String(tool_format.clone()),
1182            );
1183            metadata.insert(
1184                "tool_mode_locked".to_string(),
1185                serde_json::Value::Bool(true),
1186            );
1187        }
1188        if let Some(system_prompt) = state.system_prompt.as_ref() {
1189            metadata.insert(
1190                "system_prompt".to_string(),
1191                crate::llm::helpers::system_prompt_metadata(system_prompt),
1192            );
1193        }
1194        let text_tool_call_seq = next_text_tool_call_seq_from_json_messages(messages);
1195        let vm_messages = crate::llm::helpers::json_messages_to_vm(messages);
1196        let candidate = crate::llm::helpers::new_transcript_with(
1197            Some(resolved.clone()),
1198            vm_messages,
1199            None,
1200            Some(crate::stdlib::json_to_vm_value(&serde_json::Value::Object(
1201                metadata,
1202            ))),
1203        );
1204        apply_transcript_with_budget(state, candidate, "seed_from_messages")?;
1205        state.text_tool_call_seq = text_tool_call_seq;
1206        Ok(resolved)
1207    })
1208}
1209
1210/// Load the messages vec (as JSON) for this session, for use as prefix
1211/// to an agent_loop run. Returns an empty vec if the session doesn't
1212/// exist or has no messages.
1213/// Append a transcript event to the session without mutating its
1214/// message list. Used for orchestration-side lineage events (sub-agent
1215/// spawn/completion, workflow hooks, etc.) that should survive
1216/// persistence/replay without being replayed back into the model as
1217/// conversational messages.
1218pub fn append_event(id: &str, event: VmValue) -> Result<(), String> {
1219    let Some(event_dict) = event.as_dict() else {
1220        return Err("agent_session_append_event: event must be a dict".into());
1221    };
1222    let kind_ok = matches!(event_dict.get("kind"), Some(VmValue::String(_)));
1223    if !kind_ok {
1224        return Err("agent_session_append_event: event must have a string `kind`".into());
1225    }
1226    SESSIONS.with(|s| {
1227        let mut map = s.borrow_mut();
1228        let Some(state) = map.get_mut(id) else {
1229            return Err(format!(
1230                "agent_session_append_event: unknown session id '{id}'"
1231            ));
1232        };
1233        append_event_to_state(state, event, "append_event")?;
1234        Ok(())
1235    })
1236}
1237
1238fn append_event_to_state(
1239    state: &mut SessionState,
1240    event: VmValue,
1241    action: &str,
1242) -> Result<(), String> {
1243    let journal_event = crate::llm::helpers::vm_value_to_json(&event);
1244    let dict = state
1245        .transcript
1246        .as_dict()
1247        .cloned()
1248        .unwrap_or_else(crate::value::DictMap::new);
1249    let mut events: Vec<VmValue> = match dict.get("events") {
1250        Some(VmValue::List(list)) => list.iter().cloned().collect(),
1251        _ => dict
1252            .get("messages")
1253            .and_then(|value| match value {
1254                VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
1255                _ => None,
1256            })
1257            .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
1258            .unwrap_or_default(),
1259    };
1260    events.push(event);
1261    let mut next = dict;
1262    next.insert(
1263        crate::value::intern_key("events"),
1264        VmValue::List(std::sync::Arc::new(events)),
1265    );
1266    apply_transcript_with_budget(state, VmValue::dict(next), action)?;
1267    crate::agent_session_journal::enqueue_audit_event(&mut state.transcript_journal, journal_event);
1268    Ok(())
1269}
1270
1271/// Replace the transcript's message list wholesale. Used by the
1272/// in-loop compaction path, which operates on JSON messages.
1273pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
1274    replace_messages_with_summary(id, messages, None)
1275}
1276
1277/// Replace the transcript's message list and optionally update the
1278/// `summary` field on the persisted transcript. The compaction path
1279/// uses this to publish the human-readable rollup line that
1280/// `transcript_summary(transcript)` exposes to host code.
1281pub fn replace_messages_with_summary(
1282    id: &str,
1283    messages: &[serde_json::Value],
1284    summary: Option<&str>,
1285) -> Result<(), String> {
1286    SESSIONS.with(|s| {
1287        let mut map = s.borrow_mut();
1288        let Some(state) = map.get_mut(id) else {
1289            return Err(format!(
1290                "agent_session_replace_messages: unknown session id '{id}'"
1291            ));
1292        };
1293        let dict = state
1294            .transcript
1295            .as_dict()
1296            .cloned()
1297            .unwrap_or_else(crate::value::DictMap::new);
1298        let vm_messages: Vec<VmValue> = messages
1299            .iter()
1300            .map(crate::stdlib::json_to_vm_value)
1301            .collect();
1302        let replacement_events = crate::llm::helpers::transcript_events_from_messages(&vm_messages);
1303        let source_event_ids = replacement_events
1304            .iter()
1305            .map(|event| {
1306                event
1307                    .as_dict()
1308                    .and_then(|event| event.get("id"))
1309                    .map(VmValue::display)
1310            })
1311            .collect();
1312        let mut next = dict;
1313        next.insert(
1314            crate::value::intern_key("events"),
1315            VmValue::List(std::sync::Arc::new(replacement_events)),
1316        );
1317        next.insert(
1318            crate::value::intern_key("messages"),
1319            VmValue::List(std::sync::Arc::new(vm_messages)),
1320        );
1321        if let Some(summary) = summary {
1322            next.put_str("summary", summary);
1323        } else {
1324            next.remove("summary");
1325        }
1326        apply_transcript_with_budget(state, VmValue::dict(next), "replace_messages")?;
1327        crate::agent_session_journal::enqueue_messages_replaced(
1328            &mut state.transcript_journal,
1329            messages.to_vec(),
1330            summary.map(str::to_string),
1331            source_event_ids,
1332        );
1333        Ok(())
1334    })
1335}
1336
1337pub fn append_subscriber(id: &str, callback: VmValue) {
1338    open_or_create(Some(id.to_string()));
1339    SESSIONS.with(|s| {
1340        if let Some(state) = s.borrow_mut().get_mut(id) {
1341            state.subscribers.push(callback);
1342            state.touch();
1343        }
1344    });
1345}
1346
1347pub fn subscribers_for(id: &str) -> Vec<VmValue> {
1348    SESSIONS.with(|s| {
1349        s.borrow()
1350            .get(id)
1351            .map(|state| state.subscribers.clone())
1352            .unwrap_or_default()
1353    })
1354}
1355
1356pub fn subscriber_count(id: &str) -> usize {
1357    SESSIONS.with(|s| {
1358        s.borrow()
1359            .get(id)
1360            .map(|state| state.subscribers.len())
1361            .unwrap_or(0)
1362    })
1363}
1364
1365/// Persist the set of active skill names for session resume. Called at
1366/// the end of an agent_loop run; the next `open_or_create` for this id
1367/// reads them back via [`active_skills`].
1368#[cfg(test)]
1369#[path = "agent_sessions_tests.rs"]
1370mod tests;