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