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}