Skip to main content

mj_transcript/
authorization.rs

1//! Whole-message authorization evidence shared by workers and controllers.
2use anyhow::{Result, ensure};
3use mj_core::continuation::{ASSISTANT_BYTES, ContinuationEvidence, EvidenceMessage};
4use mj_core::state::MaterializedSession;
5use mj_core::transcript::{TranscriptBody, materialized_chunks_text, materialized_content_text};
6
7pub fn evidence(session: &MaterializedSession) -> Result<ContinuationEvidence> {
8    evidence_from_items(session.transcript.iter().rev().cloned().map(Ok))
9}
10
11pub fn evidence_from_items(
12    items: impl IntoIterator<Item = Result<std::sync::Arc<mj_core::state::TranscriptItem>>>,
13) -> Result<ContinuationEvidence> {
14    let mut messages = Vec::new();
15    let mut user_bytes = 0;
16    let mut assistant_bytes = 0;
17    let mut assistant_history_omitted = false;
18    // Whole recent assistant messages, all real user messages. Reverse then
19    // restore chronological order; never turn a clipped instruction into consent.
20    for item in items {
21        let item = item?;
22        if mj_core::archive::is_context_boundary(&item.stable_id) {
23            break;
24        }
25        let (role, text) = match &item.body {
26            TranscriptBody::User { content } => {
27                let id = item
28                    .stable_id
29                    .strip_prefix("user:")
30                    .unwrap_or(&item.stable_id);
31                if mj_core::continuation::is_generated_prompt(id) {
32                    continue;
33                }
34                ensure!(
35                    content.iter().all(|v| v["type"] == "text"),
36                    "authorization depends on non-text context"
37                );
38                let text = materialized_content_text(content);
39                if mj_core::continuation::is_generated_prompt_text(&text) {
40                    continue;
41                }
42                if matches!(
43                    mj_core::acp::context_command_text(&text),
44                    Some((mj_core::acp::ContextCommand::Clear, _))
45                ) {
46                    break;
47                }
48                user_bytes += text.len();
49                ensure!(
50                    user_bytes <= mj_core::continuation::USER_BYTES,
51                    "user history exceeds evidence budget"
52                );
53                ("user", text)
54            }
55            TranscriptBody::Agent { chunks, streaming } => {
56                ensure!(!streaming, "assistant reply is still streaming");
57                let text = materialized_chunks_text(chunks);
58                if text.trim().is_empty() {
59                    continue;
60                }
61                if assistant_history_omitted || assistant_bytes + text.len() > ASSISTANT_BYTES {
62                    ensure!(
63                        assistant_bytes > 0,
64                        "final assistant reply exceeds evidence budget"
65                    );
66                    assistant_history_omitted = true;
67                    continue;
68                }
69                assistant_bytes += text.len();
70                ("assistant", text)
71            }
72            _ => continue,
73        };
74        if !text.trim().is_empty() {
75            ensure!(messages.len() < 256, "too many continuation messages");
76            messages.push(EvidenceMessage {
77                id: item.stable_id.clone(),
78                role: role.into(),
79                text,
80            });
81        }
82    }
83    messages.reverse();
84    let evidence = ContinuationEvidence {
85        quota_message: None,
86        messages,
87        assistant_history_omitted,
88    };
89    evidence.validate()?;
90    Ok(evidence)
91}