1use 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 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 recap: outcome
327 .recap_metrics
328 .map(crate::orchestration::RecapMetrics::to_json),
329 },
330 });
331 }
332
333 let mut next = dict;
334 next.insert(
335 crate::value::intern_key("events"),
336 VmValue::List(std::sync::Arc::new(events)),
337 );
338 next.insert(
339 crate::value::intern_key("messages"),
340 VmValue::List(std::sync::Arc::new(retained)),
341 );
342 if let Some(summary) = summary {
343 next.put_str("summary", summary);
344 } else {
345 next.remove("summary");
346 }
347 BudgetCompactionResult {
348 transcript: VmValue::dict(next),
349 live_event,
350 }
351}
352
353fn recovered_transcript_with_audit(
354 recovered: VmValue,
355 action: &str,
356 source: &str,
357 reason: &str,
358 policy: &SessionTranscriptBudgetPolicy,
359 usage_before: &TranscriptBudgetUsage,
360 usage_attempted: &TranscriptBudgetUsage,
361 include_bytes: bool,
362) -> (VmValue, serde_json::Value, TranscriptBudgetUsage) {
363 let usage_after_without_audit = transcript_usage(&recovered, include_bytes);
364 let initial_audit = transcript_budget_audit_json(
365 action,
366 source,
367 reason,
368 policy,
369 usage_before,
370 usage_attempted,
371 &usage_after_without_audit,
372 );
373 let with_initial_audit =
374 append_event_to_transcript(recovered.clone(), transcript_budget_event(&initial_audit));
375 let usage_after = transcript_usage(&with_initial_audit, include_bytes);
376 let audit = transcript_budget_audit_json(
377 action,
378 source,
379 reason,
380 policy,
381 usage_before,
382 usage_attempted,
383 &usage_after,
384 );
385 let with_audit = append_event_to_transcript(recovered, transcript_budget_event(&audit));
386 let usage_after = transcript_usage(&with_audit, include_bytes);
387 (with_audit, audit, usage_after)
388}
389
390pub(crate) fn apply_transcript_with_budget(
391 state: &mut SessionState,
392 candidate: VmValue,
393 source: &str,
394) -> Result<(), String> {
395 let policy = state.transcript_budget_policy.normalized();
396 let include_bytes = policy.max_approx_bytes.is_some();
397 let usage_before = transcript_usage(&state.transcript, include_bytes);
398 let usage_attempted = transcript_usage(&candidate, include_bytes);
399 let Some(reason) = transcript_budget_exceeded_reason(&usage_attempted, &policy) else {
400 state.replace_transcript(candidate);
401 return Ok(());
402 };
403
404 match policy.recovery.clone() {
405 TranscriptBudgetRecovery::Reject => {
406 let audit = transcript_budget_audit_json(
407 "rejected",
408 source,
409 reason,
410 &policy,
411 &usage_before,
412 &usage_attempted,
413 &usage_before,
414 );
415 state.last_transcript_budget_action = Some(audit);
416 Err(transcript_budget_error(
417 state,
418 &policy,
419 &usage_attempted,
420 reason,
421 ))
422 }
423 TranscriptBudgetRecovery::Trim { keep_last } => {
424 let recovered = trim_transcript_for_budget(&candidate, &policy, keep_last);
425 let (with_audit, audit, usage_after) = recovered_transcript_with_audit(
426 recovered,
427 "trimmed",
428 source,
429 reason,
430 &policy,
431 &usage_before,
432 &usage_attempted,
433 include_bytes,
434 );
435 if transcript_budget_exceeded_reason(&usage_after, &policy).is_some() {
436 let rejected = transcript_budget_audit_json(
437 "rejected",
438 source,
439 reason,
440 &policy,
441 &usage_before,
442 &usage_attempted,
443 &usage_after,
444 );
445 state.last_transcript_budget_action = Some(rejected);
446 return Err(transcript_budget_error(
447 state,
448 &policy,
449 &usage_after,
450 reason,
451 ));
452 }
453 state.last_transcript_budget_action = Some(audit);
454 state.replace_transcript(with_audit);
455 Ok(())
456 }
457 TranscriptBudgetRecovery::Compact { keep_last } => {
458 let compacted =
459 compact_transcript_for_budget(&candidate, &policy, keep_last, &state.id);
460 let (with_audit, audit, usage_after) = recovered_transcript_with_audit(
461 compacted.transcript,
462 "compacted",
463 source,
464 reason,
465 &policy,
466 &usage_before,
467 &usage_attempted,
468 include_bytes,
469 );
470 if transcript_budget_exceeded_reason(&usage_after, &policy).is_some() {
471 let rejected = transcript_budget_audit_json(
472 "rejected",
473 source,
474 reason,
475 &policy,
476 &usage_before,
477 &usage_attempted,
478 &usage_after,
479 );
480 state.last_transcript_budget_action = Some(rejected);
481 return Err(transcript_budget_error(
482 state,
483 &policy,
484 &usage_after,
485 reason,
486 ));
487 }
488 state.last_transcript_budget_action = Some(audit);
489 state.replace_transcript(with_audit);
490 if let Some(event) = compacted.live_event {
491 crate::orchestration::emit_transcript_compacted_event_sync(
492 &state.id,
493 crate::orchestration::CompactMode::Auto,
494 crate::orchestration::CompactionTrigger::BudgetPressure
495 .as_str()
496 .to_string(),
497 &event.policy,
498 event.policy_strategy,
499 event.metrics,
500 );
501 }
502 Ok(())
503 }
504 }
505}