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, BTreeSet, HashMap, HashSet};
22use std::future::Future;
23use std::path::{Path, PathBuf};
24use std::sync::{Mutex, OnceLock};
25use std::time::Instant;
26
27use crate::actor_chain::ActorChain;
28use crate::agent_transcript_budget::{
29    apply_transcript_with_budget, transcript_budget_policy_json, transcript_budget_usage_json,
30    transcript_message_count, transcript_usage,
31};
32use crate::runtime_limits::RuntimeLimits;
33use crate::value::VmValue;
34use crate::workspace_anchor::{
35    MountMode, MountedRoot, WorkspaceAnchor, WorkspacePolicy, WORKSPACE_ANCHOR_METADATA_KEY,
36};
37
38const LIVE_CLIENT_EVENT_KIND: &str = "live_session_client";
39const LIVE_CLIENT_PERMISSION_EVENT_KIND: &str = "live_session_permission_route";
40
41/// Default cap on concurrent sessions per VM thread. Beyond this the
42/// least-recently-accessed session is evicted on the next `open`.
43pub const DEFAULT_SESSION_CAP: usize = RuntimeLimits::DEFAULT.max_agent_sessions;
44
45/// Default cap on retained prompt-visible messages per session. The
46/// limit is intentionally high enough for normal long-running agents
47/// while still bounding accidental unbounded growth.
48pub const DEFAULT_TRANSCRIPT_MESSAGE_CAP: usize = 4096;
49
50/// Default cap on retained transcript audit events per session. Events
51/// include message-derived entries plus orchestration lifecycle records.
52pub const DEFAULT_TRANSCRIPT_EVENT_CAP: usize = 32768;
53pub const MAX_SCRATCHPAD_BYTES: usize = 16 * 1024;
54#[cfg(debug_assertions)]
55const CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC: &str = "HARN-CACHE-001";
56
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub enum TranscriptBudgetRecovery {
59    Reject,
60    Trim { keep_last: usize },
61    Compact { keep_last: usize },
62}
63
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub struct SessionTranscriptBudgetPolicy {
66    pub max_messages: usize,
67    pub max_events: usize,
68    pub max_approx_bytes: Option<usize>,
69    pub recovery: TranscriptBudgetRecovery,
70}
71
72impl SessionTranscriptBudgetPolicy {
73    pub fn reject(max_messages: usize, max_events: usize) -> Self {
74        Self {
75            max_messages: max_messages.max(1),
76            max_events: max_events.max(1),
77            max_approx_bytes: None,
78            recovery: TranscriptBudgetRecovery::Reject,
79        }
80    }
81
82    pub fn trim(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
83        Self {
84            max_messages: max_messages.max(1),
85            max_events: max_events.max(1),
86            max_approx_bytes: None,
87            recovery: TranscriptBudgetRecovery::Trim { keep_last },
88        }
89    }
90
91    pub fn compact(max_messages: usize, max_events: usize, keep_last: usize) -> Self {
92        Self {
93            max_messages: max_messages.max(1),
94            max_events: max_events.max(1),
95            max_approx_bytes: None,
96            recovery: TranscriptBudgetRecovery::Compact { keep_last },
97        }
98    }
99
100    pub fn with_max_approx_bytes(mut self, max_approx_bytes: Option<usize>) -> Self {
101        self.max_approx_bytes = max_approx_bytes.map(|limit| limit.max(1));
102        self
103    }
104
105    pub(crate) fn normalized(&self) -> Self {
106        Self {
107            max_messages: self.max_messages.max(1),
108            max_events: self.max_events.max(1),
109            max_approx_bytes: self.max_approx_bytes.map(|limit| limit.max(1)),
110            recovery: self.recovery.clone(),
111        }
112    }
113}
114
115impl Default for SessionTranscriptBudgetPolicy {
116    fn default() -> Self {
117        Self::reject(DEFAULT_TRANSCRIPT_MESSAGE_CAP, DEFAULT_TRANSCRIPT_EVENT_CAP)
118    }
119}
120
121pub struct SessionState {
122    pub id: String,
123    pub transcript: VmValue,
124    pub subscribers: Vec<VmValue>,
125    pub created_at: String,
126    pub last_accessed: Instant,
127    pub parent_id: Option<String>,
128    pub child_ids: Vec<String>,
129    pub branched_at_event_index: Option<usize>,
130    pub actor_chain: Option<ActorChain>,
131    /// Names of skills that were active at the end of the most recent
132    /// `agent_loop` run on this session. Empty when no skills were
133    /// matched, when the skill system wasn't used, or when the
134    /// deactivation phase cleared them. Re-entering the session
135    /// restores these as the initial active set before matching runs.
136    pub active_skills: Vec<String>,
137    /// Tool-calling protocol claimed by the first agent loop that uses
138    /// this session. A transcript is only replayable under the same
139    /// contract that produced its prompt/history.
140    pub tool_format: Option<String>,
141    /// Stable session-level system prompt material. This is transcript
142    /// metadata, not a replay message: providers receive it through
143    /// their system/developer instruction channel on each call.
144    pub system_prompt: Option<String>,
145    /// Session-pinned model selector. When set, `llm_call` invocations
146    /// that do not pass an explicit `model:` option resolve to this
147    /// selector instead of `HARN_LLM_MODEL` / provider defaults. Mid-
148    /// session swap is exposed over ACP via `session/set_config_option`
149    /// (configId="model").
150    pub pinned_model: Option<String>,
151    /// Session-pinned high-level reasoning policy. When set, `llm_call`
152    /// invocations that do not pass explicit `thinking` or
153    /// `reasoning_effort` options resolve this provider-aware policy into
154    /// the route's native thinking shape. Exposed over ACP as
155    /// `session/set_config_option(configId="thought_level")`.
156    pub pinned_reasoning_policy: Option<String>,
157    /// Session-local workspace defaults. Persona and host policy layers
158    /// can update this without rewriting the current anchor.
159    pub workspace_policy: WorkspacePolicy,
160    /// Typed workspace anchor for the session. Primary path plus any
161    /// additional mounted roots; consumed by permission matchers, the
162    /// bundle exporter, and host-side cross-project handoff flows
163    /// (epic #2208). `None` until a host opens the session with one or
164    /// the ACP `reanchor` / `add_root` primitives populate it.
165    pub workspace_anchor: Option<WorkspaceAnchor>,
166    /// Small session-local working memory rendered into agent prompts by the
167    /// Harn stdlib agent loop. This is live state, not a replayed message.
168    pub scratchpad: Option<VmValue>,
169    pub scratchpad_version: u64,
170    pub transcript_budget_policy: SessionTranscriptBudgetPolicy,
171    pub last_transcript_budget_action: Option<serde_json::Value>,
172    pub live_clients: BTreeMap<String, LiveSessionClient>,
173    pub live_controller_id: Option<String>,
174    pub completed_turn_checkpoints: Vec<SessionTurnCheckpoint>,
175    pub redo_stack: Vec<SessionRedoEntry>,
176    pub text_tool_call_seq: u64,
177}
178
179impl SessionState {
180    fn new(id: String) -> Self {
181        let now = Instant::now();
182        let transcript = empty_transcript(&id);
183        Self {
184            id,
185            transcript,
186            subscribers: Vec::new(),
187            created_at: crate::orchestration::now_rfc3339(),
188            last_accessed: now,
189            parent_id: None,
190            child_ids: Vec::new(),
191            branched_at_event_index: None,
192            actor_chain: None,
193            active_skills: Vec::new(),
194            tool_format: None,
195            system_prompt: None,
196            pinned_model: None,
197            pinned_reasoning_policy: None,
198            workspace_policy: WorkspacePolicy::default(),
199            workspace_anchor: None,
200            scratchpad: None,
201            scratchpad_version: 0,
202            transcript_budget_policy: default_transcript_budget_policy(),
203            last_transcript_budget_action: None,
204            live_clients: BTreeMap::new(),
205            live_controller_id: None,
206            completed_turn_checkpoints: Vec::new(),
207            redo_stack: Vec::new(),
208            text_tool_call_seq: 0,
209        }
210    }
211
212    fn touch(&mut self) {
213        self.last_accessed = Instant::now();
214    }
215
216    pub(crate) fn replace_transcript(&mut self, transcript: VmValue) {
217        if !crate::values_equal(&self.transcript, &transcript) {
218            self.redo_stack.clear();
219        }
220        self.transcript = transcript;
221        self.touch();
222    }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq)]
226pub enum LiveClientMode {
227    Observer,
228    Controller,
229}
230
231impl LiveClientMode {
232    fn as_str(&self) -> &'static str {
233        match self {
234            Self::Observer => "observer",
235            Self::Controller => "controller",
236        }
237    }
238}
239
240#[derive(Clone, Debug, PartialEq, Eq)]
241pub struct LiveSessionClient {
242    pub client_id: String,
243    pub mode: LiveClientMode,
244    pub attached_at: String,
245    pub last_seen_at: String,
246    pub prompt_injection: bool,
247    pub permission_routing: bool,
248    pub metadata: serde_json::Value,
249}
250
251#[derive(Clone, Debug, PartialEq, Eq)]
252pub struct AttachLiveClient {
253    pub client_id: String,
254    pub mode: LiveClientMode,
255    pub takeover: bool,
256    pub prompt_injection: bool,
257    pub permission_routing: bool,
258    pub metadata: serde_json::Value,
259}
260
261#[derive(Clone, Debug, PartialEq, Eq)]
262pub struct LiveClientChange {
263    pub client: Option<LiveSessionClient>,
264    pub previous_controller_id: Option<String>,
265    pub active_controller_id: Option<String>,
266    pub clients: Vec<LiveSessionClient>,
267}
268
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct SessionAncestry {
271    pub parent_id: Option<String>,
272    pub child_ids: Vec<String>,
273    pub root_id: String,
274}
275
276#[derive(Clone, Debug, PartialEq, Eq)]
277pub struct SessionTruncateResult {
278    pub kept_turn_count: usize,
279    pub removed_turn_count: usize,
280    pub new_tip_turn_id: Option<String>,
281}
282
283#[derive(Clone, Debug, PartialEq, Eq)]
284pub struct SessionCheckpointSummary {
285    pub checkpoint_id: String,
286    pub before_message_count: usize,
287    pub after_message_count: usize,
288    pub fs_snapshot_ids: Vec<String>,
289}
290
291#[derive(Clone, Debug)]
292pub struct SessionTurnCheckpoint {
293    pub checkpoint_id: String,
294    pub completed_at: String,
295    pub before_transcript: VmValue,
296    pub after_transcript: VmValue,
297    pub before_message_count: usize,
298    pub after_message_count: usize,
299    pub fs_snapshot_ids: Vec<String>,
300}
301
302#[derive(Clone, Debug)]
303pub struct SessionRedoEntry {
304    pub checkpoint: SessionTurnCheckpoint,
305    pub redo_fs_snapshot_ids: Vec<String>,
306}
307
308#[derive(Clone, Debug, PartialEq, Eq)]
309pub enum SessionCheckpointError {
310    UnknownSession,
311    NoCheckpoint,
312    NoRedo,
313}
314
315#[derive(Clone, Debug, PartialEq, Eq)]
316pub struct SessionCheckpointOutcome {
317    pub status: &'static str,
318    pub checkpoint: SessionCheckpointSummary,
319    pub redo_fs_snapshot_ids: Vec<String>,
320}
321
322#[derive(Clone, Debug, PartialEq, Eq)]
323pub struct ReminderInjectionReport {
324    pub reminder_id: String,
325    pub deduped_count: usize,
326}
327
328thread_local! {
329    static SESSIONS: RefCell<HashMap<String, SessionState>> = RefCell::new(HashMap::new());
330    static SESSION_CAP: Cell<usize> = const { Cell::new(DEFAULT_SESSION_CAP) };
331    static DEFAULT_TRANSCRIPT_BUDGET_POLICY: RefCell<SessionTranscriptBudgetPolicy> =
332        RefCell::new(SessionTranscriptBudgetPolicy::default());
333    static CURRENT_SESSION_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
334    static CURRENT_TOOL_CALL_STACK: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
335}
336
337tokio::task_local! {
338    static CURRENT_TOOL_CALL_TASK: String;
339}
340
341/// Per-session set of filesystem paths a session has mutated (written or
342/// deleted) via the deterministic hostlib write surface. Keyed by session id
343/// and process-global (not thread-local like [`SESSIONS`]) so a background
344/// fan-out child's writes are attributed correctly regardless of which runtime
345/// thread serviced them. Fed by the single hostlib write chokepoint
346/// (`harn-hostlib`'s `fs_snapshot::auto_capture_for_write`), which is reached
347/// only AFTER a write is policy-approved and about to touch disk — so this
348/// reflects ACTUAL committed mutations, not denied/aborted attempts. The
349/// authoritative source for a sub-agent's `files_written` receipt field.
350static SESSION_CHANGED_PATHS: OnceLock<Mutex<BTreeMap<String, BTreeSet<String>>>> = OnceLock::new();
351
352fn session_changed_paths_store() -> &'static Mutex<BTreeMap<String, BTreeSet<String>>> {
353    SESSION_CHANGED_PATHS.get_or_init(|| Mutex::new(BTreeMap::new()))
354}
355
356/// Record that `path` was mutated (written/deleted) by `session_id`. Called from
357/// the hostlib write chokepoint; a no-op when either argument is empty.
358pub fn record_session_changed_path(session_id: &str, path: &str) {
359    if session_id.is_empty() || path.is_empty() {
360        return;
361    }
362    if let Ok(mut store) = session_changed_paths_store().lock() {
363        store
364            .entry(session_id.to_string())
365            .or_default()
366            .insert(path.to_string());
367    }
368}
369
370/// The sorted set of paths `session_id` has mutated so far (empty when none).
371/// Non-draining: safe to call for introspection without disturbing the record.
372pub fn session_changed_paths(session_id: &str) -> Vec<String> {
373    session_changed_paths_store()
374        .lock()
375        .ok()
376        .and_then(|store| {
377            store
378                .get(session_id)
379                .map(|set| set.iter().cloned().collect())
380        })
381        .unwrap_or_default()
382}
383
384/// Read AND remove a session's mutated-path set. Used at sub-agent teardown so
385/// the receipt captures the child's writes exactly once and the global map does
386/// not grow unbounded across a long-lived daemon's many fan-outs.
387pub fn take_session_changed_paths(session_id: &str) -> Vec<String> {
388    session_changed_paths_store()
389        .lock()
390        .ok()
391        .and_then(|mut store| store.remove(session_id))
392        .map(|set| set.into_iter().collect())
393        .unwrap_or_default()
394}
395
396/// Drop a session's recorded mutated paths (explicit teardown / test reset).
397pub fn clear_session_changed_paths(session_id: &str) {
398    if let Ok(mut store) = session_changed_paths_store().lock() {
399        store.remove(session_id);
400    }
401}
402
403pub struct CurrentSessionGuard {
404    active: bool,
405}
406
407impl Drop for CurrentSessionGuard {
408    fn drop(&mut self) {
409        if self.active {
410            pop_current_session();
411        }
412    }
413}
414
415/// RAII guard that scopes the active tool-call id for the running thread.
416///
417/// Set on entry to a tool dispatch and dropped on exit, so any hostlib
418/// builtin invoked under it (e.g. `tools/write_file`) can resolve the
419/// owning tool call without threading the id through every parameter.
420pub struct CurrentToolCallGuard {
421    active: bool,
422}
423
424impl Drop for CurrentToolCallGuard {
425    fn drop(&mut self) {
426        if self.active {
427            pop_current_tool_call();
428        }
429    }
430}
431
432/// Set the per-thread session cap. Primarily for tests; production VMs
433/// inherit the default.
434pub fn set_session_cap(cap: usize) {
435    SESSION_CAP.with(|c| c.set(cap.max(1)));
436}
437
438pub fn session_cap() -> usize {
439    SESSION_CAP.with(|c| c.get())
440}
441
442pub fn set_default_transcript_budget_policy(policy: SessionTranscriptBudgetPolicy) {
443    DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| {
444        *cell.borrow_mut() = policy.normalized();
445    });
446}
447
448pub fn reset_default_transcript_budget_policy() {
449    set_default_transcript_budget_policy(SessionTranscriptBudgetPolicy::default());
450}
451
452pub fn default_transcript_budget_policy() -> SessionTranscriptBudgetPolicy {
453    DEFAULT_TRANSCRIPT_BUDGET_POLICY.with(|cell| cell.borrow().clone())
454}
455
456pub fn transcript_budget_policy(id: &str) -> Option<SessionTranscriptBudgetPolicy> {
457    SESSIONS.with(|s| {
458        s.borrow()
459            .get(id)
460            .map(|state| state.transcript_budget_policy.clone())
461    })
462}
463
464pub fn set_transcript_budget_policy(
465    id: &str,
466    policy: SessionTranscriptBudgetPolicy,
467) -> Result<(), String> {
468    SESSIONS.with(|s| {
469        let mut map = s.borrow_mut();
470        let Some(state) = map.get_mut(id) else {
471            return Err(format!("agent session '{id}' does not exist"));
472        };
473        let previous = state.transcript_budget_policy.clone();
474        let previous_action = state.last_transcript_budget_action.clone();
475        state.transcript_budget_policy = policy.normalized();
476        let candidate = state.transcript.clone();
477        if let Err(error) = apply_transcript_with_budget(state, candidate, "policy_update") {
478            state.transcript_budget_policy = previous;
479            state.last_transcript_budget_action = previous_action;
480            return Err(error);
481        }
482        Ok(())
483    })
484}
485
486/// Clear the session store. Wired into `reset_llm_state` for test isolation.
487pub fn reset_session_store() {
488    SESSIONS.with(|s| s.borrow_mut().clear());
489    CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().clear());
490    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().clear());
491    reset_default_transcript_budget_policy();
492}
493
494pub(crate) fn push_current_session(id: String) {
495    if id.is_empty() {
496        return;
497    }
498    CURRENT_SESSION_STACK.with(|stack| stack.borrow_mut().push(id));
499}
500
501pub(crate) fn swap_current_session_stack(replacement: Vec<String>) -> Vec<String> {
502    CURRENT_SESSION_STACK.with(|stack| std::mem::replace(&mut *stack.borrow_mut(), replacement))
503}
504
505pub(crate) fn pop_current_session() {
506    CURRENT_SESSION_STACK.with(|stack| {
507        let _ = stack.borrow_mut().pop();
508    });
509}
510
511pub fn current_session_id() -> Option<String> {
512    CURRENT_SESSION_STACK.with(|stack| stack.borrow().last().cloned())
513}
514
515pub(crate) fn next_text_tool_call_seq(id: &str) -> Option<u64> {
516    SESSIONS.with(|s| {
517        let mut map = s.borrow_mut();
518        let state = map.get_mut(id)?;
519        let seq = state.text_tool_call_seq;
520        state.text_tool_call_seq = state.text_tool_call_seq.checked_add(1).unwrap_or(0);
521        state.touch();
522        Some(seq)
523    })
524}
525
526fn parse_text_tool_call_seq(id: &str) -> Option<u64> {
527    let digits = id.strip_prefix("tc_")?;
528    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
529        return None;
530    }
531    digits.parse().ok()
532}
533
534fn update_next_text_tool_call_seq(value: &serde_json::Value, next_seq: &mut u64) {
535    match value {
536        serde_json::Value::Array(items) => {
537            for item in items {
538                update_next_text_tool_call_seq(item, next_seq);
539            }
540        }
541        serde_json::Value::Object(map) => {
542            for key in ["id", "tool_call_id"] {
543                if let Some(seq) = map
544                    .get(key)
545                    .and_then(serde_json::Value::as_str)
546                    .and_then(parse_text_tool_call_seq)
547                {
548                    *next_seq = (*next_seq).max(seq.saturating_add(1));
549                }
550            }
551            for item in map.values() {
552                update_next_text_tool_call_seq(item, next_seq);
553            }
554        }
555        _ => {}
556    }
557}
558
559fn next_text_tool_call_seq_from_json_messages(messages: &[serde_json::Value]) -> u64 {
560    let mut next_seq = 0;
561    for message in messages {
562        update_next_text_tool_call_seq(message, &mut next_seq);
563    }
564    next_seq
565}
566
567fn next_text_tool_call_seq_from_transcript(transcript: &VmValue) -> u64 {
568    let Some(dict) = transcript.as_dict() else {
569        return 0;
570    };
571    let Some(VmValue::List(messages)) = dict.get("messages") else {
572        return 0;
573    };
574    next_text_tool_call_seq_from_json_messages(
575        &messages
576            .iter()
577            .map(crate::llm::helpers::vm_value_to_json)
578            .collect::<Vec<_>>(),
579    )
580}
581
582pub fn current_actor_chain() -> Option<ActorChain> {
583    current_session_id().as_deref().and_then(actor_chain)
584}
585
586pub fn enter_current_session(id: impl Into<String>) -> CurrentSessionGuard {
587    let id = id.into();
588    if id.trim().is_empty() {
589        return CurrentSessionGuard { active: false };
590    }
591    push_current_session(id);
592    CurrentSessionGuard { active: true }
593}
594
595pub fn actor_chain(id: &str) -> Option<ActorChain> {
596    SESSIONS.with(|s| {
597        s.borrow()
598            .get(id)
599            .and_then(|state| state.actor_chain.clone())
600    })
601}
602
603pub fn set_actor_chain(id: &str, actor_chain: Option<ActorChain>) -> Result<bool, String> {
604    SESSIONS.with(|s| {
605        let mut map = s.borrow_mut();
606        let Some(state) = map.get_mut(id) else {
607            return Err(format!("agent session '{id}' does not exist"));
608        };
609        let changed = state.actor_chain != actor_chain;
610        state.actor_chain = actor_chain;
611        state.touch();
612        Ok(changed)
613    })
614}
615
616fn push_current_tool_call(id: String) {
617    if id.is_empty() {
618        return;
619    }
620    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow_mut().push(id));
621}
622
623fn pop_current_tool_call() {
624    CURRENT_TOOL_CALL_STACK.with(|stack| {
625        let _ = stack.borrow_mut().pop();
626    });
627}
628
629/// Return the active tool-call id for the current thread, if any.
630///
631/// Hostlib builtins consult this to attribute side-effect snapshots to
632/// the owning ACP `toolCallId` without callers passing it explicitly.
633pub fn current_tool_call_id() -> Option<String> {
634    if let Ok(id) = CURRENT_TOOL_CALL_TASK.try_with(Clone::clone) {
635        if !id.trim().is_empty() {
636            return Some(id);
637        }
638    }
639    CURRENT_TOOL_CALL_STACK.with(|stack| stack.borrow().last().cloned())
640}
641
642/// Scope the active tool-call id to one async task.
643///
644/// Parallel tool dispatch runs sibling calls on the same OS thread, so
645/// thread-local guards alone cannot preserve attribution across `.await`
646/// points. Tokio task-local scoping follows the future instead.
647pub async fn scope_current_tool_call<F, T>(id: impl Into<String>, future: F) -> T
648where
649    F: Future<Output = T>,
650{
651    let id = id.into();
652    if id.trim().is_empty() {
653        future.await
654    } else {
655        CURRENT_TOOL_CALL_TASK.scope(id, future).await
656    }
657}
658
659/// Scope the active tool-call id for the duration of the returned guard.
660pub fn enter_current_tool_call(id: impl Into<String>) -> CurrentToolCallGuard {
661    let id = id.into();
662    if id.trim().is_empty() {
663        return CurrentToolCallGuard { active: false };
664    }
665    push_current_tool_call(id);
666    CurrentToolCallGuard { active: true }
667}
668
669pub fn exists(id: &str) -> bool {
670    SESSIONS.with(|s| s.borrow().contains_key(id))
671}
672
673pub fn length(id: &str) -> Option<usize> {
674    SESSIONS.with(|s| {
675        s.borrow().get(id).map(|state| {
676            state
677                .transcript
678                .as_dict()
679                .and_then(|d| d.get("messages"))
680                .and_then(|v| match v {
681                    VmValue::List(list) => Some(list.len()),
682                    _ => None,
683                })
684                .unwrap_or(0)
685        })
686    })
687}
688
689pub fn scratchpad(id: &str) -> Option<VmValue> {
690    SESSIONS.with(|s| {
691        s.borrow()
692            .get(id)
693            .and_then(|state| state.scratchpad.clone())
694    })
695}
696
697pub fn scratchpad_version(id: &str) -> Option<u64> {
698    SESSIONS.with(|s| s.borrow().get(id).map(|state| state.scratchpad_version))
699}
700
701pub fn set_scratchpad(
702    id: &str,
703    scratchpad: VmValue,
704    source: impl Into<String>,
705    reason: Option<String>,
706    metadata: serde_json::Value,
707) -> Result<u64, String> {
708    validate_scratchpad_value(&scratchpad)?;
709    SESSIONS.with(|s| {
710        let mut map = s.borrow_mut();
711        let Some(state) = map.get_mut(id) else {
712            return Err(format!("agent session '{id}' does not exist"));
713        };
714        let version = state.scratchpad_version.saturating_add(1);
715        let event = scratchpad_transcript_event(
716            "set",
717            version,
718            Some(&scratchpad),
719            source.into(),
720            reason,
721            metadata,
722        );
723        append_event_to_state(state, event, "set_scratchpad")?;
724        state.scratchpad = Some(scratchpad);
725        state.scratchpad_version = version;
726        state.touch();
727        Ok(version)
728    })
729}
730
731pub fn clear_scratchpad(
732    id: &str,
733    source: impl Into<String>,
734    reason: Option<String>,
735    metadata: serde_json::Value,
736) -> Result<u64, String> {
737    SESSIONS.with(|s| {
738        let mut map = s.borrow_mut();
739        let Some(state) = map.get_mut(id) else {
740            return Err(format!("agent session '{id}' does not exist"));
741        };
742        let version = state.scratchpad_version.saturating_add(1);
743        let event =
744            scratchpad_transcript_event("clear", version, None, source.into(), reason, metadata);
745        append_event_to_state(state, event, "clear_scratchpad")?;
746        state.scratchpad = None;
747        state.scratchpad_version = version;
748        state.touch();
749        Ok(version)
750    })
751}
752
753fn validate_scratchpad_value(value: &VmValue) -> Result<(), String> {
754    if !matches!(value, VmValue::Dict(_)) {
755        return Err("agent session scratchpad must be a dict".to_string());
756    }
757    let json = crate::llm::helpers::vm_value_to_json(value);
758    let approx_bytes = serde_json::to_vec(&json)
759        .map(|bytes| bytes.len())
760        .unwrap_or(usize::MAX);
761    if approx_bytes > MAX_SCRATCHPAD_BYTES {
762        return Err(format!(
763            "agent session scratchpad is {approx_bytes} bytes; max is {MAX_SCRATCHPAD_BYTES}"
764        ));
765    }
766    Ok(())
767}
768
769fn scratchpad_transcript_event(
770    action: &str,
771    version: u64,
772    scratchpad: Option<&VmValue>,
773    source: String,
774    reason: Option<String>,
775    metadata: serde_json::Value,
776) -> VmValue {
777    let scratchpad_json = scratchpad.map(crate::llm::helpers::vm_value_to_json);
778    let approx_bytes = scratchpad_json
779        .as_ref()
780        .and_then(|value| serde_json::to_vec(value).ok().map(|bytes| bytes.len()))
781        .unwrap_or(0);
782    let event_metadata = serde_json::json!({
783        "action": action,
784        "version": version,
785        "source": normalize_scratchpad_source(source),
786        "reason": reason.unwrap_or_default(),
787        "approx_bytes": approx_bytes,
788        "counts": scratchpad_json
789            .as_ref()
790            .map(scratchpad_counts_json)
791            .unwrap_or_else(|| serde_json::json!({})),
792        "metadata": metadata,
793    });
794    let content = format!("Agent scratchpad {action}");
795    crate::llm::helpers::transcript_event(
796        "agent_scratchpad",
797        "system",
798        "internal",
799        &content,
800        Some(event_metadata),
801    )
802}
803
804fn normalize_scratchpad_source(source: String) -> String {
805    let trimmed = source.trim();
806    if trimmed.is_empty() {
807        "harn.agent_scratchpad".to_string()
808    } else {
809        trimmed.to_string()
810    }
811}
812
813fn scratchpad_counts_json(value: &serde_json::Value) -> serde_json::Value {
814    serde_json::json!({
815        "goals": scratchpad_array_len(value, "goals"),
816        "open_items": scratchpad_array_len(value, "open_items"),
817        "facts": scratchpad_array_len(value, "facts"),
818        "refs": scratchpad_array_len(value, "refs"),
819    })
820}
821
822fn scratchpad_array_len(value: &serde_json::Value, key: &str) -> usize {
823    value
824        .get(key)
825        .and_then(serde_json::Value::as_array)
826        .map(Vec::len)
827        .unwrap_or(0)
828}
829
830pub fn snapshot(id: &str) -> Option<VmValue> {
831    SESSIONS.with(|s| s.borrow().get(id).map(session_snapshot))
832}
833
834/// Session-only fields stay on `agent_session_snapshot`.
835pub fn transcript(id: &str) -> Option<VmValue> {
836    SESSIONS.with(|s| {
837        s.borrow()
838            .get(id)
839            .map(|state| transcript_with_session_metadata(state.transcript.clone(), state))
840    })
841}
842
843/// Open a session, or create it if missing. Returns the resolved id.
844///
845/// Newly-created sessions auto-register an event-log-backed sink when a
846/// generalized [`crate::event_log::EventLog`] has been installed for the
847/// current VM thread. For legacy env-driven workflows that still point
848/// `HARN_EVENT_LOG_DIR` at a directory, we preserve the older JSONL sink
849/// as a compatibility fallback. Re-opening an existing session does not
850/// re-register — sinks are per-session, owned by the first opener.
851pub fn open_or_create(id: Option<String>) -> String {
852    open_or_create_with_actor_chain(id, None)
853}
854
855pub fn open_or_create_with_actor_chain(
856    id: Option<String>,
857    requested_actor_chain: Option<ActorChain>,
858) -> String {
859    let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
860    let parent_session = current_session_id();
861    let inherited_actor_chain = requested_actor_chain
862        .clone()
863        .or_else(|| parent_session.as_deref().and_then(actor_chain));
864    let mut was_new = false;
865    SESSIONS.with(|s| {
866        let mut map = s.borrow_mut();
867        if let Some(state) = map.get_mut(&resolved) {
868            if let Some(actor_chain) = requested_actor_chain.clone() {
869                state.actor_chain = Some(actor_chain);
870            }
871            state.touch();
872            return;
873        }
874        was_new = true;
875        let cap = SESSION_CAP.with(|c| c.get());
876        if map.len() >= cap {
877            if let Some(victim) = map
878                .iter()
879                .min_by_key(|(_, state)| state.last_accessed)
880                .map(|(id, _)| id.clone())
881            {
882                map.remove(&victim);
883            }
884        }
885        let mut state = SessionState::new(resolved.clone());
886        state.actor_chain = inherited_actor_chain.clone();
887        map.insert(resolved.clone(), state);
888    });
889    if was_new {
890        if let Some(parent) = parent_session.as_deref() {
891            crate::agent_events::mirror_session_sinks(parent, &resolved);
892        }
893        try_register_event_log(&resolved);
894    }
895    resolved
896}
897
898pub fn open_child_session(parent_id: &str, id: Option<String>) -> String {
899    open_child_session_with_actor(parent_id, id, None)
900}
901
902pub fn open_child_session_with_actor(
903    parent_id: &str,
904    id: Option<String>,
905    actor: Option<&str>,
906) -> String {
907    let actor_chain = actor_chain(parent_id).map(|chain| match actor {
908        Some(actor) if !actor.trim().is_empty() => chain.pushed(actor.trim()),
909        _ => chain,
910    });
911    let resolved = open_or_create_with_actor_chain(id, actor_chain);
912    link_child_session(parent_id, &resolved);
913    resolved
914}
915
916pub fn link_child_session(parent_id: &str, child_id: &str) {
917    link_child_session_with_branch(parent_id, child_id, None);
918}
919
920pub fn link_child_session_with_branch(
921    parent_id: &str,
922    child_id: &str,
923    branched_at_event_index: Option<usize>,
924) {
925    if parent_id == child_id {
926        return;
927    }
928    open_or_create(Some(parent_id.to_string()));
929    open_or_create(Some(child_id.to_string()));
930    SESSIONS.with(|s| {
931        let mut map = s.borrow_mut();
932        update_lineage(&mut map, parent_id, child_id, branched_at_event_index);
933    });
934}
935
936pub fn parent_id(id: &str) -> Option<String> {
937    SESSIONS.with(|s| s.borrow().get(id).and_then(|state| state.parent_id.clone()))
938}
939
940pub fn child_ids(id: &str) -> Vec<String> {
941    SESSIONS.with(|s| {
942        s.borrow()
943            .get(id)
944            .map(|state| state.child_ids.clone())
945            .unwrap_or_default()
946    })
947}
948
949pub fn ancestry(id: &str) -> Option<SessionAncestry> {
950    SESSIONS.with(|s| {
951        let map = s.borrow();
952        let state = map.get(id)?;
953        let mut root_id = state.id.clone();
954        let mut cursor = state.parent_id.clone();
955        let mut seen = HashSet::from([state.id.clone()]);
956        while let Some(parent_id) = cursor {
957            if !seen.insert(parent_id.clone()) {
958                break;
959            }
960            root_id = parent_id.clone();
961            cursor = map
962                .get(&parent_id)
963                .and_then(|parent| parent.parent_id.clone());
964        }
965        Some(SessionAncestry {
966            parent_id: state.parent_id.clone(),
967            child_ids: state.child_ids.clone(),
968            root_id,
969        })
970    })
971}
972
973pub fn live_clients(id: &str) -> Option<Vec<LiveSessionClient>> {
974    SESSIONS.with(|s| {
975        s.borrow()
976            .get(id)
977            .map(|state| state.live_clients.values().cloned().collect())
978    })
979}
980
981pub fn attach_live_client(id: &str, request: AttachLiveClient) -> Result<LiveClientChange, String> {
982    SESSIONS.with(|s| {
983        let mut map = s.borrow_mut();
984        let Some(state) = map.get_mut(id) else {
985            return Err(format!("agent session '{id}' does not exist"));
986        };
987        let client_id = validate_live_client_id(request.client_id)?;
988        let now = crate::orchestration::now_rfc3339();
989        let previous_clients = state.live_clients.clone();
990        let previous_controller_id = state.live_controller_id.clone();
991
992        if request.mode == LiveClientMode::Controller {
993            let conflicting_controller = previous_controller_id
994                .as_ref()
995                .filter(|controller_id| *controller_id != &client_id)
996                .filter(|controller_id| state.live_clients.contains_key(*controller_id));
997            if let Some(previous) = conflicting_controller {
998                if !request.takeover {
999                    return Err(format!("live session already has controller '{previous}'"));
1000                }
1001                if let Some(previous_client) = state.live_clients.get_mut(previous) {
1002                    previous_client.mode = LiveClientMode::Observer;
1003                    previous_client.prompt_injection = false;
1004                    previous_client.permission_routing = false;
1005                    previous_client.last_seen_at = now.clone();
1006                }
1007            }
1008            state.live_controller_id = Some(client_id.clone());
1009        } else if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1010            state.live_controller_id = None;
1011        }
1012
1013        let attached_at = state
1014            .live_clients
1015            .get(&client_id)
1016            .map(|client| client.attached_at.clone())
1017            .unwrap_or_else(|| now.clone());
1018        let client = LiveSessionClient {
1019            client_id: client_id.clone(),
1020            mode: request.mode,
1021            attached_at,
1022            last_seen_at: now,
1023            prompt_injection: request.prompt_injection,
1024            permission_routing: request.permission_routing,
1025            metadata: request.metadata,
1026        };
1027        state.live_clients.insert(client_id, client.clone());
1028        state.touch();
1029        let active_controller_id = state.live_controller_id.clone();
1030        append_live_client_event(
1031            state,
1032            "attached",
1033            Some(&client),
1034            previous_controller_id.as_deref(),
1035            active_controller_id.as_deref(),
1036            serde_json::Value::Null,
1037        )
1038        .inspect_err(|_error| {
1039            state.live_clients = previous_clients;
1040            state.live_controller_id = previous_controller_id.clone();
1041        })?;
1042        Ok(live_client_change(
1043            Some(client),
1044            previous_controller_id,
1045            state,
1046        ))
1047    })
1048}
1049
1050pub fn takeover_live_client(
1051    id: &str,
1052    client_id: impl Into<String>,
1053    metadata: serde_json::Value,
1054) -> Result<LiveClientChange, String> {
1055    attach_live_client(
1056        id,
1057        AttachLiveClient {
1058            client_id: client_id.into(),
1059            mode: LiveClientMode::Controller,
1060            takeover: true,
1061            prompt_injection: true,
1062            permission_routing: true,
1063            metadata,
1064        },
1065    )
1066}
1067
1068pub fn detach_live_client(
1069    id: &str,
1070    client_id: impl Into<String>,
1071    reason: Option<String>,
1072    metadata: serde_json::Value,
1073) -> Result<LiveClientChange, String> {
1074    SESSIONS.with(|s| {
1075        let mut map = s.borrow_mut();
1076        let Some(state) = map.get_mut(id) else {
1077            return Err(format!("agent session '{id}' does not exist"));
1078        };
1079        let client_id = validate_live_client_id(client_id.into())?;
1080        let previous_clients = state.live_clients.clone();
1081        let previous_controller_id = state.live_controller_id.clone();
1082        let Some(mut client) = state.live_clients.remove(&client_id) else {
1083            return Err(format!("live client '{client_id}' is not attached"));
1084        };
1085        client.last_seen_at = crate::orchestration::now_rfc3339();
1086        if state.live_controller_id.as_deref() == Some(client_id.as_str()) {
1087            state.live_controller_id = None;
1088        }
1089        state.touch();
1090        let active_controller_id = state.live_controller_id.clone();
1091        append_live_client_event(
1092            state,
1093            "detached",
1094            Some(&client),
1095            previous_controller_id.as_deref(),
1096            active_controller_id.as_deref(),
1097            serde_json::json!({
1098                "reason": reason.unwrap_or_else(|| "client_detached".to_string()),
1099                "metadata": metadata,
1100            }),
1101        )
1102        .inspect_err(|_error| {
1103            state.live_clients = previous_clients;
1104            state.live_controller_id = previous_controller_id.clone();
1105        })?;
1106        Ok(live_client_change(None, previous_controller_id, state))
1107    })
1108}
1109
1110pub fn heartbeat_live_client(
1111    id: &str,
1112    client_id: impl Into<String>,
1113    metadata: serde_json::Value,
1114) -> Result<LiveClientChange, String> {
1115    SESSIONS.with(|s| {
1116        let mut map = s.borrow_mut();
1117        let Some(state) = map.get_mut(id) else {
1118            return Err(format!("agent session '{id}' does not exist"));
1119        };
1120        let client_id = validate_live_client_id(client_id.into())?;
1121        let previous_clients = state.live_clients.clone();
1122        let previous_controller_id = state.live_controller_id.clone();
1123        let Some(client) = state.live_clients.get_mut(&client_id) else {
1124            return Err(format!("live client '{client_id}' is not attached"));
1125        };
1126        client.last_seen_at = crate::orchestration::now_rfc3339();
1127        if !metadata.is_null() {
1128            client.metadata = metadata.clone();
1129        }
1130        let client = client.clone();
1131        state.touch();
1132        let active_controller_id = state.live_controller_id.clone();
1133        append_live_client_event(
1134            state,
1135            "heartbeat",
1136            Some(&client),
1137            previous_controller_id.as_deref(),
1138            active_controller_id.as_deref(),
1139            serde_json::json!({ "metadata": metadata }),
1140        )
1141        .inspect_err(|_error| {
1142            state.live_clients = previous_clients;
1143            state.live_controller_id = previous_controller_id.clone();
1144        })?;
1145        Ok(live_client_change(
1146            Some(client),
1147            previous_controller_id,
1148            state,
1149        ))
1150    })
1151}
1152
1153pub fn inject_prompt_from_live_client(
1154    id: &str,
1155    client_id: impl Into<String>,
1156    content: VmValue,
1157    metadata: serde_json::Value,
1158) -> Result<(), String> {
1159    let client_id = validate_live_client_id(client_id.into())?;
1160    ensure_live_controller(id, &client_id, LiveControllerCapability::PromptInjection)?;
1161    let mut message = BTreeMap::new();
1162    message.put_str("role", "user");
1163    message.insert("content".to_string(), content);
1164    message.insert(
1165        "metadata".to_string(),
1166        crate::stdlib::json_to_vm_value(&serde_json::json!({
1167            "live_session": {
1168                "client_id": client_id,
1169                "mode": "controller",
1170                "source": "live_session_attach",
1171                "metadata": metadata,
1172            }
1173        })),
1174    );
1175    inject_message(id, VmValue::dict(message))
1176}
1177
1178pub fn route_live_permission_request(
1179    id: &str,
1180    client_id: impl Into<String>,
1181    request: serde_json::Value,
1182    metadata: serde_json::Value,
1183) -> Result<serde_json::Value, String> {
1184    let client_id = validate_live_client_id(client_id.into())?;
1185    let client =
1186        ensure_live_controller(id, &client_id, LiveControllerCapability::PermissionRouting)?;
1187    let request_id = request
1188        .get("id")
1189        .or_else(|| request.get("request_id"))
1190        .and_then(serde_json::Value::as_str)
1191        .filter(|id| !id.trim().is_empty())
1192        .unwrap_or("permission_request");
1193    let event_metadata = serde_json::json!({
1194        "action": "permission_routed",
1195        "client": live_client_json(&client),
1196        "request_id": request_id,
1197        "request": request,
1198        "metadata": metadata,
1199    });
1200    let event = crate::llm::helpers::transcript_event(
1201        LIVE_CLIENT_PERMISSION_EVENT_KIND,
1202        "system",
1203        "internal",
1204        "Live session permission request routed",
1205        Some(event_metadata.clone()),
1206    );
1207    append_event(id, event)?;
1208    Ok(event_metadata)
1209}
1210
1211enum LiveControllerCapability {
1212    PromptInjection,
1213    PermissionRouting,
1214}
1215
1216fn ensure_live_controller(
1217    id: &str,
1218    client_id: &str,
1219    capability: LiveControllerCapability,
1220) -> Result<LiveSessionClient, String> {
1221    SESSIONS.with(|s| {
1222        let map = s.borrow();
1223        let Some(state) = map.get(id) else {
1224            return Err(format!("agent session '{id}' does not exist"));
1225        };
1226        if state.live_controller_id.as_deref() != Some(client_id) {
1227            return Err(format!(
1228                "live client '{client_id}' is not the active controller"
1229            ));
1230        }
1231        let Some(client) = state.live_clients.get(client_id) else {
1232            return Err(format!("live client '{client_id}' is not attached"));
1233        };
1234        match capability {
1235            LiveControllerCapability::PromptInjection if !client.prompt_injection => Err(format!(
1236                "live client '{client_id}' cannot inject prompts for this session"
1237            )),
1238            LiveControllerCapability::PermissionRouting if !client.permission_routing => Err(
1239                format!("live client '{client_id}' cannot route permissions for this session"),
1240            ),
1241            _ => Ok(client.clone()),
1242        }
1243    })
1244}
1245
1246fn append_live_client_event(
1247    state: &mut SessionState,
1248    action: &str,
1249    client: Option<&LiveSessionClient>,
1250    previous_controller_id: Option<&str>,
1251    active_controller_id: Option<&str>,
1252    extra: serde_json::Value,
1253) -> Result<(), String> {
1254    let metadata = serde_json::json!({
1255        "action": action,
1256        "session_id": state.id,
1257        "client": client.map(live_client_json),
1258        "previous_controller_id": previous_controller_id,
1259        "active_controller_id": active_controller_id,
1260        "clients": state
1261            .live_clients
1262            .values()
1263            .map(live_client_json)
1264            .collect::<Vec<_>>(),
1265        "extra": extra,
1266    });
1267    let event = crate::llm::helpers::transcript_event(
1268        LIVE_CLIENT_EVENT_KIND,
1269        "system",
1270        "internal",
1271        "Live session client lifecycle changed",
1272        Some(metadata),
1273    );
1274    append_event_to_state(state, event, "live_client")
1275}
1276
1277fn live_client_change(
1278    client: Option<LiveSessionClient>,
1279    previous_controller_id: Option<String>,
1280    state: &SessionState,
1281) -> LiveClientChange {
1282    LiveClientChange {
1283        client,
1284        previous_controller_id,
1285        active_controller_id: state.live_controller_id.clone(),
1286        clients: state.live_clients.values().cloned().collect(),
1287    }
1288}
1289
1290fn validate_live_client_id(id: impl Into<String>) -> Result<String, String> {
1291    let id = id.into();
1292    let trimmed = id.trim();
1293    if trimmed.is_empty() {
1294        return Err("live client id cannot be empty".to_string());
1295    }
1296    Ok(trimmed.to_string())
1297}
1298
1299pub fn live_client_json(client: &LiveSessionClient) -> serde_json::Value {
1300    serde_json::json!({
1301        "client_id": client.client_id,
1302        "mode": client.mode.as_str(),
1303        "attached_at": client.attached_at,
1304        "last_seen_at": client.last_seen_at,
1305        "prompt_injection": client.prompt_injection,
1306        "permission_routing": client.permission_routing,
1307        "metadata": client.metadata,
1308    })
1309}
1310
1311pub fn live_client_change_json(change: &LiveClientChange) -> serde_json::Value {
1312    serde_json::json!({
1313        "client": change.client.as_ref().map(live_client_json),
1314        "previous_controller_id": change.previous_controller_id,
1315        "active_controller_id": change.active_controller_id,
1316        "clients": change
1317            .clients
1318            .iter()
1319            .map(live_client_json)
1320            .collect::<Vec<_>>(),
1321    })
1322}
1323
1324/// Auto-register a persistent sink for a newly-created session.
1325/// Silent no-op on failure — a broken observability sink must never
1326/// prevent a session from starting.
1327fn try_register_event_log(session_id: &str) {
1328    if let Some(log) = crate::event_log::active_event_log() {
1329        crate::agent_events::register_sink(
1330            session_id,
1331            crate::agent_events::EventLogSink::new(log, session_id),
1332        );
1333        return;
1334    }
1335    let Ok(dir) = std::env::var("HARN_EVENT_LOG_DIR") else {
1336        return;
1337    };
1338    if dir.is_empty() {
1339        return;
1340    }
1341    let path = std::path::PathBuf::from(dir).join(format!("event_log-{session_id}.jsonl"));
1342    if let Ok(sink) = crate::agent_events::JsonlEventSink::open(&path) {
1343        crate::agent_events::register_sink(session_id, sink);
1344    }
1345}
1346
1347pub fn register_event_log_sink(session_id: &str) {
1348    try_register_event_log(session_id);
1349}
1350
1351pub fn close(id: &str) {
1352    SESSIONS.with(|s| {
1353        s.borrow_mut().remove(id);
1354    });
1355    // Cross-thread per-session state must be released too, otherwise
1356    // pending inbox entries can be delivered to a future session that
1357    // happens to reuse the same id.
1358    crate::orchestration::agent_inbox::clear_session(id);
1359    crate::agent_events::clear_session_sinks(id);
1360}
1361
1362pub fn close_with_status(
1363    id: &str,
1364    reason: impl Into<String>,
1365    status: impl Into<String>,
1366    metadata: serde_json::Value,
1367) -> bool {
1368    if !exists(id) {
1369        return false;
1370    }
1371    let reason = reason.into();
1372    let status = status.into();
1373    let event_metadata = serde_json::json!({
1374        "reason": reason,
1375        "status": status,
1376        "metadata": metadata,
1377    });
1378    let transcript_event = crate::llm::helpers::transcript_event(
1379        "agent_session_closed",
1380        "system",
1381        "internal",
1382        "Agent session closed",
1383        Some(event_metadata),
1384    );
1385    let _ = append_event(id, transcript_event);
1386    crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::SessionClosed {
1387        session_id: id.to_string(),
1388        reason,
1389        status,
1390        metadata,
1391    });
1392    close(id);
1393    true
1394}
1395
1396pub fn reset_transcript(id: &str) -> bool {
1397    SESSIONS.with(|s| {
1398        let mut map = s.borrow_mut();
1399        let Some(state) = map.get_mut(id) else {
1400            return false;
1401        };
1402        state.transcript = empty_transcript(id);
1403        state.tool_format = None;
1404        state.system_prompt = None;
1405        state.scratchpad = None;
1406        state.scratchpad_version = 0;
1407        state.last_transcript_budget_action = None;
1408        state.completed_turn_checkpoints.clear();
1409        state.redo_stack.clear();
1410        state.text_tool_call_seq = 0;
1411        state.touch();
1412        true
1413    })
1414}
1415
1416/// Copy `src`'s transcript into a new session id. Subscribers are NOT
1417/// copied — a fork is a conversation branch, not an event fanout.
1418///
1419/// Touches `src`'s `last_accessed` before evicting, so the fork
1420/// operation itself can't make `src` look stale and kick it out of
1421/// the LRU just to make room for the new fork.
1422pub fn fork(src_id: &str, dst_id: Option<String>) -> Option<String> {
1423    let (
1424        src_transcript,
1425        src_tool_format,
1426        src_system_prompt,
1427        src_pinned_model,
1428        src_pinned_reasoning_policy,
1429        src_actor_chain,
1430        src_workspace_anchor,
1431        src_workspace_policy,
1432        src_scratchpad,
1433        src_scratchpad_version,
1434        src_transcript_budget_policy,
1435        src_last_transcript_budget_action,
1436        src_text_tool_call_seq,
1437        dst,
1438    ) = SESSIONS.with(|s| {
1439        let mut map = s.borrow_mut();
1440        let src = map.get_mut(src_id)?;
1441        src.touch();
1442        let dst = dst_id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1443        let forked_transcript = clone_transcript_with_id(&src.transcript, &dst);
1444        Some((
1445            forked_transcript,
1446            src.tool_format.clone(),
1447            src.system_prompt.clone(),
1448            src.pinned_model.clone(),
1449            src.pinned_reasoning_policy.clone(),
1450            src.actor_chain.clone(),
1451            src.workspace_anchor.clone(),
1452            src.workspace_policy.clone(),
1453            src.scratchpad.clone(),
1454            src.scratchpad_version,
1455            src.transcript_budget_policy.clone(),
1456            src.last_transcript_budget_action.clone(),
1457            src.text_tool_call_seq,
1458            dst,
1459        ))
1460    })?;
1461    // Ensure cap is respected when inserting the fork.
1462    open_or_create(Some(dst.clone()));
1463    SESSIONS.with(|s| {
1464        let mut map = s.borrow_mut();
1465        if let Some(state) = map.get_mut(&dst) {
1466            state.transcript = src_transcript;
1467            state.tool_format = src_tool_format;
1468            state.system_prompt = src_system_prompt;
1469            state.pinned_model = src_pinned_model;
1470            state.pinned_reasoning_policy = src_pinned_reasoning_policy;
1471            state.actor_chain = src_actor_chain;
1472            state.workspace_anchor = src_workspace_anchor;
1473            state.workspace_policy = src_workspace_policy;
1474            state.scratchpad = src_scratchpad;
1475            state.scratchpad_version = src_scratchpad_version;
1476            state.transcript_budget_policy = src_transcript_budget_policy;
1477            state.last_transcript_budget_action = src_last_transcript_budget_action;
1478            state.text_tool_call_seq = src_text_tool_call_seq;
1479            state.touch();
1480        }
1481        update_lineage(&mut map, src_id, &dst, None);
1482    });
1483    let budget_ok = SESSIONS.with(|s| {
1484        let mut map = s.borrow_mut();
1485        let Some(state) = map.get_mut(&dst) else {
1486            return false;
1487        };
1488        let candidate = state.transcript.clone();
1489        apply_transcript_with_budget(state, candidate, "fork").is_ok()
1490    });
1491    if !budget_ok {
1492        close(&dst);
1493        return None;
1494    }
1495    // open_or_create evicts BEFORE inserting, so the dst slot is
1496    // guaranteed once we get here. The existence check is cheap
1497    // insurance against a future refactor that breaks that invariant.
1498    if exists(&dst) {
1499        Some(dst)
1500    } else {
1501        None
1502    }
1503}
1504
1505/// Fork `src_id` and truncate the destination transcript to the
1506/// first `keep_first` messages (#105 — branch-replay). Pairs with the
1507/// scrubber: the host picks an event index, rebuilds a message count,
1508/// and calls this to spawn a live sibling session that resumes from
1509/// the rebuilt state. Subscribers are not carried over (same as
1510/// `fork`), so sibling events don't double-fan into the parent's
1511/// consumers.
1512///
1513/// Returns the new session id on success, `None` if `src_id` doesn't
1514/// exist.
1515pub fn fork_at(src_id: &str, keep_first: usize, dst_id: Option<String>) -> Option<String> {
1516    let branched_at_event_index = SESSIONS.with(|s| {
1517        let map = s.borrow();
1518        let src = map.get(src_id)?;
1519        Some(branch_event_index(&src.transcript, keep_first))
1520    })?;
1521    let new_id = fork(src_id, dst_id)?;
1522    link_child_session_with_branch(src_id, &new_id, Some(branched_at_event_index));
1523    let _ = truncate(&new_id, keep_first);
1524    Some(new_id)
1525}
1526
1527/// Truncate the session transcript to the first `keep_first`
1528/// messages (opposite of `trim`, which keeps the last N). Returns
1529/// counts and the retained tip event id when the session exists.
1530pub fn truncate(id: &str, keep_first: usize) -> Option<SessionTruncateResult> {
1531    SESSIONS.with(|s| {
1532        let mut map = s.borrow_mut();
1533        let state = map.get_mut(id)?;
1534        let result = truncate_state(state, keep_first)?;
1535        Some(result)
1536    })
1537}
1538
1539fn truncate_state(state: &mut SessionState, keep_first: usize) -> Option<SessionTruncateResult> {
1540    let dict = state
1541        .transcript
1542        .as_dict()
1543        .cloned()
1544        .unwrap_or_else(crate::value::DictMap::new);
1545    let messages: Vec<VmValue> = match dict.get("messages") {
1546        Some(VmValue::List(list)) => list.iter().cloned().collect(),
1547        _ => Vec::new(),
1548    };
1549    let existing_events = match dict.get("events") {
1550        Some(VmValue::List(list)) => Some(list.iter().cloned().collect::<Vec<_>>()),
1551        _ => None,
1552    };
1553    let kept_turn_count = keep_first.min(messages.len());
1554    let removed_turn_count = messages.len().saturating_sub(kept_turn_count);
1555    let mut new_tip_turn_id = existing_events
1556        .as_ref()
1557        .map(|events| turn_event_id_for_count(events, kept_turn_count))
1558        .unwrap_or_else(|| {
1559            let events = crate::llm::helpers::transcript_events_from_messages(&messages);
1560            turn_event_id_for_count(&events, kept_turn_count)
1561        });
1562
1563    if removed_turn_count > 0 {
1564        let retained: Vec<VmValue> = messages.into_iter().take(kept_turn_count).collect();
1565        let retained_events = match existing_events {
1566            Some(events) => {
1567                let keep_event_count = event_prefix_len_for_messages(&events, kept_turn_count);
1568                events.into_iter().take(keep_event_count).collect()
1569            }
1570            None => crate::llm::helpers::transcript_events_from_messages(&retained),
1571        };
1572        new_tip_turn_id = turn_event_id_for_count(&retained_events, kept_turn_count);
1573        let mut next = dict;
1574        next.insert(
1575            crate::value::intern_key("events"),
1576            VmValue::List(std::sync::Arc::new(retained_events)),
1577        );
1578        next.insert(
1579            crate::value::intern_key("messages"),
1580            VmValue::List(std::sync::Arc::new(retained)),
1581        );
1582        next.remove("summary");
1583        apply_transcript_with_budget(state, VmValue::dict(next), "truncate").ok()?;
1584    }
1585    state.touch();
1586    Some(SessionTruncateResult {
1587        kept_turn_count,
1588        removed_turn_count,
1589        new_tip_turn_id,
1590    })
1591}
1592
1593/// Pop the trailing message iff it is an assistant message. Used by
1594/// `agent_step_judge` to remove a vetoed assistant turn before
1595/// regeneration (the "replace" on_veto path). Returns `true` if a
1596/// message was popped, `false` if the transcript was empty, and an
1597/// error if the trailing message was not an assistant turn —
1598/// signalling a call-site discipline bug rather than a runtime error.
1599pub fn pop_last_if_assistant(id: &str) -> Result<bool, String> {
1600    SESSIONS.with(|s| {
1601        let mut map = s.borrow_mut();
1602        let Some(state) = map.get_mut(id) else {
1603            return Err(format!(
1604                "pop_last_if_assistant: unknown session id '{id}'"
1605            ));
1606        };
1607        let messages: Vec<VmValue> = match state.transcript.as_dict() {
1608            Some(dict) => match dict.get("messages") {
1609                Some(VmValue::List(list)) => list.iter().cloned().collect(),
1610                _ => Vec::new(),
1611            },
1612            None => Vec::new(),
1613        };
1614        if messages.is_empty() {
1615            return Ok(false);
1616        }
1617        let trailing_role = messages
1618            .last()
1619            .and_then(|m| m.as_dict())
1620            .and_then(|d| d.get("role"))
1621            .map(|v| v.display())
1622            .unwrap_or_default();
1623        if trailing_role != "assistant" {
1624            return Err(format!(
1625                "pop_last_if_assistant: trailing message role is '{trailing_role}', expected 'assistant'"
1626            ));
1627        }
1628        let keep = messages.len() - 1;
1629        truncate_state(state, keep);
1630        Ok(true)
1631    })
1632}
1633
1634pub fn trim(id: &str, keep_last: usize) -> Option<usize> {
1635    SESSIONS.with(|s| {
1636        let mut map = s.borrow_mut();
1637        let state = map.get_mut(id)?;
1638        let dict = state.transcript.as_dict()?.clone();
1639        let messages: Vec<VmValue> = match dict.get("messages") {
1640            Some(VmValue::List(list)) => list.iter().cloned().collect(),
1641            _ => Vec::new(),
1642        };
1643        let start = messages.len().saturating_sub(keep_last);
1644        let retained: Vec<VmValue> = messages.into_iter().skip(start).collect();
1645        let kept = retained.len();
1646        let mut next = dict;
1647        next.insert(
1648            crate::value::intern_key("events"),
1649            VmValue::List(std::sync::Arc::new(
1650                crate::llm::helpers::transcript_events_from_messages(&retained),
1651            )),
1652        );
1653        next.insert(
1654            crate::value::intern_key("messages"),
1655            VmValue::List(std::sync::Arc::new(retained)),
1656        );
1657        apply_transcript_with_budget(state, VmValue::dict(next), "trim").ok()?;
1658        Some(kept)
1659    })
1660}
1661
1662/// Append a message dict to the session transcript. The message must
1663/// have at least a string `role`; anything else is merged verbatim.
1664pub fn inject_message(id: &str, message: VmValue) -> Result<(), String> {
1665    let Some(msg_dict) = message.as_dict().cloned() else {
1666        return Err("agent_session_inject: message must be a dict".into());
1667    };
1668    let role_ok = matches!(msg_dict.get("role"), Some(VmValue::String(_)));
1669    if !role_ok {
1670        return Err(
1671            "agent_session_inject: message must have a string `role` (user|assistant|tool_result|system)"
1672                .into(),
1673        );
1674    }
1675    SESSIONS.with(|s| {
1676        let mut map = s.borrow_mut();
1677        let Some(state) = map.get_mut(id) else {
1678            return Err(format!("agent_session_inject: unknown session id '{id}'"));
1679        };
1680        let dict = state
1681            .transcript
1682            .as_dict()
1683            .cloned()
1684            .unwrap_or_else(crate::value::DictMap::new);
1685        let mut messages: Vec<VmValue> = match dict.get("messages") {
1686            Some(VmValue::List(list)) => list.iter().cloned().collect(),
1687            _ => Vec::new(),
1688        };
1689        let mut events: Vec<VmValue> = match dict.get("events") {
1690            Some(VmValue::List(list)) => list.iter().cloned().collect(),
1691            _ => crate::llm::helpers::transcript_events_from_messages(&messages),
1692        };
1693        let new_message = VmValue::dict(msg_dict);
1694        let message_index = messages.len();
1695        events.push(crate::llm::helpers::transcript_event_from_message(
1696            &new_message,
1697        ));
1698        messages.push(new_message);
1699        let mut next = dict;
1700        next.insert(
1701            crate::value::intern_key("events"),
1702            VmValue::List(std::sync::Arc::new(events)),
1703        );
1704        next.insert(
1705            crate::value::intern_key("messages"),
1706            VmValue::List(std::sync::Arc::new(messages)),
1707        );
1708        let persisted_message = next
1709            .get("messages")
1710            .and_then(|value| match value {
1711                VmValue::List(list) => list.get(message_index).cloned(),
1712                _ => None,
1713            })
1714            .unwrap_or(VmValue::Nil);
1715        apply_transcript_with_budget(state, VmValue::dict(next), "inject_message")?;
1716        emit_identified_user_message_event(id, &persisted_message);
1717        emit_llm_message_event(id, message_index, &persisted_message);
1718        Ok(())
1719    })
1720}
1721
1722fn emit_identified_user_message_event(session_id: &str, message: &VmValue) {
1723    let message_json = crate::llm::helpers::vm_value_to_json(message);
1724    let role = message_json.get("role").and_then(|value| value.as_str());
1725    if role != Some("user") {
1726        return;
1727    }
1728    let Some(message_id) = message_json
1729        .get("messageId")
1730        .or_else(|| message_json.get("message_id"))
1731        .and_then(|value| value.as_str())
1732        .filter(|value| !value.trim().is_empty())
1733    else {
1734        return;
1735    };
1736    let content = message_json
1737        .get("content")
1738        .map(user_message_content_blocks)
1739        .unwrap_or_default();
1740    crate::agent_events::emit_event(&crate::agent_events::AgentEvent::UserMessage {
1741        session_id: session_id.to_string(),
1742        message_id: message_id.to_string(),
1743        content,
1744    });
1745}
1746
1747fn user_message_content_blocks(content: &serde_json::Value) -> Vec<serde_json::Value> {
1748    match content {
1749        serde_json::Value::Array(items) => items.clone(),
1750        serde_json::Value::String(text) => vec![serde_json::json!({
1751            "type": "text",
1752            "text": text,
1753        })],
1754        other => vec![serde_json::json!({
1755            "type": "text",
1756            "text": other.to_string(),
1757        })],
1758    }
1759}
1760
1761fn emit_llm_message_event(session_id: &str, message_index: usize, message: &VmValue) {
1762    let mut fields = serde_json::Map::new();
1763    fields.insert(
1764        "session_id".to_string(),
1765        serde_json::Value::String(session_id.to_string()),
1766    );
1767    fields.insert(
1768        "message_index".to_string(),
1769        serde_json::json!(message_index),
1770    );
1771    let message_json = crate::llm::helpers::vm_value_to_json(message);
1772    if let Some(role) = message_json.get("role").and_then(|value| value.as_str()) {
1773        fields.insert(
1774            "role".to_string(),
1775            serde_json::Value::String(role.to_string()),
1776        );
1777    }
1778    if let Some(content) = message_json.get("content") {
1779        fields.insert("content".to_string(), content.clone());
1780    }
1781    fields.insert("message".to_string(), message_json);
1782    crate::llm::append_observability_sidecar_entry("message", fields);
1783}
1784
1785/// Create a new session from a reconstructed message list.
1786///
1787/// This is intentionally an all-at-once write instead of repeated
1788/// `inject_message` calls: importing a transcript should not re-emit
1789/// each historic turn into the active observability sidecar.
1790pub fn seed_from_messages(
1791    id: Option<String>,
1792    messages: &[serde_json::Value],
1793    metadata: serde_json::Value,
1794    system_prompt: Option<String>,
1795    tool_format: Option<String>,
1796) -> Result<String, String> {
1797    let resolved = id.unwrap_or_else(|| uuid::Uuid::now_v7().to_string());
1798    if exists(&resolved) {
1799        return Err(format!("agent session '{resolved}' already exists"));
1800    }
1801    open_or_create(Some(resolved.clone()));
1802    SESSIONS.with(|s| {
1803        let mut map = s.borrow_mut();
1804        let Some(state) = map.get_mut(&resolved) else {
1805            return Err(format!("failed to create agent session '{resolved}'"));
1806        };
1807        state.tool_format = tool_format.filter(|value| !value.trim().is_empty());
1808        state.system_prompt = system_prompt.filter(|value| !value.trim().is_empty());
1809
1810        let mut metadata = metadata
1811            .as_object()
1812            .cloned()
1813            .unwrap_or_else(serde_json::Map::new);
1814        if let Some(tool_format) = state.tool_format.as_ref() {
1815            metadata.insert(
1816                "tool_format".to_string(),
1817                serde_json::Value::String(tool_format.clone()),
1818            );
1819            metadata.insert(
1820                "tool_mode_locked".to_string(),
1821                serde_json::Value::Bool(true),
1822            );
1823        }
1824        if let Some(system_prompt) = state.system_prompt.as_ref() {
1825            metadata.insert(
1826                "system_prompt".to_string(),
1827                crate::llm::helpers::system_prompt_metadata(system_prompt),
1828            );
1829        }
1830        let text_tool_call_seq = next_text_tool_call_seq_from_json_messages(messages);
1831        let vm_messages = crate::llm::helpers::json_messages_to_vm(messages);
1832        let candidate = crate::llm::helpers::new_transcript_with(
1833            Some(resolved.clone()),
1834            vm_messages,
1835            None,
1836            Some(crate::stdlib::json_to_vm_value(&serde_json::Value::Object(
1837                metadata,
1838            ))),
1839        );
1840        apply_transcript_with_budget(state, candidate, "seed_from_messages")?;
1841        state.text_tool_call_seq = text_tool_call_seq;
1842        Ok(resolved)
1843    })
1844}
1845
1846/// Load the messages vec (as JSON) for this session, for use as prefix
1847/// to an agent_loop run. Returns an empty vec if the session doesn't
1848/// exist or has no messages.
1849pub fn messages_json(id: &str) -> Vec<serde_json::Value> {
1850    SESSIONS.with(|s| {
1851        let map = s.borrow();
1852        let Some(state) = map.get(id) else {
1853            return Vec::new();
1854        };
1855        let Some(dict) = state.transcript.as_dict() else {
1856            return Vec::new();
1857        };
1858        match dict.get("messages") {
1859            Some(VmValue::List(list)) => list
1860                .iter()
1861                .map(crate::llm::helpers::vm_value_to_json)
1862                .collect(),
1863            _ => Vec::new(),
1864        }
1865    })
1866}
1867
1868#[derive(Clone, Debug, Default)]
1869pub struct SessionPromptState {
1870    pub messages: Vec<serde_json::Value>,
1871    pub summary: Option<String>,
1872}
1873
1874fn summary_message_json(summary: &str) -> serde_json::Value {
1875    serde_json::json!({
1876        "role": "user",
1877        "content": summary,
1878    })
1879}
1880
1881fn messages_begin_with_summary(messages: &[serde_json::Value], summary: &str) -> bool {
1882    messages.first().is_some_and(|message| {
1883        message.get("role").and_then(|value| value.as_str()) == Some("user")
1884            && message.get("content").and_then(|value| value.as_str()) == Some(summary)
1885    })
1886}
1887
1888/// Prompt-surface resume state for a persisted session.
1889///
1890/// Returns the compacted/rehydratable message list plus the transcript's
1891/// summary field. When the transcript carries a summary field but its
1892/// message list does not already begin with the compacted summary
1893/// message, this helper prepends one so session re-entry preserves the
1894/// same prompt surface the previous loop was actually using.
1895pub fn prompt_state_json(id: &str) -> SessionPromptState {
1896    SESSIONS.with(|s| {
1897        let map = s.borrow();
1898        let Some(state) = map.get(id) else {
1899            return SessionPromptState::default();
1900        };
1901        let Some(dict) = state.transcript.as_dict() else {
1902            return SessionPromptState::default();
1903        };
1904        let mut messages = match dict.get("messages") {
1905            Some(VmValue::List(list)) => list
1906                .iter()
1907                .map(crate::llm::helpers::vm_value_to_json)
1908                .collect::<Vec<_>>(),
1909            _ => Vec::new(),
1910        };
1911        let summary = dict.get("summary").and_then(|value| match value {
1912            VmValue::String(text) if !text.trim().is_empty() => Some(text.to_string()),
1913            _ => None,
1914        });
1915        if let Some(summary_text) = summary.as_deref() {
1916            if !messages_begin_with_summary(&messages, summary_text) {
1917                messages.insert(0, summary_message_json(summary_text));
1918            }
1919        }
1920        SessionPromptState { messages, summary }
1921    })
1922}
1923
1924/// Overwrite the transcript for this session. Used by `agent_loop` on
1925/// exit to persist the synthesized transcript.
1926pub fn store_transcript(id: &str, transcript: VmValue) -> Result<(), String> {
1927    SESSIONS.with(|s| {
1928        let mut map = s.borrow_mut();
1929        let Some(state) = map.get_mut(id) else {
1930            return Err(format!(
1931                "agent_session_store_transcript: unknown session id '{id}'"
1932            ));
1933        };
1934        let transcript = transcript_with_session_metadata(transcript, state);
1935        let text_tool_call_seq = next_text_tool_call_seq_from_transcript(&transcript);
1936        apply_transcript_with_budget(state, transcript, "store_transcript")?;
1937        state.text_tool_call_seq = state.text_tool_call_seq.max(text_tool_call_seq);
1938        Ok(())
1939    })
1940}
1941
1942fn checkpoint_summary(checkpoint: &SessionTurnCheckpoint) -> SessionCheckpointSummary {
1943    SessionCheckpointSummary {
1944        checkpoint_id: checkpoint.checkpoint_id.clone(),
1945        before_message_count: checkpoint.before_message_count,
1946        after_message_count: checkpoint.after_message_count,
1947        fs_snapshot_ids: checkpoint.fs_snapshot_ids.clone(),
1948    }
1949}
1950
1951fn checkpoint_error_status(error: SessionCheckpointError) -> &'static str {
1952    match error {
1953        SessionCheckpointError::UnknownSession => "unknown_session",
1954        SessionCheckpointError::NoCheckpoint => "no_checkpoint",
1955        SessionCheckpointError::NoRedo => "no_redo",
1956    }
1957}
1958
1959pub fn checkpoint_status_name(error: SessionCheckpointError) -> &'static str {
1960    checkpoint_error_status(error)
1961}
1962
1963/// Clear redo checkpoints after host-side workspace mutations that are not part
1964/// of the redo flow. Returns whether any redo state was discarded.
1965pub fn invalidate_redo(id: &str) -> bool {
1966    SESSIONS.with(|s| {
1967        let mut map = s.borrow_mut();
1968        let Some(state) = map.get_mut(id) else {
1969            return false;
1970        };
1971        let had_redo = !state.redo_stack.is_empty();
1972        state.redo_stack.clear();
1973        state.touch();
1974        had_redo
1975    })
1976}
1977
1978/// Record a completed prompt turn boundary.
1979///
1980/// `before_transcript` must be captured immediately before the user turn
1981/// starts. The current live transcript becomes the redo target, and optional
1982/// `fs_snapshot_ids` name host-owned filesystem snapshots captured during the
1983/// turn. Harn owns the transcript stack; hosts own concrete file restoration.
1984pub fn record_completed_turn_checkpoint(
1985    id: &str,
1986    before_transcript: VmValue,
1987    fs_snapshot_ids: Vec<String>,
1988) -> Result<Option<SessionCheckpointSummary>, SessionCheckpointError> {
1989    SESSIONS.with(|s| {
1990        let mut map = s.borrow_mut();
1991        let Some(state) = map.get_mut(id) else {
1992            return Err(SessionCheckpointError::UnknownSession);
1993        };
1994        let after_transcript = transcript_with_session_metadata(state.transcript.clone(), state);
1995        let before_message_count = transcript_message_count(&before_transcript);
1996        let after_message_count = transcript_message_count(&after_transcript);
1997        if crate::values_equal(&before_transcript, &after_transcript) && fs_snapshot_ids.is_empty()
1998        {
1999            return Ok(None);
2000        }
2001        let checkpoint = SessionTurnCheckpoint {
2002            checkpoint_id: format!("turn_{}", uuid::Uuid::now_v7().simple()),
2003            completed_at: crate::orchestration::now_rfc3339(),
2004            before_message_count,
2005            after_message_count,
2006            before_transcript,
2007            after_transcript,
2008            fs_snapshot_ids,
2009        };
2010        state.redo_stack.clear();
2011        state.completed_turn_checkpoints.push(checkpoint.clone());
2012        state.touch();
2013        Ok(Some(checkpoint_summary(&checkpoint)))
2014    })
2015}
2016
2017pub fn rollback_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2018    SESSIONS.with(|s| {
2019        let map = s.borrow();
2020        let Some(state) = map.get(id) else {
2021            return Err(SessionCheckpointError::UnknownSession);
2022        };
2023        state
2024            .completed_turn_checkpoints
2025            .last()
2026            .map(checkpoint_summary)
2027            .ok_or(SessionCheckpointError::NoCheckpoint)
2028    })
2029}
2030
2031pub fn redo_plan(id: &str) -> Result<SessionCheckpointSummary, SessionCheckpointError> {
2032    SESSIONS.with(|s| {
2033        let map = s.borrow();
2034        let Some(state) = map.get(id) else {
2035            return Err(SessionCheckpointError::UnknownSession);
2036        };
2037        state
2038            .redo_stack
2039            .last()
2040            .map(|entry| {
2041                let mut summary = checkpoint_summary(&entry.checkpoint);
2042                summary.fs_snapshot_ids = entry.redo_fs_snapshot_ids.clone();
2043                summary
2044            })
2045            .ok_or(SessionCheckpointError::NoRedo)
2046    })
2047}
2048
2049pub fn rollback_last_completed_turn(
2050    id: &str,
2051    redo_fs_snapshot_ids: Vec<String>,
2052) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2053    SESSIONS.with(|s| {
2054        let mut map = s.borrow_mut();
2055        let Some(state) = map.get_mut(id) else {
2056            return Err(SessionCheckpointError::UnknownSession);
2057        };
2058        let Some(checkpoint) = state.completed_turn_checkpoints.pop() else {
2059            return Err(SessionCheckpointError::NoCheckpoint);
2060        };
2061        state.transcript = checkpoint.before_transcript.clone();
2062        state.redo_stack.push(SessionRedoEntry {
2063            checkpoint: checkpoint.clone(),
2064            redo_fs_snapshot_ids: redo_fs_snapshot_ids.clone(),
2065        });
2066        state.touch();
2067        Ok(SessionCheckpointOutcome {
2068            status: "rolled_back",
2069            checkpoint: checkpoint_summary(&checkpoint),
2070            redo_fs_snapshot_ids,
2071        })
2072    })
2073}
2074
2075pub fn redo_last_rollback(id: &str) -> Result<SessionCheckpointOutcome, SessionCheckpointError> {
2076    SESSIONS.with(|s| {
2077        let mut map = s.borrow_mut();
2078        let Some(state) = map.get_mut(id) else {
2079            return Err(SessionCheckpointError::UnknownSession);
2080        };
2081        let Some(entry) = state.redo_stack.pop() else {
2082            return Err(SessionCheckpointError::NoRedo);
2083        };
2084        let checkpoint = entry.checkpoint;
2085        state.transcript = checkpoint.after_transcript.clone();
2086        state.completed_turn_checkpoints.push(checkpoint.clone());
2087        state.touch();
2088        Ok(SessionCheckpointOutcome {
2089            status: "redone",
2090            checkpoint: checkpoint_summary(&checkpoint),
2091            redo_fs_snapshot_ids: entry.redo_fs_snapshot_ids,
2092        })
2093    })
2094}
2095
2096/// Remove malformed reminder events after their drop audit has been emitted.
2097/// Pending-reminder rendering scans the transcript on every LLM call; pruning
2098/// invalid entries makes the drop event one-shot instead of noisy per turn.
2099pub fn prune_invalid_reminder_events(id: &str) -> usize {
2100    SESSIONS.with(|s| {
2101        let mut map = s.borrow_mut();
2102        let Some(state) = map.get_mut(id) else {
2103            return 0;
2104        };
2105        let Some(dict) = state.transcript.as_dict().cloned() else {
2106            return 0;
2107        };
2108        let Some(VmValue::List(events)) = dict.get("events") else {
2109            return 0;
2110        };
2111        let mut pruned = 0_usize;
2112        let mut kept = Vec::with_capacity(events.len());
2113        for event in events.iter().cloned() {
2114            let is_reminder = event
2115                .as_dict()
2116                .and_then(|event| event.get("kind"))
2117                .map(VmValue::display)
2118                .as_deref()
2119                == Some(crate::llm::helpers::SYSTEM_REMINDER_EVENT_KIND);
2120            if !is_reminder {
2121                kept.push(event);
2122                continue;
2123            }
2124            let valid = crate::llm::helpers::reminder_from_event(&event)
2125                .is_some_and(|reminder| !reminder.body.trim().is_empty());
2126            if valid {
2127                kept.push(event);
2128            } else {
2129                pruned += 1;
2130            }
2131        }
2132        if pruned > 0 {
2133            let mut next = dict;
2134            next.insert(
2135                crate::value::intern_key("events"),
2136                VmValue::List(std::sync::Arc::new(kept)),
2137            );
2138            let _ = apply_transcript_with_budget(
2139                state,
2140                VmValue::dict(next),
2141                "prune_invalid_reminder_events",
2142            );
2143            state.touch();
2144        }
2145        pruned
2146    })
2147}
2148
2149/// Apply the reminder TTL lifecycle that runs once per completed agent
2150/// turn. Reminders with `ttl_turns = 1` expire and are removed; larger
2151/// finite TTLs are decremented in place. Expiry audit events are emitted
2152/// to the active EventLog when one is installed.
2153pub fn apply_reminder_post_turn(id: &str, turn: i64) -> Result<serde_json::Value, String> {
2154    let report = SESSIONS.with(|s| {
2155        let mut map = s.borrow_mut();
2156        let Some(state) = map.get_mut(id) else {
2157            return Err(format!(
2158                "agent_session_apply_reminder_post_turn: unknown session id '{id}'"
2159            ));
2160        };
2161        let report = crate::llm::helpers::apply_reminder_post_turn(&state.transcript, turn);
2162        if report.decremented_count > 0 || !report.expired.is_empty() {
2163            if let Some(next) = report.transcript.clone() {
2164                apply_transcript_with_budget(state, next, "apply_reminder_post_turn")?;
2165            }
2166            state.touch();
2167        }
2168        Ok(report)
2169    })?;
2170
2171    for reminder in &report.expired {
2172        let mut payload = crate::llm::helpers::reminder_lifecycle_payload(Some(id), reminder);
2173        if let Some(obj) = payload.as_object_mut() {
2174            obj.insert(
2175                "transcript_id".to_string(),
2176                serde_json::Value::String(id.to_string()),
2177            );
2178            obj.insert(
2179                "reason".to_string(),
2180                serde_json::Value::String("ttl".to_string()),
2181            );
2182            obj.insert(
2183                "ttl_turns_before".to_string(),
2184                serde_json::json!(&reminder.ttl_turns),
2185            );
2186            obj.insert("expired_at_turn".to_string(), serde_json::json!(turn));
2187        }
2188        crate::llm::helpers::emit_reminder_lifecycle_event(
2189            crate::llm::helpers::REMINDER_EXPIRED_EVENT_KIND,
2190            payload,
2191        );
2192    }
2193
2194    Ok(serde_json::json!({
2195        "expired_count": report.expired.len(),
2196        "decremented_count": report.decremented_count,
2197        "remaining_count": report.remaining_count,
2198    }))
2199}
2200
2201/// Inject a typed system reminder into the session transcript's event
2202/// stream. This mirrors `transcript.inject_reminder` for live sessions:
2203/// reminders with the same `dedupe_key` are replaced before the new
2204/// reminder event is appended.
2205pub fn inject_reminder(
2206    id: &str,
2207    reminder: crate::llm::helpers::SystemReminder,
2208) -> Result<ReminderInjectionReport, String> {
2209    let reminder_id = reminder.id.clone();
2210    let dedupe_key = reminder.dedupe_key.clone();
2211    let mut deduped_reminder_ids = Vec::new();
2212    SESSIONS.with(|s| {
2213        let mut map = s.borrow_mut();
2214        let Some(state) = map.get_mut(id) else {
2215            return Err(format!(
2216                "agent_session_inject_reminder: unknown session id '{id}'"
2217            ));
2218        };
2219        let dict = state
2220            .transcript
2221            .as_dict()
2222            .cloned()
2223            .unwrap_or_else(crate::value::DictMap::new);
2224        let mut events: Vec<VmValue> = match dict.get("events") {
2225            Some(VmValue::List(list)) => list.iter().cloned().collect(),
2226            _ => dict
2227                .get("messages")
2228                .and_then(|value| match value {
2229                    VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
2230                    _ => None,
2231                })
2232                .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
2233                .unwrap_or_default(),
2234        };
2235        if let Some(expected_key) = dedupe_key.as_deref() {
2236            events.retain(|event| {
2237                let Some(existing) = crate::llm::helpers::reminder_from_event(event) else {
2238                    return true;
2239                };
2240                if existing.dedupe_key.as_deref() == Some(expected_key) {
2241                    deduped_reminder_ids.push(existing.id);
2242                    false
2243                } else {
2244                    true
2245                }
2246            });
2247        }
2248        events.push(crate::llm::helpers::transcript_reminder_event(&reminder));
2249        let mut next = dict;
2250        next.insert(
2251            crate::value::intern_key("events"),
2252            VmValue::List(std::sync::Arc::new(events)),
2253        );
2254        apply_transcript_with_budget(state, VmValue::dict(next), "inject_reminder")?;
2255        state.touch();
2256        Ok(())
2257    })?;
2258
2259    if !deduped_reminder_ids.is_empty() {
2260        let dropped_count = deduped_reminder_ids.len();
2261        crate::llm::helpers::emit_reminder_lifecycle_event(
2262            crate::llm::helpers::REMINDER_DEDUPED_EVENT_KIND,
2263            serde_json::json!({
2264                "session_id": id,
2265                "transcript_id": id,
2266                "reminder_id": &reminder_id,
2267                "replacing_id": &reminder_id,
2268                "replaced_id": deduped_reminder_ids.first(),
2269                "replaced_ids": &deduped_reminder_ids,
2270                "dedupe_key": &dedupe_key,
2271                "dropped_reminder_ids": &deduped_reminder_ids,
2272                "dropped_count": dropped_count,
2273            }),
2274        );
2275    }
2276
2277    crate::llm::helpers::emit_reminder_lifecycle_event(
2278        crate::llm::helpers::REMINDER_INJECTED_EVENT_KIND,
2279        crate::llm::helpers::reminder_lifecycle_payload(Some(id), &reminder),
2280    );
2281
2282    Ok(ReminderInjectionReport {
2283        reminder_id,
2284        deduped_count: deduped_reminder_ids.len(),
2285    })
2286}
2287
2288/// Append a transcript event to the session without mutating its
2289/// message list. Used for orchestration-side lineage events (sub-agent
2290/// spawn/completion, workflow hooks, etc.) that should survive
2291/// persistence/replay without being replayed back into the model as
2292/// conversational messages.
2293pub fn append_event(id: &str, event: VmValue) -> Result<(), String> {
2294    let Some(event_dict) = event.as_dict() else {
2295        return Err("agent_session_append_event: event must be a dict".into());
2296    };
2297    let kind_ok = matches!(event_dict.get("kind"), Some(VmValue::String(_)));
2298    if !kind_ok {
2299        return Err("agent_session_append_event: event must have a string `kind`".into());
2300    }
2301    SESSIONS.with(|s| {
2302        let mut map = s.borrow_mut();
2303        let Some(state) = map.get_mut(id) else {
2304            return Err(format!(
2305                "agent_session_append_event: unknown session id '{id}'"
2306            ));
2307        };
2308        append_event_to_state(state, event, "append_event")?;
2309        Ok(())
2310    })
2311}
2312
2313fn append_event_to_state(
2314    state: &mut SessionState,
2315    event: VmValue,
2316    action: &str,
2317) -> Result<(), String> {
2318    let dict = state
2319        .transcript
2320        .as_dict()
2321        .cloned()
2322        .unwrap_or_else(crate::value::DictMap::new);
2323    let mut events: Vec<VmValue> = match dict.get("events") {
2324        Some(VmValue::List(list)) => list.iter().cloned().collect(),
2325        _ => dict
2326            .get("messages")
2327            .and_then(|value| match value {
2328                VmValue::List(list) => Some(list.iter().cloned().collect::<Vec<_>>()),
2329                _ => None,
2330            })
2331            .map(|messages| crate::llm::helpers::transcript_events_from_messages(&messages))
2332            .unwrap_or_default(),
2333    };
2334    events.push(event);
2335    let mut next = dict;
2336    next.insert(
2337        crate::value::intern_key("events"),
2338        VmValue::List(std::sync::Arc::new(events)),
2339    );
2340    apply_transcript_with_budget(state, VmValue::dict(next), action)
2341}
2342
2343/// Replace the transcript's message list wholesale. Used by the
2344/// in-loop compaction path, which operates on JSON messages.
2345pub fn replace_messages(id: &str, messages: &[serde_json::Value]) -> Result<(), String> {
2346    replace_messages_with_summary(id, messages, None)
2347}
2348
2349/// Replace the transcript's message list and optionally update the
2350/// `summary` field on the persisted transcript. The compaction path
2351/// uses this to publish the human-readable rollup line that
2352/// `transcript_summary(transcript)` exposes to host code.
2353pub fn replace_messages_with_summary(
2354    id: &str,
2355    messages: &[serde_json::Value],
2356    summary: Option<&str>,
2357) -> Result<(), String> {
2358    SESSIONS.with(|s| {
2359        let mut map = s.borrow_mut();
2360        let Some(state) = map.get_mut(id) else {
2361            return Err(format!(
2362                "agent_session_replace_messages: unknown session id '{id}'"
2363            ));
2364        };
2365        let dict = state
2366            .transcript
2367            .as_dict()
2368            .cloned()
2369            .unwrap_or_else(crate::value::DictMap::new);
2370        let vm_messages: Vec<VmValue> = messages
2371            .iter()
2372            .map(crate::stdlib::json_to_vm_value)
2373            .collect();
2374        let mut next = dict;
2375        next.insert(
2376            crate::value::intern_key("events"),
2377            VmValue::List(std::sync::Arc::new(
2378                crate::llm::helpers::transcript_events_from_messages(&vm_messages),
2379            )),
2380        );
2381        next.insert(
2382            crate::value::intern_key("messages"),
2383            VmValue::List(std::sync::Arc::new(vm_messages)),
2384        );
2385        if let Some(summary) = summary {
2386            next.put_str("summary", summary);
2387        } else {
2388            next.remove("summary");
2389        }
2390        apply_transcript_with_budget(state, VmValue::dict(next), "replace_messages")?;
2391        Ok(())
2392    })
2393}
2394
2395pub fn append_subscriber(id: &str, callback: VmValue) {
2396    open_or_create(Some(id.to_string()));
2397    SESSIONS.with(|s| {
2398        if let Some(state) = s.borrow_mut().get_mut(id) {
2399            state.subscribers.push(callback);
2400            state.touch();
2401        }
2402    });
2403}
2404
2405pub fn subscribers_for(id: &str) -> Vec<VmValue> {
2406    SESSIONS.with(|s| {
2407        s.borrow()
2408            .get(id)
2409            .map(|state| state.subscribers.clone())
2410            .unwrap_or_default()
2411    })
2412}
2413
2414pub fn subscriber_count(id: &str) -> usize {
2415    SESSIONS.with(|s| {
2416        s.borrow()
2417            .get(id)
2418            .map(|state| state.subscribers.len())
2419            .unwrap_or(0)
2420    })
2421}
2422
2423/// Persist the set of active skill names for session resume. Called at
2424/// the end of an agent_loop run; the next `open_or_create` for this id
2425/// reads them back via [`active_skills`].
2426pub fn set_active_skills(id: &str, skills: Vec<String>) {
2427    SESSIONS.with(|s| {
2428        if let Some(state) = s.borrow_mut().get_mut(id) {
2429            state.active_skills = skills;
2430            state.touch();
2431        }
2432    });
2433}
2434
2435/// Skills that were active at the end of the previous agent_loop run
2436/// against this session. Returns an empty vec when the session doesn't
2437/// exist or nothing was persisted.
2438pub fn active_skills(id: &str) -> Vec<String> {
2439    SESSIONS.with(|s| {
2440        s.borrow()
2441            .get(id)
2442            .map(|state| state.active_skills.clone())
2443            .unwrap_or_default()
2444    })
2445}
2446
2447/// Claim the tool-calling contract for a session.
2448///
2449/// The first loop against a named session records its `tool_format`.
2450/// Later re-entry must use the same format so prompt/history generated
2451/// under a text contract is never replayed as native, or vice versa.
2452pub fn claim_tool_format(id: &str, tool_format: &str) -> Result<(), String> {
2453    let tool_format = tool_format.trim();
2454    if tool_format.is_empty() {
2455        return Ok(());
2456    }
2457    SESSIONS.with(|s| {
2458        let mut map = s.borrow_mut();
2459        let Some(state) = map.get_mut(id) else {
2460            return Err(format!("agent session '{id}' does not exist"));
2461        };
2462        match state.tool_format.as_deref() {
2463            Some(existing) if existing != tool_format => Err(format!(
2464                "agent session '{id}' is locked to tool_format='{existing}', but this run requested tool_format='{tool_format}'. Start a new session or fork/reset the transcript before changing tool mode."
2465            )),
2466            Some(_) => {
2467                state.touch();
2468                Ok(())
2469            }
2470            None => {
2471                state.tool_format = Some(tool_format.to_string());
2472                state.touch();
2473                Ok(())
2474            }
2475        }
2476    })
2477}
2478
2479pub fn tool_format(id: &str) -> Option<String> {
2480    SESSIONS.with(|s| {
2481        s.borrow()
2482            .get(id)
2483            .and_then(|state| state.tool_format.clone())
2484    })
2485}
2486
2487pub fn record_system_prompt(id: &str, system_prompt: &str) -> Result<(), String> {
2488    let system_prompt = system_prompt.trim();
2489    if system_prompt.is_empty() {
2490        return Ok(());
2491    }
2492    assert_cache_stable_system_prompt(system_prompt);
2493    SESSIONS.with(|s| {
2494        let mut map = s.borrow_mut();
2495        let Some(state) = map.get_mut(id) else {
2496            return Err(format!("agent session '{id}' does not exist"));
2497        };
2498        let changed = state.system_prompt.as_deref() != Some(system_prompt);
2499        state.system_prompt = Some(system_prompt.to_string());
2500        let dict = state
2501            .transcript
2502            .as_dict()
2503            .cloned()
2504            .unwrap_or_else(crate::value::DictMap::new);
2505        let mut next = dict;
2506        apply_system_prompt_metadata(&mut next, system_prompt);
2507        if changed {
2508            let mut events: Vec<VmValue> = match next.get("events") {
2509                Some(VmValue::List(list)) => list.iter().cloned().collect(),
2510                _ => Vec::new(),
2511            };
2512            events.push(crate::llm::helpers::transcript_event(
2513                "system_prompt",
2514                "system",
2515                "internal",
2516                "",
2517                Some(crate::llm::helpers::system_prompt_event_metadata(
2518                    system_prompt,
2519                )),
2520            ));
2521            next.insert(
2522                crate::value::intern_key("events"),
2523                VmValue::List(std::sync::Arc::new(events)),
2524            );
2525        }
2526        apply_transcript_with_budget(state, VmValue::dict(next), "record_system_prompt")?;
2527        Ok(())
2528    })
2529}
2530
2531pub fn system_prompt(id: &str) -> Option<String> {
2532    SESSIONS.with(|s| {
2533        s.borrow()
2534            .get(id)
2535            .and_then(|state| state.system_prompt.clone())
2536    })
2537}
2538
2539#[cfg(debug_assertions)]
2540fn forbidden_workspace_prompt_token(system_prompt: &str) -> Option<&'static str> {
2541    let mut remaining = system_prompt;
2542    while let Some(index) = remaining.find("{{") {
2543        let candidate = remaining[index + 2..].trim_start();
2544        if candidate.starts_with("workspace_") {
2545            return Some("workspace_");
2546        }
2547        if candidate.starts_with("project_") {
2548            return Some("project_");
2549        }
2550        remaining = candidate;
2551    }
2552    None
2553}
2554
2555#[cfg(debug_assertions)]
2556fn assert_cache_stable_system_prompt(system_prompt: &str) {
2557    if let Some(prefix) = forbidden_workspace_prompt_token(system_prompt) {
2558        panic!(
2559            "{CACHE_STABLE_SYSTEM_PROMPT_DIAGNOSTIC}: session system prompts must not interpolate `{{{{{prefix}...` tokens; move workspace/project context into the workspace-anchor reminder"
2560        );
2561    }
2562}
2563
2564#[cfg(not(debug_assertions))]
2565fn assert_cache_stable_system_prompt(_system_prompt: &str) {}
2566
2567/// Pin (or clear, with `None`) a model selector on a session. Returns
2568/// `Ok(true)` when the value actually changed so callers can decide
2569/// whether to broadcast a notification. The selector is stored verbatim
2570/// — alias / catalog resolution is the call-site's job.
2571pub fn set_pinned_model(id: &str, model: Option<String>) -> Result<bool, String> {
2572    let normalized = model
2573        .map(|value| value.trim().to_string())
2574        .filter(|value| !value.is_empty());
2575    SESSIONS.with(|s| {
2576        let mut map = s.borrow_mut();
2577        let Some(state) = map.get_mut(id) else {
2578            return Err(format!("agent session '{id}' does not exist"));
2579        };
2580        let changed = state.pinned_model != normalized;
2581        state.pinned_model = normalized;
2582        state.touch();
2583        Ok(changed)
2584    })
2585}
2586
2587/// Read the session's pinned model selector, if any. Consumed by
2588/// `vm_resolve_model` as the per-session default when a script-level
2589/// `llm_call` does not pass `model:` explicitly.
2590pub fn pinned_model(id: &str) -> Option<String> {
2591    SESSIONS.with(|s| {
2592        s.borrow()
2593            .get(id)
2594            .and_then(|state| state.pinned_model.clone())
2595    })
2596}
2597
2598/// Pin (or clear) the session-level provider-aware reasoning policy.
2599pub fn set_pinned_reasoning_policy(id: &str, policy: Option<String>) -> Result<bool, String> {
2600    let normalized = match policy {
2601        Some(value) => crate::llm::reasoning_policy::normalize_policy_selector(&value)?,
2602        None => None,
2603    };
2604    SESSIONS.with(|s| {
2605        let mut map = s.borrow_mut();
2606        let Some(state) = map.get_mut(id) else {
2607            return Err(format!("agent session '{id}' does not exist"));
2608        };
2609        let changed = state.pinned_reasoning_policy != normalized;
2610        state.pinned_reasoning_policy = normalized;
2611        state.touch();
2612        Ok(changed)
2613    })
2614}
2615
2616/// Read the session's pinned reasoning policy, if any.
2617pub fn pinned_reasoning_policy(id: &str) -> Option<String> {
2618    SESSIONS.with(|s| {
2619        s.borrow()
2620            .get(id)
2621            .and_then(|state| state.pinned_reasoning_policy.clone())
2622    })
2623}
2624
2625/// Set (or clear, with `None`) the typed workspace anchor on a session.
2626/// Returns `Ok(true)` when the value actually changed so callers can
2627/// decide whether to broadcast `AnchorChanged` notifications.
2628pub fn set_workspace_anchor(id: &str, anchor: Option<WorkspaceAnchor>) -> Result<bool, String> {
2629    SESSIONS.with(|s| {
2630        let mut map = s.borrow_mut();
2631        let Some(state) = map.get_mut(id) else {
2632            return Err(format!("agent session '{id}' does not exist"));
2633        };
2634        let changed = state.workspace_anchor != anchor;
2635        state.workspace_anchor = anchor;
2636        if changed {
2637            state.redo_stack.clear();
2638            crate::llm::permissions::clear_session_grants(id);
2639        }
2640        state.touch();
2641        Ok(changed)
2642    })
2643}
2644
2645/// Read the session's typed workspace anchor, if any.
2646pub fn workspace_anchor(id: &str) -> Option<WorkspaceAnchor> {
2647    SESSIONS.with(|s| {
2648        s.borrow()
2649            .get(id)
2650            .and_then(|state| state.workspace_anchor.clone())
2651    })
2652}
2653
2654/// Outcome of `reanchor_session`: previous + new anchor and whether the
2655/// swap actually moved anything. Callers use `changed` to suppress
2656/// no-op transcript / live events.
2657#[derive(Clone, Debug, PartialEq, Eq)]
2658pub struct ReanchorOutcome {
2659    pub previous: Option<WorkspaceAnchor>,
2660    pub current: WorkspaceAnchor,
2661    pub changed: bool,
2662}
2663
2664/// Atomically swap the session's primary anchor + emit the canonical
2665/// `AnchorChanged` transcript event and live `AgentEvent::AnchorChanged`
2666/// notification (#2218). Clears session-scoped permission grants so
2667/// stale anchor-based decisions don't leak into the next turn.
2668pub fn reanchor_session(
2669    id: &str,
2670    new_anchor: WorkspaceAnchor,
2671    carry_transcript: bool,
2672    compacted: bool,
2673    reason: Option<String>,
2674) -> Result<ReanchorOutcome, String> {
2675    let outcome = SESSIONS.with(|s| {
2676        let mut map = s.borrow_mut();
2677        let Some(state) = map.get_mut(id) else {
2678            return Err(format!("agent session '{id}' does not exist"));
2679        };
2680        let previous = state.workspace_anchor.clone();
2681        let changed = previous.as_ref() != Some(&new_anchor);
2682        state.workspace_anchor = Some(new_anchor.clone());
2683        if changed {
2684            crate::llm::permissions::clear_session_grants(id);
2685        }
2686        state.touch();
2687        Ok(ReanchorOutcome {
2688            previous,
2689            current: new_anchor,
2690            changed,
2691        })
2692    })?;
2693    if !outcome.changed {
2694        return Ok(outcome);
2695    }
2696    let previous_json = outcome.previous.as_ref().map(WorkspaceAnchor::to_json);
2697    let current_json = outcome.current.to_json();
2698    let event_metadata = serde_json::json!({
2699        "previous": previous_json,
2700        "current": current_json,
2701        "carry_transcript": carry_transcript,
2702        "compacted": compacted,
2703        "reason": reason,
2704    });
2705    let event = crate::llm::helpers::transcript_event(
2706        "AnchorChanged",
2707        "system",
2708        "internal",
2709        "",
2710        Some(event_metadata),
2711    );
2712    let _ = append_event(id, event);
2713    crate::llm::emit_live_agent_event_sync(&crate::agent_events::AgentEvent::AnchorChanged {
2714        session_id: id.to_string(),
2715        previous: previous_json,
2716        current: current_json,
2717        carry_transcript,
2718        compacted,
2719        reason,
2720    });
2721    Ok(outcome)
2722}
2723
2724/// Set session-local workspace defaults. Returns `Ok(true)` when the
2725/// policy changed.
2726pub fn set_workspace_policy(id: &str, policy: WorkspacePolicy) -> Result<bool, String> {
2727    SESSIONS.with(|s| {
2728        let mut map = s.borrow_mut();
2729        let Some(state) = map.get_mut(id) else {
2730            return Err(format!("agent session '{id}' does not exist"));
2731        };
2732        let changed = state.workspace_policy != policy;
2733        state.workspace_policy = policy;
2734        if changed {
2735            state.redo_stack.clear();
2736        }
2737        state.touch();
2738        Ok(changed)
2739    })
2740}
2741
2742/// Read the session's workspace policy, if the session exists.
2743pub fn workspace_policy(id: &str) -> Option<WorkspacePolicy> {
2744    SESSIONS.with(|s| {
2745        s.borrow()
2746            .get(id)
2747            .map(|state| state.workspace_policy.clone())
2748    })
2749}
2750
2751/// Validate and mount an additional workspace root on an anchored
2752/// session. When the path is already mounted, updates its mount mode
2753/// in place and refreshes its `mounted_at` timestamp.
2754pub fn add_workspace_root(
2755    id: &str,
2756    root: &str,
2757    mount_mode: Option<MountMode>,
2758    reason: Option<String>,
2759) -> Result<String, String> {
2760    let normalized_root = validate_workspace_root_path(root)?;
2761    let mounted_at = crate::orchestration::now_rfc3339();
2762    SESSIONS.with(|s| {
2763        let mut map = s.borrow_mut();
2764        let Some(state) = map.get_mut(id) else {
2765            return Err(format!("agent session '{id}' does not exist"));
2766        };
2767        let default_mount_mode = state.workspace_policy.default_mount_mode;
2768        let Some(anchor) = state.workspace_anchor.as_mut() else {
2769            return Err(format!("agent session '{id}' has no workspace anchor"));
2770        };
2771        let resolved_mount_mode = mount_mode.unwrap_or(default_mount_mode);
2772        if let Some(existing) = anchor
2773            .additional_roots
2774            .iter_mut()
2775            .find(|entry| entry.path == normalized_root)
2776        {
2777            let changed =
2778                existing.mount_mode != resolved_mount_mode || existing.mounted_at != mounted_at;
2779            existing.mount_mode = resolved_mount_mode;
2780            existing.mounted_at = mounted_at.clone();
2781            if changed {
2782                state.redo_stack.clear();
2783            }
2784        } else {
2785            anchor.additional_roots.push(MountedRoot {
2786                path: normalized_root.clone(),
2787                mount_mode: resolved_mount_mode,
2788                mounted_at: mounted_at.clone(),
2789            });
2790            state.redo_stack.clear();
2791        }
2792        let event = crate::llm::helpers::transcript_event(
2793            "RootMounted",
2794            "system",
2795            "internal",
2796            "",
2797            Some(serde_json::json!({
2798                "path": normalized_root.to_string_lossy(),
2799                "mount_mode": resolved_mount_mode.as_str(),
2800                "mounted_at": mounted_at.clone(),
2801                "reason": reason,
2802            })),
2803        );
2804        append_event_to_state(state, event, "add_workspace_root")?;
2805        crate::llm::permissions::clear_session_grants(id);
2806        state.touch();
2807        Ok(mounted_at.clone())
2808    })
2809}
2810
2811/// Remove one mounted root from an anchored session. Returns whether an
2812/// existing mount entry was deleted. Removing an absent root is a no-op.
2813pub fn remove_workspace_root(id: &str, root: &str) -> Result<bool, String> {
2814    let normalized_root = normalize_workspace_root_path(root);
2815    SESSIONS.with(|s| {
2816        let mut map = s.borrow_mut();
2817        let Some(state) = map.get_mut(id) else {
2818            return Err(format!("agent session '{id}' does not exist"));
2819        };
2820        let Some(anchor) = state.workspace_anchor.as_mut() else {
2821            return Err(format!("agent session '{id}' has no workspace anchor"));
2822        };
2823        let before = anchor.additional_roots.len();
2824        anchor
2825            .additional_roots
2826            .retain(|entry| entry.path != normalized_root);
2827        let removed = anchor.additional_roots.len() != before;
2828        if removed {
2829            state.redo_stack.clear();
2830            crate::llm::permissions::clear_session_grants(id);
2831        }
2832        state.touch();
2833        Ok(removed)
2834    })
2835}
2836
2837pub fn list_workspace_roots(id: &str) -> Result<(PathBuf, Vec<MountedRoot>), String> {
2838    SESSIONS.with(|s| {
2839        let map = s.borrow();
2840        let Some(state) = map.get(id) else {
2841            return Err(format!("agent session '{id}' does not exist"));
2842        };
2843        let Some(anchor) = state.workspace_anchor.as_ref() else {
2844            return Err(format!("agent session '{id}' has no workspace anchor"));
2845        };
2846        Ok((anchor.primary.clone(), anchor.additional_roots.clone()))
2847    })
2848}
2849
2850fn validate_workspace_root_path(root: &str) -> Result<PathBuf, String> {
2851    let normalized = normalize_workspace_root_path(root);
2852    let canonical = std::fs::canonicalize(&normalized)
2853        .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
2854    let metadata = std::fs::metadata(&canonical)
2855        .map_err(|error| format!("workspace root '{root}' must exist and be readable: {error}"))?;
2856    if !metadata.is_dir() {
2857        return Err(format!("workspace root '{root}' must be a directory"));
2858    }
2859    std::fs::read_dir(&canonical)
2860        .map_err(|error| format!("workspace root '{root}' must be readable: {error}"))?;
2861    Ok(canonical)
2862}
2863
2864fn normalize_workspace_root_path(root: &str) -> PathBuf {
2865    let absolute = crate::stdlib::process::normalize_context_path(Path::new(root));
2866    std::fs::canonicalize(&absolute).unwrap_or(absolute)
2867}
2868
2869fn empty_transcript(id: &str) -> VmValue {
2870    use crate::llm::helpers::new_transcript_with;
2871    new_transcript_with(Some(id.to_string()), Vec::new(), None, None)
2872}
2873
2874fn clone_transcript_with_id(transcript: &VmValue, new_id: &str) -> VmValue {
2875    let Some(dict) = transcript.as_dict() else {
2876        return empty_transcript(new_id);
2877    };
2878    let mut next = dict.clone();
2879    next.put_str("id", new_id);
2880    VmValue::dict(next)
2881}
2882
2883fn clone_transcript_with_parent(transcript: &VmValue, parent_id: &str) -> VmValue {
2884    let Some(dict) = transcript.as_dict() else {
2885        return transcript.clone();
2886    };
2887    let mut next = dict.clone();
2888    let metadata = match next.get("metadata") {
2889        Some(VmValue::Dict(metadata)) => {
2890            let mut metadata = metadata.as_ref().clone();
2891            metadata.put_str("parent_session_id", parent_id);
2892            VmValue::dict(metadata)
2893        }
2894        _ => VmValue::dict(BTreeMap::from([(
2895            "parent_session_id".to_string(),
2896            VmValue::String(arcstr::ArcStr::from(parent_id.to_string())),
2897        )])),
2898    };
2899    next.insert(crate::value::intern_key("metadata"), metadata);
2900    VmValue::dict(next)
2901}
2902
2903fn apply_system_prompt_metadata(next: &mut crate::value::DictMap, system_prompt: &str) {
2904    let mut metadata = match next.get("metadata") {
2905        Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
2906        _ => crate::value::DictMap::new(),
2907    };
2908    metadata.insert(
2909        crate::value::intern_key("system_prompt"),
2910        crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
2911            system_prompt,
2912        )),
2913    );
2914    next.insert(
2915        crate::value::intern_key("metadata"),
2916        VmValue::dict(metadata),
2917    );
2918}
2919
2920fn transcript_with_session_metadata(transcript: VmValue, state: &SessionState) -> VmValue {
2921    let Some(dict) = transcript.as_dict() else {
2922        return transcript;
2923    };
2924    let mut next = dict.clone();
2925    let mut metadata = match next.get("metadata") {
2926        Some(VmValue::Dict(metadata)) => metadata.as_ref().clone(),
2927        _ => crate::value::DictMap::new(),
2928    };
2929    if let Some(tool_format) = state.tool_format.as_ref() {
2930        metadata.put_str("tool_format", tool_format.clone());
2931        metadata.insert(
2932            crate::value::intern_key("tool_mode_locked"),
2933            VmValue::Bool(true),
2934        );
2935    }
2936    if let Some(system_prompt) = state.system_prompt.as_ref() {
2937        metadata.insert(
2938            crate::value::intern_key("system_prompt"),
2939            crate::stdlib::json_to_vm_value(&crate::llm::helpers::system_prompt_metadata(
2940                system_prompt,
2941            )),
2942        );
2943    }
2944    if let Some(actor_chain) = state.actor_chain.as_ref() {
2945        metadata.insert(
2946            crate::value::intern_key("actor_chain"),
2947            actor_chain.to_vm_value(),
2948        );
2949    } else {
2950        metadata.remove("actor_chain");
2951    }
2952    if let Some(anchor) = state.workspace_anchor.as_ref() {
2953        metadata.insert(
2954            crate::value::intern_key(WORKSPACE_ANCHOR_METADATA_KEY),
2955            anchor.to_vm_value(),
2956        );
2957    } else {
2958        metadata.remove(WORKSPACE_ANCHOR_METADATA_KEY);
2959    }
2960    if let Some(scratchpad) = state.scratchpad.as_ref() {
2961        metadata.insert(
2962            crate::value::intern_key("agent_scratchpad"),
2963            scratchpad.clone(),
2964        );
2965        metadata.insert(
2966            crate::value::intern_key("agent_scratchpad_version"),
2967            VmValue::Int(state.scratchpad_version as i64),
2968        );
2969    } else {
2970        metadata.remove("agent_scratchpad");
2971        metadata.remove("agent_scratchpad_version");
2972    }
2973    if let Some(last_action) = state.last_transcript_budget_action.as_ref() {
2974        let usage = transcript_usage(
2975            &VmValue::dict(next.clone()),
2976            state.transcript_budget_policy.max_approx_bytes.is_some(),
2977        );
2978        metadata.insert(
2979            crate::value::intern_key("transcript_budget"),
2980            crate::stdlib::json_to_vm_value(&serde_json::json!({
2981                "policy": transcript_budget_policy_json(&state.transcript_budget_policy.normalized()),
2982                "usage": transcript_budget_usage_json(&usage),
2983                "last_action": last_action,
2984            })),
2985        );
2986    }
2987    if !metadata.is_empty() {
2988        next.insert(
2989            crate::value::intern_key("metadata"),
2990            VmValue::dict(metadata),
2991        );
2992    }
2993    VmValue::dict(next)
2994}
2995
2996/// Materialize the externally-visible view of a session: the transcript
2997/// plus session-level metadata (lineage, pinned model, scratchpad, live
2998/// clients, checkpoint counts, ...).
2999///
3000/// `state.subscribers` (closure callbacks registered for agent-loop
3001/// events) are intentionally omitted: they are ephemeral by design,
3002/// owned by the current-thread agent-loop worker, and are re-registered
3003/// by the host when a persisted session is reopened. Serializing them
3004/// would at best persist a useless display-string — see
3005/// `crate::llm::vm_value_to_json_strict` for the seams that do guard
3006/// against that class of silent corruption.
3007fn session_snapshot(state: &SessionState) -> VmValue {
3008    let transcript = transcript_with_session_metadata(state.transcript.clone(), state);
3009    let Some(dict) = transcript.as_dict() else {
3010        return state.transcript.clone();
3011    };
3012    let mut next = dict.clone();
3013    let length = next
3014        .get("messages")
3015        .and_then(|value| match value {
3016            VmValue::List(list) => Some(list.len() as i64),
3017            _ => None,
3018        })
3019        .unwrap_or(0);
3020    next.insert(crate::value::intern_key("length"), VmValue::Int(length));
3021    next.put_str("created_at", state.created_at.clone());
3022    next.insert(
3023        crate::value::intern_key("parent_id"),
3024        state
3025            .parent_id
3026            .as_ref()
3027            .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3028            .unwrap_or(VmValue::Nil),
3029    );
3030    next.insert(
3031        crate::value::intern_key("child_ids"),
3032        VmValue::List(std::sync::Arc::new(
3033            state
3034                .child_ids
3035                .iter()
3036                .cloned()
3037                .map(|id| VmValue::String(arcstr::ArcStr::from(id)))
3038                .collect(),
3039        )),
3040    );
3041    next.insert(
3042        crate::value::intern_key("branched_at_event_index"),
3043        state
3044            .branched_at_event_index
3045            .map(|index| VmValue::Int(index as i64))
3046            .unwrap_or(VmValue::Nil),
3047    );
3048    next.insert(
3049        crate::value::intern_key("system_prompt"),
3050        state
3051            .system_prompt
3052            .as_ref()
3053            .map(|prompt| VmValue::String(arcstr::ArcStr::from(prompt.clone())))
3054            .unwrap_or(VmValue::Nil),
3055    );
3056    next.insert(
3057        crate::value::intern_key("tool_format"),
3058        state
3059            .tool_format
3060            .as_ref()
3061            .map(|format| VmValue::String(arcstr::ArcStr::from(format.clone())))
3062            .unwrap_or(VmValue::Nil),
3063    );
3064    next.insert(
3065        crate::value::intern_key("pinned_model"),
3066        state
3067            .pinned_model
3068            .as_ref()
3069            .map(|model| VmValue::String(arcstr::ArcStr::from(model.clone())))
3070            .unwrap_or(VmValue::Nil),
3071    );
3072    next.insert(
3073        crate::value::intern_key("pinned_reasoning_policy"),
3074        state
3075            .pinned_reasoning_policy
3076            .as_ref()
3077            .map(|policy| VmValue::String(arcstr::ArcStr::from(policy.clone())))
3078            .unwrap_or(VmValue::Nil),
3079    );
3080    next.insert(
3081        crate::value::intern_key("actor_chain"),
3082        state
3083            .actor_chain
3084            .as_ref()
3085            .map(ActorChain::to_vm_value)
3086            .unwrap_or(VmValue::Nil),
3087    );
3088    next.insert(
3089        crate::value::intern_key("scratchpad"),
3090        state.scratchpad.clone().unwrap_or(VmValue::Nil),
3091    );
3092    next.insert(
3093        crate::value::intern_key("scratchpad_version"),
3094        VmValue::Int(state.scratchpad_version as i64),
3095    );
3096    next.insert(
3097        crate::value::intern_key("workspace_anchor"),
3098        state
3099            .workspace_anchor
3100            .as_ref()
3101            .map(WorkspaceAnchor::to_vm_value)
3102            .unwrap_or(VmValue::Nil),
3103    );
3104    next.insert(
3105        crate::value::intern_key("workspace_policy"),
3106        state.workspace_policy.to_vm_value(),
3107    );
3108    next.insert(
3109        crate::value::intern_key("live_clients"),
3110        crate::stdlib::json_to_vm_value(&serde_json::Value::Array(
3111            state.live_clients.values().map(live_client_json).collect(),
3112        )),
3113    );
3114    next.insert(
3115        crate::value::intern_key("live_controller_id"),
3116        state
3117            .live_controller_id
3118            .as_ref()
3119            .map(|id| VmValue::String(arcstr::ArcStr::from(id.clone())))
3120            .unwrap_or(VmValue::Nil),
3121    );
3122    next.insert(
3123        crate::value::intern_key("completed_turn_checkpoint_count"),
3124        VmValue::Int(state.completed_turn_checkpoints.len() as i64),
3125    );
3126    next.insert(
3127        crate::value::intern_key("redo_checkpoint_count"),
3128        VmValue::Int(state.redo_stack.len() as i64),
3129    );
3130    VmValue::dict(next)
3131}
3132
3133fn update_lineage(
3134    map: &mut HashMap<String, SessionState>,
3135    parent_id: &str,
3136    child_id: &str,
3137    branched_at_event_index: Option<usize>,
3138) {
3139    let old_parent_id = map.get(child_id).and_then(|child| child.parent_id.clone());
3140    if let Some(old_parent_id) = old_parent_id.filter(|old_parent_id| old_parent_id != parent_id) {
3141        if let Some(old_parent) = map.get_mut(&old_parent_id) {
3142            old_parent.child_ids.retain(|id| id != child_id);
3143            old_parent.touch();
3144        }
3145    }
3146    if let Some(parent) = map.get_mut(parent_id) {
3147        parent.touch();
3148        if !parent.child_ids.iter().any(|id| id == child_id) {
3149            parent.child_ids.push(child_id.to_string());
3150        }
3151    }
3152    if let Some(child) = map.get_mut(child_id) {
3153        child.touch();
3154        child.parent_id = Some(parent_id.to_string());
3155        child.branched_at_event_index = branched_at_event_index;
3156        child.transcript = clone_transcript_with_parent(&child.transcript, parent_id);
3157    }
3158}
3159
3160fn branch_event_index(transcript: &VmValue, keep_first: usize) -> usize {
3161    if keep_first == 0 {
3162        return 0;
3163    }
3164    let Some(dict) = transcript.as_dict() else {
3165        return keep_first;
3166    };
3167    let Some(VmValue::List(events)) = dict.get("events") else {
3168        return keep_first;
3169    };
3170    event_prefix_len_for_messages(events, keep_first)
3171}
3172
3173fn event_kind(event: &VmValue) -> Option<String> {
3174    event
3175        .as_dict()
3176        .and_then(|dict| dict.get("kind"))
3177        .map(VmValue::display)
3178}
3179
3180fn event_id(event: &VmValue) -> Option<String> {
3181    event
3182        .as_dict()
3183        .and_then(|dict| dict.get("id"))
3184        .map(VmValue::display)
3185}
3186
3187fn is_turn_event(event: &VmValue) -> bool {
3188    matches!(
3189        event_kind(event).as_deref(),
3190        Some("message" | "tool_result")
3191    )
3192}
3193
3194fn event_prefix_len_for_messages(events: &[VmValue], keep_first: usize) -> usize {
3195    if keep_first == 0 {
3196        return 0;
3197    }
3198    let mut retained_messages = 0usize;
3199    for (index, event) in events.iter().enumerate() {
3200        if is_turn_event(event) {
3201            retained_messages += 1;
3202            if retained_messages == keep_first {
3203                return index + 1;
3204            }
3205        }
3206    }
3207    events.len()
3208}
3209
3210fn turn_event_id_for_count(events: &[VmValue], keep_first: usize) -> Option<String> {
3211    if keep_first == 0 {
3212        return None;
3213    }
3214    let mut retained_messages = 0usize;
3215    for event in events {
3216        if is_turn_event(event) {
3217            retained_messages += 1;
3218            if retained_messages == keep_first {
3219                return event_id(event);
3220            }
3221        }
3222    }
3223    None
3224}
3225
3226#[cfg(test)]
3227#[path = "agent_sessions_tests.rs"]
3228mod tests;