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 },
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}