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