Skip to main content

harn_vm/
agent_transcript_budget.rs

1//! Transcript budget enforcement and recovery.
2//!
3//! A self-contained slice of the session transcript concern: given a
4//! candidate transcript and a session's [`SessionTranscriptBudgetPolicy`],
5//! decide whether it fits under the message/event/byte caps and, if not,
6//! recover by rejecting, trimming, or LLM-compacting it — attaching an
7//! audit event that records the before/after usage. Extracted verbatim
8//! from `agent_sessions` (behavior-preserving); the module operates on
9//! `VmValue` transcripts and the session's policy, reaching into
10//! [`SessionState`] only for the transcript field, the pinned policy, the
11//! last-action audit slot, and the id.
12
13use crate::agent_sessions::{
14    SessionState, SessionTranscriptBudgetPolicy, TranscriptBudgetRecovery,
15};
16use crate::value::{VmDictExt, VmValue};
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub(crate) struct TranscriptBudgetUsage {
20    message_count: usize,
21    event_count: usize,
22    approx_bytes: Option<usize>,
23}
24
25fn transcript_messages_from_dict(dict: &crate::value::DictMap) -> Vec<VmValue> {
26    match dict.get("messages") {
27        Some(VmValue::List(list)) => list.iter().cloned().collect(),
28        _ => Vec::new(),
29    }
30}
31
32pub(crate) fn transcript_message_count(transcript: &VmValue) -> usize {
33    transcript
34        .as_dict()
35        .map(transcript_messages_from_dict)
36        .map(|messages| messages.len())
37        .unwrap_or(0)
38}
39
40fn transcript_events_from_dict(dict: &crate::value::DictMap) -> Vec<VmValue> {
41    match dict.get("events") {
42        Some(VmValue::List(list)) => list.iter().cloned().collect(),
43        _ => {
44            let messages = transcript_messages_from_dict(dict);
45            crate::llm::helpers::transcript_events_from_messages(&messages)
46        }
47    }
48}
49
50pub(crate) fn transcript_usage(transcript: &VmValue, include_bytes: bool) -> TranscriptBudgetUsage {
51    let Some(dict) = transcript.as_dict() else {
52        return TranscriptBudgetUsage {
53            message_count: 0,
54            event_count: 0,
55            approx_bytes: include_bytes.then_some(0),
56        };
57    };
58    let approx_bytes = if include_bytes {
59        serde_json::to_vec(&crate::llm::helpers::vm_value_to_json(transcript))
60            .map(|bytes| bytes.len())
61            .ok()
62            .or(Some(usize::MAX))
63    } else {
64        None
65    };
66    TranscriptBudgetUsage {
67        message_count: transcript_messages_from_dict(dict).len(),
68        event_count: transcript_events_from_dict(dict).len(),
69        approx_bytes,
70    }
71}
72
73fn transcript_budget_exceeded_reason(
74    usage: &TranscriptBudgetUsage,
75    policy: &SessionTranscriptBudgetPolicy,
76) -> Option<&'static str> {
77    if usage.message_count > policy.max_messages {
78        return Some("message_count");
79    }
80    if usage.event_count > policy.max_events {
81        return Some("event_count");
82    }
83    if let (Some(bytes), Some(limit)) = (usage.approx_bytes, policy.max_approx_bytes) {
84        if bytes > limit {
85            return Some("approx_bytes");
86        }
87    }
88    None
89}
90
91pub(crate) fn transcript_budget_usage_json(usage: &TranscriptBudgetUsage) -> serde_json::Value {
92    serde_json::json!({
93        "messages": usage.message_count,
94        "events": usage.event_count,
95        "approx_bytes": usage.approx_bytes,
96    })
97}
98
99pub(crate) fn transcript_budget_policy_json(
100    policy: &SessionTranscriptBudgetPolicy,
101) -> serde_json::Value {
102    let recovery = match &policy.recovery {
103        TranscriptBudgetRecovery::Reject => serde_json::json!({"action": "reject"}),
104        TranscriptBudgetRecovery::Trim { keep_last } => {
105            serde_json::json!({"action": "trim", "keep_last": keep_last})
106        }
107        TranscriptBudgetRecovery::Compact { keep_last } => {
108            serde_json::json!({"action": "compact", "keep_last": keep_last})
109        }
110    };
111    serde_json::json!({
112        "max_messages": policy.max_messages,
113        "max_events": policy.max_events,
114        "max_approx_bytes": policy.max_approx_bytes,
115        "recovery": recovery,
116    })
117}
118
119fn transcript_budget_recovery_name(recovery: &TranscriptBudgetRecovery) -> &'static str {
120    match recovery {
121        TranscriptBudgetRecovery::Reject => "reject",
122        TranscriptBudgetRecovery::Trim { .. } => "trim",
123        TranscriptBudgetRecovery::Compact { .. } => "compact",
124    }
125}
126
127fn transcript_budget_error(
128    state: &SessionState,
129    policy: &SessionTranscriptBudgetPolicy,
130    usage: &TranscriptBudgetUsage,
131    reason: &str,
132) -> String {
133    let byte_suffix = match (usage.approx_bytes, policy.max_approx_bytes) {
134        (Some(bytes), Some(limit)) => format!(", approx_bytes {bytes}/{limit}"),
135        _ => String::new(),
136    };
137    format!(
138        "transcript budget exceeded for session '{}': {reason} (messages {}/{}, events {}/{}{}; recovery={})",
139        state.id,
140        usage.message_count,
141        policy.max_messages,
142        usage.event_count,
143        policy.max_events,
144        byte_suffix,
145        transcript_budget_recovery_name(&policy.recovery),
146    )
147}
148
149fn transcript_budget_audit_json(
150    action: &str,
151    source: &str,
152    reason: &str,
153    policy: &SessionTranscriptBudgetPolicy,
154    usage_before: &TranscriptBudgetUsage,
155    usage_attempted: &TranscriptBudgetUsage,
156    usage_after: &TranscriptBudgetUsage,
157) -> serde_json::Value {
158    serde_json::json!({
159        "action": action,
160        "source": source,
161        "reason": reason,
162        "policy": transcript_budget_policy_json(policy),
163        "usage_before": transcript_budget_usage_json(usage_before),
164        "usage_attempted": transcript_budget_usage_json(usage_attempted),
165        "usage_after": transcript_budget_usage_json(usage_after),
166        "removed_messages": usage_attempted.message_count.saturating_sub(usage_after.message_count),
167        "removed_events": usage_attempted.event_count.saturating_sub(usage_after.event_count),
168    })
169}
170
171fn transcript_budget_event(audit: &serde_json::Value) -> VmValue {
172    let action = audit
173        .get("action")
174        .and_then(serde_json::Value::as_str)
175        .unwrap_or("enforced");
176    crate::llm::helpers::transcript_event(
177        "transcript_budget",
178        "system",
179        "internal",
180        &format!("Transcript budget {action}."),
181        Some(audit.clone()),
182    )
183}
184
185fn append_event_to_transcript(transcript: VmValue, event: VmValue) -> VmValue {
186    let Some(dict) = transcript.as_dict() else {
187        return transcript;
188    };
189    let mut next = dict.clone();
190    let mut events = transcript_events_from_dict(&next);
191    events.push(event);
192    next.insert(
193        crate::value::intern_key("events"),
194        VmValue::List(std::sync::Arc::new(events)),
195    );
196    VmValue::dict(next)
197}
198
199fn tail_message_capacity(
200    policy: &SessionTranscriptBudgetPolicy,
201    reserve_audit_event: bool,
202) -> usize {
203    let event_capacity = tail_event_capacity(policy, usize::from(reserve_audit_event));
204    policy.max_messages.min(event_capacity)
205}
206
207fn tail_event_capacity(policy: &SessionTranscriptBudgetPolicy, reserved_events: usize) -> usize {
208    policy.max_events.saturating_sub(reserved_events)
209}
210
211fn trim_transcript_for_budget(
212    transcript: &VmValue,
213    policy: &SessionTranscriptBudgetPolicy,
214    keep_last: usize,
215) -> VmValue {
216    let dict = transcript
217        .as_dict()
218        .cloned()
219        .unwrap_or_else(crate::value::DictMap::new);
220    let messages = transcript_messages_from_dict(&dict);
221    let keep = keep_last.min(tail_message_capacity(policy, true));
222    let start = messages.len().saturating_sub(keep);
223    let retained: Vec<VmValue> = messages.into_iter().skip(start).collect();
224    let mut next = dict;
225    next.insert(
226        crate::value::intern_key("events"),
227        VmValue::List(std::sync::Arc::new(
228            crate::llm::helpers::transcript_events_from_messages(&retained),
229        )),
230    );
231    next.insert(
232        crate::value::intern_key("messages"),
233        VmValue::List(std::sync::Arc::new(retained)),
234    );
235    next.remove("summary");
236    VmValue::dict(next)
237}
238
239struct BudgetCompactionLiveEvent {
240    policy: crate::orchestration::CompactionPolicy,
241    policy_strategy: String,
242    metrics: crate::orchestration::TranscriptCompactedEventMetrics,
243}
244
245struct BudgetCompactionResult {
246    transcript: VmValue,
247    live_event: Option<BudgetCompactionLiveEvent>,
248}
249
250fn compact_transcript_for_budget(
251    transcript: &VmValue,
252    policy: &SessionTranscriptBudgetPolicy,
253    keep_last: usize,
254    session_id: &str,
255) -> BudgetCompactionResult {
256    let dict = transcript
257        .as_dict()
258        .cloned()
259        .unwrap_or_else(crate::value::DictMap::new);
260    let messages = transcript_messages_from_dict(&dict);
261    let message_capacity = policy.max_messages.min(tail_event_capacity(policy, 2));
262    // Auto-compaction may widen a suffix to start on a clean user-turn boundary,
263    // so reserve one extra slot beyond the summary when sizing for hard caps.
264    let tail_keep = keep_last.min(message_capacity.saturating_sub(2));
265    let mut config = crate::orchestration::AutoCompactConfig {
266        token_threshold: 0,
267        keep_last: tail_keep,
268        compact_strategy: crate::orchestration::CompactStrategy::Llm,
269        hard_limit_strategy: crate::orchestration::CompactStrategy::Truncate,
270        fallback_strategy: Some(crate::orchestration::CompactStrategy::Truncate),
271        policy_strategy: crate::orchestration::compact_strategy_name(
272            &crate::orchestration::CompactStrategy::Llm,
273        )
274        .to_string(),
275        ..Default::default()
276    };
277
278    let mut json_messages = messages
279        .iter()
280        .map(crate::llm::helpers::vm_value_to_json)
281        .collect::<Vec<_>>();
282    let lifecycle =
283        crate::orchestration::CompactLifecycle::new(crate::orchestration::CompactMode::Auto)
284            .with_session_id(Some(session_id))
285            .with_trigger(crate::orchestration::CompactionTrigger::BudgetPressure)
286            .with_hook_dispatch(false)
287            .with_evaluate_providers(false);
288    let llm_opts = crate::llm::extract_llm_options(&[
289        VmValue::String(arcstr::ArcStr::from("")),
290        VmValue::Nil,
291        VmValue::Nil,
292    ])
293    .ok();
294    let outcome = futures::executor::block_on(crate::orchestration::run_compaction_lifecycle(
295        &mut json_messages,
296        &mut config,
297        llm_opts.as_ref(),
298        lifecycle,
299    ))
300    .ok()
301    .flatten();
302
303    let retained = json_messages
304        .iter()
305        .map(crate::stdlib::json_to_vm_value)
306        .collect::<Vec<_>>();
307    let mut events = crate::llm::helpers::transcript_events_from_messages(&retained);
308    let summary = outcome.as_ref().map(|outcome| outcome.summary.clone());
309    let mut live_event = None;
310    if let Some(outcome) = outcome {
311        events.push(crate::llm::helpers::transcript_event(
312            "compaction",
313            "system",
314            "internal",
315            "",
316            Some(outcome.event_metadata.clone()),
317        ));
318        live_event = Some(BudgetCompactionLiveEvent {
319            policy: config.policy.clone(),
320            policy_strategy: outcome.policy_strategy,
321            metrics: crate::orchestration::TranscriptCompactedEventMetrics {
322                archived_messages: outcome.archived_messages,
323                estimated_tokens_before: outcome.estimated_tokens_before,
324                estimated_tokens_after: outcome.estimated_tokens_after,
325                snapshot_asset_id: outcome.snapshot_asset_id,
326            },
327        });
328    }
329
330    let mut next = dict;
331    next.insert(
332        crate::value::intern_key("events"),
333        VmValue::List(std::sync::Arc::new(events)),
334    );
335    next.insert(
336        crate::value::intern_key("messages"),
337        VmValue::List(std::sync::Arc::new(retained)),
338    );
339    if let Some(summary) = summary {
340        next.put_str("summary", summary);
341    } else {
342        next.remove("summary");
343    }
344    BudgetCompactionResult {
345        transcript: VmValue::dict(next),
346        live_event,
347    }
348}
349
350fn recovered_transcript_with_audit(
351    recovered: VmValue,
352    action: &str,
353    source: &str,
354    reason: &str,
355    policy: &SessionTranscriptBudgetPolicy,
356    usage_before: &TranscriptBudgetUsage,
357    usage_attempted: &TranscriptBudgetUsage,
358    include_bytes: bool,
359) -> (VmValue, serde_json::Value, TranscriptBudgetUsage) {
360    let usage_after_without_audit = transcript_usage(&recovered, include_bytes);
361    let initial_audit = transcript_budget_audit_json(
362        action,
363        source,
364        reason,
365        policy,
366        usage_before,
367        usage_attempted,
368        &usage_after_without_audit,
369    );
370    let with_initial_audit =
371        append_event_to_transcript(recovered.clone(), transcript_budget_event(&initial_audit));
372    let usage_after = transcript_usage(&with_initial_audit, include_bytes);
373    let audit = transcript_budget_audit_json(
374        action,
375        source,
376        reason,
377        policy,
378        usage_before,
379        usage_attempted,
380        &usage_after,
381    );
382    let with_audit = append_event_to_transcript(recovered, transcript_budget_event(&audit));
383    let usage_after = transcript_usage(&with_audit, include_bytes);
384    (with_audit, audit, usage_after)
385}
386
387pub(crate) fn apply_transcript_with_budget(
388    state: &mut SessionState,
389    candidate: VmValue,
390    source: &str,
391) -> Result<(), String> {
392    let policy = state.transcript_budget_policy.normalized();
393    let include_bytes = policy.max_approx_bytes.is_some();
394    let usage_before = transcript_usage(&state.transcript, include_bytes);
395    let usage_attempted = transcript_usage(&candidate, include_bytes);
396    let Some(reason) = transcript_budget_exceeded_reason(&usage_attempted, &policy) else {
397        state.replace_transcript(candidate);
398        return Ok(());
399    };
400
401    match policy.recovery.clone() {
402        TranscriptBudgetRecovery::Reject => {
403            let audit = transcript_budget_audit_json(
404                "rejected",
405                source,
406                reason,
407                &policy,
408                &usage_before,
409                &usage_attempted,
410                &usage_before,
411            );
412            state.last_transcript_budget_action = Some(audit);
413            Err(transcript_budget_error(
414                state,
415                &policy,
416                &usage_attempted,
417                reason,
418            ))
419        }
420        TranscriptBudgetRecovery::Trim { keep_last } => {
421            let recovered = trim_transcript_for_budget(&candidate, &policy, keep_last);
422            let (with_audit, audit, usage_after) = recovered_transcript_with_audit(
423                recovered,
424                "trimmed",
425                source,
426                reason,
427                &policy,
428                &usage_before,
429                &usage_attempted,
430                include_bytes,
431            );
432            if transcript_budget_exceeded_reason(&usage_after, &policy).is_some() {
433                let rejected = transcript_budget_audit_json(
434                    "rejected",
435                    source,
436                    reason,
437                    &policy,
438                    &usage_before,
439                    &usage_attempted,
440                    &usage_after,
441                );
442                state.last_transcript_budget_action = Some(rejected);
443                return Err(transcript_budget_error(
444                    state,
445                    &policy,
446                    &usage_after,
447                    reason,
448                ));
449            }
450            state.last_transcript_budget_action = Some(audit);
451            state.replace_transcript(with_audit);
452            Ok(())
453        }
454        TranscriptBudgetRecovery::Compact { keep_last } => {
455            let compacted =
456                compact_transcript_for_budget(&candidate, &policy, keep_last, &state.id);
457            let (with_audit, audit, usage_after) = recovered_transcript_with_audit(
458                compacted.transcript,
459                "compacted",
460                source,
461                reason,
462                &policy,
463                &usage_before,
464                &usage_attempted,
465                include_bytes,
466            );
467            if transcript_budget_exceeded_reason(&usage_after, &policy).is_some() {
468                let rejected = transcript_budget_audit_json(
469                    "rejected",
470                    source,
471                    reason,
472                    &policy,
473                    &usage_before,
474                    &usage_attempted,
475                    &usage_after,
476                );
477                state.last_transcript_budget_action = Some(rejected);
478                return Err(transcript_budget_error(
479                    state,
480                    &policy,
481                    &usage_after,
482                    reason,
483                ));
484            }
485            state.last_transcript_budget_action = Some(audit);
486            state.replace_transcript(with_audit);
487            if let Some(event) = compacted.live_event {
488                crate::orchestration::emit_transcript_compacted_event_sync(
489                    &state.id,
490                    crate::orchestration::CompactMode::Auto,
491                    crate::orchestration::CompactionTrigger::BudgetPressure
492                        .as_str()
493                        .to_string(),
494                    &event.policy,
495                    event.policy_strategy,
496                    event.metrics,
497                );
498            }
499            Ok(())
500        }
501    }
502}