Skip to main content

agentmux_srv/server/app_api/bundle/
export_for_agent.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `bundle.export_for_agent` / `bundle.export_for_agent_with_history`: build an
5//! ABF from a live agent definition, optionally with its session history under
6//! the size budgets defined here.
7
8use super::*;
9
10/// `bundle.export_for_agent` — ABF v0.2 §2.3. The only export path that
11/// carries native memory: pulls a bundle's normal components (identical to
12/// `bundle.export`) plus a snapshot of ONE agent's
13/// `db_agent_native_memory` rows, refreshed from the live filesystem
14/// first (see [`native_memory_handlers::refresh_memory_mirror_from_live_fs`]'s
15/// doc comment for why that refresh is required, not optional). `bundle.
16/// export` stays agent-less and memory-less — bundles are reusable across
17/// many agents by design, so memory (inherently per-agent) needs its own,
18/// explicitly-scoped entry point rather than an implicit "whichever agent
19/// happens to be attached" guess.
20#[derive(serde::Deserialize, Default, ts_rs::TS)]
21#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "BundleExportForAgentReq")]
22pub(super) struct ExportForAgentReq {
23    pub(crate) bundle_id: String,
24    pub(crate) agent_id: String,
25    #[serde(default)]
26    #[ts(as = "Option<String>", optional)]
27    pub(crate) format: String,
28}
29
30/// `bundle.export_for_agent` — ABF v0.2 §2.3. The only export path that
31/// carries native memory: pulls a bundle's normal components (identical to
32/// `bundle.export`) plus a snapshot of ONE agent's
33/// `db_agent_native_memory` rows, refreshed from the live filesystem
34/// first (see [`native_memory_handlers::refresh_memory_mirror_from_live_fs`]'s
35/// doc comment for why that refresh is required, not optional). `bundle.
36/// export` stays agent-less and memory-less — bundles are reusable across
37/// many agents by design, so memory (inherently per-agent) needs its own,
38/// explicitly-scoped entry point rather than an implicit "whichever agent
39/// happens to be attached" guess. Extracted from the RPC closure into a
40/// directly-callable, directly-testable function, matching
41/// `bundle_import_preview_impl`'s pattern.
42/// Shared by `bundle_export_for_agent_impl` and
43/// `bundle_export_for_agent_with_history_impl`: resolve the bundle + agent,
44/// build the base `BundleExport`, splice in native memory. Returns the
45/// export, the resolved agent (the history variant needs its id), combined
46/// warnings, and missing skill ids. Everything after this point (whether to
47/// zip, whether to also append history files) is caller-specific.
48pub(super) async fn build_export_for_agent(
49    id_store: &crate::backend::storage::store::Store,
50    mstore: &crate::backend::storage::store::Store,
51    identity_store: &crate::backend::storage::store::Store,
52    bundle_id: &str,
53    agent_id: &str,
54    err_prefix: &str,
55) -> Result<(
56    crate::backend::bundle_export::BundleExport,
57    crate::backend::storage::store::AgentDefinition,
58    Vec<String>,
59    Vec<String>,
60), String> {
61    let bundle = id_store
62        .bundle_get(bundle_id)
63        .map_err(|e| format!("{err_prefix}: {e}"))?
64        .ok_or_else(|| format!("{err_prefix}: no bundle with id {bundle_id}"))?;
65    let agent = mstore
66        .agent_def_get(agent_id)
67        .map_err(|e| format!("{err_prefix}: {e}"))?
68        .ok_or_else(|| format!("{err_prefix}: no agent with id {agent_id}"))?;
69
70    // Same resolver as the agent-less path, so the two cannot disagree about
71    // what the bundle contains.
72    let components = resolve_bundle_components(mstore, identity_store, &bundle.id)
73        .map_err(|e| format!("{err_prefix}: {e}"))?;
74    let mut handler_warnings: Vec<String> = Vec::new();
75    // Always empty now — see the note at the agent-less export path.
76    let missing_skill_ids: Vec<String> = Vec::new();
77
78    // The hint names the agent this was exported from: the bundle itself
79    // carries no harness (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.2).
80    let mut bundle = bundle;
81    let provider = id_store.resolve_effective_provider_id(&agent);
82    if !provider.is_empty() {
83        bundle.model =
84            crate::backend::storage::store::Store::resolve_effective_vendor(&provider, &agent.model_vendor_base_url);
85        bundle.provider = provider;
86    }
87
88    let mut export = crate::backend::bundle_export::export_bundle(&bundle, &components.skills);
89
90    // Refresh the mirror from the live FS, then read every mirrored
91    // file's content — this agent's memory, freshest as of right now,
92    // not as of whenever its Stash Bundle tab was last opened.
93    let mut memory_files: Vec<(String, String)> = Vec::new();
94    if let Some(memory_dir) =
95        crate::server::native_memory_handlers::memory_dir_for_agent_by_id(mstore, &agent)
96    {
97        match crate::server::native_memory_handlers::refresh_memory_mirror_from_live_fs(
98            &agent.id,
99            &memory_dir,
100            id_store,
101        ) {
102            Ok(truncated) => {
103                // reagent P2, PR #2527: surface truncation instead of
104                // silently exporting/importing a partial file.
105                for filename in truncated {
106                    handler_warnings.push(format!(
107                        "{filename}: exceeds the native-memory size limit and was truncated; the exported copy is incomplete"
108                    ));
109                }
110            }
111            Err(e) => handler_warnings.push(format!("native memory refresh failed (exporting mirror as-is): {e}")),
112        }
113    }
114    match id_store.agent_native_memory_list_meta(&agent.id) {
115        Ok(rows) => {
116            for row in rows {
117                match id_store.agent_native_memory_read(&agent.id, &row.filename) {
118                    Ok(Some(content)) => memory_files.push((row.filename, content)),
119                    Ok(None) => {} // deleted between list_meta and read — skip, not an error
120                    Err(e) => handler_warnings.push(format!(
121                        "native memory: failed to read {}: {e}",
122                        row.filename
123                    )),
124                }
125            }
126        }
127        Err(e) => handler_warnings.push(format!("native memory: failed to list: {e}")),
128    }
129    splice_memory_component(&mut export, &memory_files).map_err(|e| format!("{err_prefix}: {e}"))?;
130
131    // Project instructions — carried as a record of what the source agent was
132    // reading, not as content to install. The working directory is resolved
133    // the same way launch resolves it, so the export describes the directory
134    // the agent actually runs in.
135    let work_dir = crate::backend::project_instructions::effective_working_dir(
136        &agent.working_directory,
137        &agent.name,
138    );
139    let instructions = crate::backend::project_instructions::resolve_project_instructions(
140        &agent.provider,
141        &work_dir,
142    );
143    splice_project_instructions_component(&mut export, &instructions)
144        .map_err(|e| format!("{err_prefix}: {e}"))?;
145
146    let mut all_warnings = export.warnings.clone();
147    all_warnings.append(&mut handler_warnings);
148
149    Ok((export, agent, all_warnings, missing_skill_ids))
150}
151
152pub(super) async fn bundle_export_for_agent_impl(
153    id_store: &crate::backend::storage::store::Store,
154    mstore: &crate::backend::storage::store::Store,
155    identity_store: &crate::backend::storage::store::Store,
156    req: ExportForAgentReq,
157) -> Result<BundleExportResponse, String> {
158    let (export, _agent, all_warnings, missing_skill_ids) =
159        build_export_for_agent(id_store, mstore, identity_store, &req.bundle_id, &req.agent_id, "bundle.export_for_agent").await?;
160
161    if req.format == "zip" {
162        let zip_bytes = crate::backend::bundle_export::zip_bundle_export(&export)
163            .map_err(|e| format!("bundle.export_for_agent: {e}"))?;
164        use base64::Engine as _;
165        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_bytes);
166        return Ok(BundleExportResponse {
167            root_slug: export.root_slug,
168            files: None,
169            skipped_skills: export.skipped_skills,
170            warnings: all_warnings,
171            missing_skill_ids,
172            zip_base64: Some(encoded),
173        });
174    }
175
176    Ok(BundleExportResponse {
177        root_slug: export.root_slug,
178        files: Some(export.files),
179        skipped_skills: export.skipped_skills,
180        warnings: all_warnings,
181        missing_skill_ids,
182        zip_base64: None,
183    })
184}
185
186#[derive(serde::Deserialize, ts_rs::TS)]
187#[ts(export, export_to = "../../../frontend/types/rpc/", rename = "BundleExportForAgentWithHistoryReq")]
188pub(super) struct ExportForAgentWithHistoryReq {
189    pub(crate) bundle_id: String,
190    pub(crate) agent_id: String,
191}
192
193/// Response for `bundle.export_for_agent_with_history`, which always zips.
194#[derive(Debug, Clone, serde::Serialize, ts_rs::TS)]
195#[ts(export, export_to = "../../../frontend/types/rpc/")]
196pub(crate) struct BundleExportWithHistoryResponse {
197    pub(crate) root_slug: String,
198    pub(crate) skipped_skills: Vec<String>,
199    pub(crate) warnings: Vec<String>,
200    pub(crate) missing_skill_ids: Vec<String>,
201    pub(crate) history_session_count: u32,
202    pub(crate) zip_base64: String,
203}
204
205/// Per-transcript size ceiling for `bundle.export_for_agent_with_history`.
206/// reagentx P1 on PR #2613: reading every session fully into a `String`
207/// with no bound could exhaust server memory for the multi-GB histories
208/// this feature exists to migrate. Unlike `MAX_ABF_FILE_SIZE_BYTES`
209/// (context files, truncated with a warning when oversized — fine, since
210/// a partial context file is still useful), a transcript is skipped
211/// ENTIRELY rather than truncated: JSONL requires whole lines, and a
212/// truncated mid-record file could fail to parse (or silently misparse)
213/// on the receiving end, which is worse than the session being visibly
214/// absent with a warning explaining why.
215pub(super) const MAX_HISTORY_SESSION_FILE_SIZE_BYTES: u64 = 200 * 1024 * 1024;
216
217/// Aggregate ceiling across every session included in one
218/// `bundle.export_for_agent_with_history` call. reagentx P1, third review
219/// round: a per-file cap alone doesn't bound an agent with many sessions
220/// each just under it — this is the analogous total cap the sibling
221/// import path already enforces (`MAX_TOTAL_UNCOMPRESSED_BYTES`,
222/// `bundle_import.rs`), sized larger since export's job is specifically
223/// to carry as much real history as reasonably fits, not to validate a
224/// small hand-authored bundle. Sessions are processed in
225/// `sessions_for_agent`'s existing `modified_at desc` order, so hitting
226/// this cap keeps the most recent conversations and cuts off the oldest
227/// — the same priority a human moving "my history" to a new machine would
228/// want. Unlike import's reject-the-whole-thing-on-overflow behavior,
229/// export keeps its existing best-effort philosophy: stop including
230/// further sessions and say so in a warning, never fail the call outright.
231pub(super) const MAX_TOTAL_HISTORY_BYTES: u64 = 500 * 1024 * 1024;
232
233/// Given per-session sizes already in priority order (most-recent-first,
234/// matching `sessions_for_agent`'s `modified_at desc` sort), decide how
235/// many fit within `total_cap`. Returns `(count_included, stopped_at_cap)`.
236/// Pure — no I/O — deliberately extracted so this decision is testable
237/// with tiny numbers instead of needing real `MAX_TOTAL_HISTORY_BYTES`-
238/// scale files on disk.
239pub(super) fn sessions_within_total_budget(sizes: &[u64], total_cap: u64) -> (usize, bool) {
240    let mut total = 0u64;
241    for (i, &size) in sizes.iter().enumerate() {
242        if total + size > total_cap {
243            return (i, true);
244        }
245        total += size;
246    }
247    (sizes.len(), false)
248}
249
250/// `bundle.export_for_agent_with_history` —
251/// `docs/specs/SPEC_AGENT_IDENTITY_HISTORY_PERSISTENCE_PROTOCOL_2026_08_16.md`
252/// §3.3. Deliberately a SEPARATE, explicit, opt-in action from
253/// `bundle.export_for_agent` — see `COMMAND_BUNDLE_EXPORT_FOR_AGENT_WITH_HISTORY`'s
254/// doc comment for why conversation transcripts must never ride along in
255/// the default (shareable, reusable-across-agents) bundle export. Builds
256/// the identical base export, then appends every session
257/// `HistoryService::sessions_for_agent` finds for this agent under
258/// `history/<provider>/<session_id>.jsonl`. Always returns a zip — a
259/// multi-file, potentially large archive has no sensible inline-JSON
260/// shape, unlike the plain export's optional format. A transcript that
261/// fails to read (deleted, permission issue, or over
262/// `MAX_HISTORY_SESSION_FILE_SIZE_BYTES`) is warned about and skipped,
263/// never fails the whole export — same best-effort philosophy as the
264/// memory-splicing step above.
265///
266/// reagentx P1 on PR #2613, all addressed:
267/// - `sessions_for_agent(..., force_refresh: true)` — this export claims
268///   completeness ("included N of N known sessions"), so it can't rely on
269///   the interactive lazy-refresh-if-empty behavior; a session created
270///   since the index was first populated (by ANY prior call) must not be
271///   silently missing.
272/// - `sessions_for_agent` itself runs inside its own `spawn_blocking` —
273///   `force_refresh: true` means it calls `SessionIndex::refresh()`,
274///   which synchronously walks and parses every session file for every
275///   provider on disk. A second review round caught that only the LATER
276///   file-read/zip/encode step (below) had been moved off the async
277///   worker thread; this earlier, more expensive full-index-rebuild step
278///   was still running inline on it. Takes `Arc<Store>`/
279///   `Arc<HistoryService>` (not bare refs) specifically so a clone can
280///   cross into this closure — `build_export_for_agent` above still
281///   receives them by reference (`Arc` derefs to `&Store` for free), no
282///   signature change needed there.
283/// - the actual file reads + zip + base64 encode run inside their own
284///   `spawn_blocking` — synchronous, potentially slow I/O and CPU work
285///   that must not block the async RPC worker thread either. Only the
286///   already-resolved, owned `export`/`sessions` data crosses into that
287///   closure.
288/// - `MAX_TOTAL_HISTORY_BYTES` — a third review round caught that the
289///   per-session cap alone doesn't bound an agent with MANY sessions each
290///   just under it; the sibling import path enforces an analogous total
291///   cap (`MAX_TOTAL_UNCOMPRESSED_BYTES`). See `sessions_within_total_budget`.
292pub(super) async fn bundle_export_for_agent_with_history_impl(
293    id_store: Arc<crate::backend::storage::store::Store>,
294    identity_store: Arc<crate::backend::storage::store::Store>,
295    mstore: &crate::backend::storage::store::Store,
296    history_service: Arc<crate::backend::history::HistoryService>,
297    req: ExportForAgentWithHistoryReq,
298) -> Result<BundleExportWithHistoryResponse, String> {
299    let (export, _agent, all_warnings, missing_skill_ids) = build_export_for_agent(
300        &id_store,
301        mstore,
302        &identity_store,
303        &req.bundle_id,
304        &req.agent_id,
305        "bundle.export_for_agent_with_history",
306    )
307    .await?;
308
309    let agent_id = req.agent_id.clone();
310    let (sessions, session_count, _has_more) = {
311        // identity_store, not id_store — agent-identity links now live in
312        // the permanently-global identity store
313        // (SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md); reagentx P1 review on
314        // PR #2632. build_export_for_agent above still reads the BUNDLE via
315        // id_store — bundle routing isn't part of this fix's scope.
316        let identity_store = identity_store.clone();
317        let history_service = history_service.clone();
318        tokio::task::spawn_blocking(move || {
319            history_service.sessions_for_agent(&identity_store, &agent_id, 0, usize::MAX, "modified_at", "desc", true)
320        })
321        .await
322        .map_err(|e| format!("bundle.export_for_agent_with_history: blocking task panicked: {e}"))?
323        .map_err(|e| format!("bundle.export_for_agent_with_history: {e}"))?
324    };
325
326    tokio::task::spawn_blocking(move || {
327        let mut export = export;
328        let mut all_warnings = all_warnings;
329
330        // Pass 1: stat every session, drop per-file-oversized ones. No
331        // reads yet — just sizes, so the pure budget decision below (pass
332        // 2) can be tested with tiny numbers instead of real files.
333        let mut sized: Vec<(&crate::backend::history::adapter::SessionMeta, u64)> = Vec::new();
334        for session in &sessions {
335            match std::fs::metadata(&session.file_path) {
336                Ok(meta) if meta.len() > MAX_HISTORY_SESSION_FILE_SIZE_BYTES => {
337                    all_warnings.push(format!(
338                        "history: skipped session {} ({}) — {} bytes exceeds the {} byte per-session limit",
339                        session.session_id, session.file_path, meta.len(), MAX_HISTORY_SESSION_FILE_SIZE_BYTES
340                    ));
341                }
342                Ok(meta) => sized.push((session, meta.len())),
343                Err(e) => all_warnings.push(format!(
344                    "history: failed to read session {} ({}): {e}",
345                    session.session_id, session.file_path
346                )),
347            }
348        }
349
350        // Pass 2: how many of the (already most-recent-first-ordered)
351        // remaining sessions fit within the aggregate cap.
352        let sizes: Vec<u64> = sized.iter().map(|(_, size)| *size).collect();
353        let sized_count = sized.len();
354        let (fit_count, stopped_at_total_cap) = sessions_within_total_budget(&sizes, MAX_TOTAL_HISTORY_BYTES);
355        if stopped_at_total_cap {
356            let total: u64 = sizes[..fit_count].iter().sum();
357            all_warnings.push(format!(
358                "history: stopped after {total} bytes (limit {MAX_TOTAL_HISTORY_BYTES}) — {} additional session(s) omitted, oldest first",
359                sized_count - fit_count
360            ));
361        }
362
363        // Pass 3: actually read + include only what fit.
364        let mut included = 0u32;
365        let mut history_paths: Vec<String> = Vec::new();
366        for (session, _size) in sized.into_iter().take(fit_count) {
367            match std::fs::read_to_string(&session.file_path) {
368                Ok(content) => {
369                    let out_path =
370                        format!("history/{}/{}.jsonl", session.provider, session.session_id);
371                    export.files.push(crate::backend::bundle_export::BundleExportFile {
372                        path: out_path.clone(),
373                        content,
374                    });
375                    history_paths.push(out_path);
376                    included += 1;
377                }
378                Err(e) => all_warnings.push(format!(
379                    "history: failed to read session {} ({}): {e}",
380                    session.session_id, session.file_path
381                )),
382            }
383        }
384        all_warnings.push(format!("history: included {included} of {session_count} known session(s)"));
385
386        // §3.5: the files above are in the archive; say so in the manifest too.
387        // Must happen before zipping — the manifest (bundle.json) is zipped from export.files.
388        splice_history_component(&mut export, history_paths)
389            .map_err(|e| format!("bundle.export_for_agent_with_history: {e}"))?;
390
391        let zip_bytes = crate::backend::bundle_export::zip_bundle_export(&export)
392            .map_err(|e| format!("bundle.export_for_agent_with_history: {e}"))?;
393        use base64::Engine as _;
394        let encoded = base64::engine::general_purpose::STANDARD.encode(&zip_bytes);
395        Ok(BundleExportWithHistoryResponse {
396            root_slug: export.root_slug,
397            skipped_skills: export.skipped_skills,
398            warnings: all_warnings,
399            missing_skill_ids,
400            history_session_count: included,
401            zip_base64: encoded,
402        })
403    })
404    .await
405    .map_err(|e| format!("bundle.export_for_agent_with_history: blocking task panicked: {e}"))?
406}
407
408pub(super) fn register_bundle_export_for_agent(engine: &Arc<WshRpcEngine>, state: &AppState) {
409    let id_store = state.id_store.clone();
410    let mstore = state.mstore.clone();
411    let identity_store = state.identity_store.clone();
412    engine.register_typed(
413        COMMAND_BUNDLE_EXPORT_FOR_AGENT,
414        move |req: ExportForAgentReq, _ctx| {
415            let id_store = id_store.clone();
416            let mstore = mstore.clone();
417            let identity_store = identity_store.clone();
418            async move { bundle_export_for_agent_impl(&id_store, &mstore, &identity_store, req).await }
419        },
420    );
421}
422
423pub(super) fn register_bundle_export_for_agent_with_history(engine: &Arc<WshRpcEngine>, state: &AppState) {
424    let id_store = state.id_store.clone();
425    let identity_store = state.identity_store.clone();
426    let mstore = state.mstore.clone();
427    let history_service = state.history_service.clone();
428    engine.register_typed(
429        COMMAND_BUNDLE_EXPORT_FOR_AGENT_WITH_HISTORY,
430        move |req: ExportForAgentWithHistoryReq, _ctx| {
431            let id_store = id_store.clone();
432            let identity_store = identity_store.clone();
433            let mstore = mstore.clone();
434            let history_service = history_service.clone();
435            async move {
436                bundle_export_for_agent_with_history_impl(id_store, identity_store, &mstore, history_service, req).await
437            }
438        },
439    );
440}