1use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::agent_definition::AgentDefinition;
17use crate::capability_types::AgentCapabilityConfig;
18use crate::config_layer::AgentConfigOverlay;
19use crate::error::{AgentLoopError, Result};
20use crate::harness_definition::HarnessDefinition;
21use crate::mcp_server::{
22 McpProtocolMode, McpServerAuthMode, McpServerTransportType, ScopedMcpServer,
23};
24use crate::network_access::NetworkAccessList;
25use crate::session::ExecutionSession;
26use crate::session_file::InitialFile;
27use crate::tool_types::ToolDefinition;
28use crate::typed_id::{AgentId, HarnessId, ModelId, SessionId, WorkspaceId};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct SnapshotMcpServer {
39 pub transport_type: McpServerTransportType,
40 pub header_names: Vec<String>,
42 pub env_names: Vec<String>,
44 pub auth_mode: McpServerAuthMode,
45 pub protocol_mode: McpProtocolMode,
46 pub oauth_provider_id: Option<String>,
47 pub tool_discovery: bool,
48}
49
50impl From<&ScopedMcpServer> for SnapshotMcpServer {
51 fn from(server: &ScopedMcpServer) -> Self {
52 let mut header_names: Vec<String> = server.headers.keys().cloned().collect();
53 header_names.sort();
54 let mut env_names: Vec<String> = server.env.keys().cloned().collect();
55 env_names.sort();
56 Self {
57 transport_type: server.transport_type.clone(),
58 header_names,
59 env_names,
60 auth_mode: server.auth_mode.clone(),
61 protocol_mode: server.protocol_mode,
62 oauth_provider_id: server.oauth_provider_id.clone(),
63 tool_discovery: server.tool_discovery,
64 }
65 }
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ResolvedExecutionSnapshot {
79 pub session_id: SessionId,
81 pub workspace_id: WorkspaceId,
82 pub harness_id: HarnessId,
83 pub agent_id: Option<AgentId>,
84 pub organization_id: String,
87
88 pub instructions: Option<String>,
91 pub default_model_id: Option<ModelId>,
93 pub capabilities: Vec<AgentCapabilityConfig>,
95 pub tools: Vec<ToolDefinition>,
97 pub initial_files: Vec<InitialFile>,
99 pub mcp_servers: BTreeMap<String, SnapshotMcpServer>,
101 pub network_access: Option<NetworkAccessList>,
103 pub max_iterations: Option<usize>,
105 pub parallel_tool_calls: Option<bool>,
107
108 pub locale: Option<String>,
110 pub tags: Vec<String>,
111 pub blueprint_id: Option<String>,
112 pub blueprint_config: Option<serde_json::Value>,
113 pub cumulative_usage: Option<crate::events::TokenUsage>,
115 pub embedder_metadata: BTreeMap<String, String>,
117}
118
119impl ResolvedExecutionSnapshot {
120 pub fn project(
135 harness: &HarnessDefinition,
136 agent: Option<&AgentDefinition>,
137 session: &ExecutionSession,
138 ) -> Result<Self> {
139 let agent = match (session.agent_id, agent) {
140 (Some(agent_id), Some(agent)) => {
141 if agent.id != agent_id {
142 return Err(AgentLoopError::config(format!(
143 "session {} references agent {} but agent {} was provided",
144 session.id, agent_id, agent.id
145 )));
146 }
147 Some(agent)
148 }
149 (Some(agent_id), None) => {
150 return Err(AgentLoopError::agent_not_found(agent_id));
151 }
152 (None, _) => None,
153 };
154
155 let agent_layers = agent.into_iter().map(AgentConfigOverlay::from);
156 let effective = AgentConfigOverlay::fold(
157 [AgentConfigOverlay::from(harness)]
158 .into_iter()
159 .chain(agent_layers)
160 .chain([AgentConfigOverlay::from(session)]),
161 );
162
163 let embedder_metadata = harness
166 .embedder_metadata
167 .iter()
168 .map(|(key, value)| (key.clone(), value.clone()))
169 .collect();
170
171 Ok(Self {
172 session_id: session.id,
173 workspace_id: session.workspace_id,
174 harness_id: session.harness_id,
175 agent_id: session.agent_id,
176 organization_id: session.organization_id.clone(),
177 instructions: effective.system_prompt,
178 default_model_id: effective.default_model_id,
179 capabilities: effective.capabilities,
180 tools: effective.tools,
181 initial_files: effective.initial_files,
182 mcp_servers: effective
183 .mcp_servers
184 .iter()
185 .map(|(name, server)| (name.clone(), SnapshotMcpServer::from(server)))
186 .collect(),
187 network_access: effective.network_access,
188 max_iterations: effective.max_iterations,
189 parallel_tool_calls: effective.parallel_tool_calls,
190 locale: session.locale.clone(),
191 tags: session.tags.clone(),
192 blueprint_id: session.blueprint_id.clone(),
193 blueprint_config: session.blueprint_config.clone(),
194 cumulative_usage: session.usage.clone(),
195 embedder_metadata,
196 })
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::network_access::NetworkAccessList;
204 use std::collections::HashMap;
205
206 fn harness() -> HarnessDefinition {
207 HarnessDefinition {
208 capabilities: vec![AgentCapabilityConfig::new("session_file_system")],
209 ..HarnessDefinition::new("base", "Harness prompt.")
210 }
211 }
212
213 fn agent(agent_id: AgentId, _harness_id: HarnessId) -> AgentDefinition {
214 AgentDefinition {
215 max_iterations: Some(8),
216 display_name: Some("Agent".into()),
217 ..AgentDefinition::new(agent_id, "agent", "Agent prompt.")
218 }
219 }
220
221 fn session(
222 session_id: SessionId,
223 harness_id: HarnessId,
224 agent_id: Option<AgentId>,
225 ) -> ExecutionSession {
226 ExecutionSession {
227 agent_id,
228 title: Some("UI-TITLE-MARKER".into()),
229 locale: Some("en-US".into()),
230 tags: vec!["tag-a".into()],
231 ..ExecutionSession::with_own_workspace(session_id, harness_id)
232 }
233 }
234
235 fn ids() -> (HarnessId, AgentId, SessionId) {
236 (
237 HarnessId::from_seed(11),
238 AgentId::from_seed(11),
239 SessionId::from_seed(11),
240 )
241 }
242
243 fn file(path: &str, content: &str) -> InitialFile {
244 InitialFile {
245 path: path.into(),
246 content: content.into(),
247 encoding: "text".into(),
248 is_readonly: false,
249 }
250 }
251
252 #[test]
255 fn precedence_instructions_concatenate_root_to_leaf() {
256 let (harness_id, agent_id, session_id) = ids();
257 let harness = harness();
258 let agent = agent(agent_id, harness_id);
259 let mut session = session(session_id, harness_id, Some(agent_id));
260 session.system_prompt = Some("Session prompt.".into());
261
262 let snapshot =
263 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
264 assert_eq!(
265 snapshot.instructions.as_deref(),
266 Some("Harness prompt.\n\nAgent prompt.\n\nSession prompt.")
267 );
268 }
269
270 #[test]
271 fn precedence_model_leaf_layer_wins() {
272 let (harness_id, agent_id, session_id) = ids();
273 let mut harness = harness();
274 harness.default_model_id = Some(ModelId::from_seed(1));
275 let mut agent = agent(agent_id, harness_id);
276 agent.default_model_id = Some(ModelId::from_seed(2));
277 let mut session = session(session_id, harness_id, Some(agent_id));
278
279 let snapshot =
280 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
281 assert_eq!(snapshot.default_model_id, Some(ModelId::from_seed(2)));
282
283 session.model_id = Some(ModelId::from_seed(3));
284 let snapshot =
285 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
286 assert_eq!(snapshot.default_model_id, Some(ModelId::from_seed(3)));
287 session.model_id = None;
288 session.agent_id = None;
289 let snapshot = ResolvedExecutionSnapshot::project(&harness, None, &session).unwrap();
290 assert_eq!(snapshot.default_model_id, Some(ModelId::from_seed(1)));
291 harness.default_model_id = None;
292 assert_eq!(
293 ResolvedExecutionSnapshot::project(&harness, None, &session)
294 .unwrap()
295 .default_model_id,
296 None
297 );
298 }
299
300 #[test]
301 fn precedence_capabilities_override_by_id() {
302 let (harness_id, agent_id, session_id) = ids();
303 let mut harness = harness();
304 harness.capabilities = vec![AgentCapabilityConfig::with_config(
305 "web_fetch",
306 serde_json::json!({"enable_file_download": true}),
307 )];
308 let mut agent = agent(agent_id, harness_id);
309 agent.capabilities = vec![AgentCapabilityConfig::with_config(
310 "current_time",
311 serde_json::json!({"zone":"UTC"}),
312 )];
313 let mut session = session(session_id, harness_id, Some(agent_id));
314 session.capabilities = vec![AgentCapabilityConfig::with_config(
315 "web_fetch",
316 serde_json::json!({"enable_file_download": false}),
317 )];
318
319 let snapshot =
320 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
321 assert_eq!(snapshot.capabilities.len(), 2);
322 assert_eq!(
323 snapshot.capabilities[0],
324 AgentCapabilityConfig::with_config(
325 "web_fetch",
326 serde_json::json!({"enable_file_download": false})
327 )
328 );
329 assert_eq!(
330 snapshot.capabilities[1],
331 AgentCapabilityConfig::with_config("current_time", serde_json::json!({"zone":"UTC"}))
332 );
333 }
334
335 #[test]
336 fn capability_ref_round_trips_framework_persistence_and_worker_resolution() {
337 let framework_ref = everruns_capability::CapabilityRef::new("web_fetch")
342 .config(serde_json::json!({"enable_file_download": true}));
343 let persisted = serde_json::to_value(&framework_ref).unwrap();
344 assert_eq!(
345 persisted,
346 serde_json::json!({
347 "ref": "web_fetch",
348 "config": {"enable_file_download": true}
349 }),
350 "wire shape is the persisted attachment row shape"
351 );
352
353 let attachment: AgentCapabilityConfig = serde_json::from_value(persisted).unwrap();
354 assert_eq!(attachment, framework_ref);
355
356 let (harness_id, agent_id, session_id) = ids();
357 let mut harness = harness();
358 harness.capabilities = vec![attachment.clone()];
359 let agent = agent(agent_id, harness_id);
360 let session = session(session_id, harness_id, Some(agent_id));
361 let snapshot =
362 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
363 assert_eq!(snapshot.capabilities, vec![framework_ref.clone()]);
364 assert_eq!(
365 serde_json::to_value(&snapshot.capabilities[0]).unwrap(),
366 serde_json::to_value(&framework_ref).unwrap()
367 );
368 }
369
370 #[test]
371 fn snapshot_debug_redacts_capability_config_values() {
372 let attachment = AgentCapabilityConfig::with_config(
376 "vendor.search",
377 serde_json::json!({"api_key": "sk-super-secret"}),
378 );
379 let (harness_id, _, session_id) = ids();
380 let mut harness = harness();
381 harness.capabilities = vec![attachment];
382 let snapshot = ResolvedExecutionSnapshot::project(
383 &harness,
384 None,
385 &session(session_id, harness_id, None),
386 )
387 .unwrap();
388 let debug = format!("{snapshot:?}");
389 assert!(debug.contains("vendor.search"));
390 assert!(!debug.contains("sk-super-secret"));
391 assert!(!debug.contains("api_key"));
392 }
393
394 #[test]
395 fn precedence_initial_files_override_by_path() {
396 let (harness_id, agent_id, session_id) = ids();
397 let mut harness = harness();
398 harness.initial_files = vec![file("/config.txt", "harness"), file("/keep.txt", "keep")];
399 let mut agent = agent(agent_id, harness_id);
400 agent.initial_files = vec![file("config.txt", "agent")];
401 agent.initial_files[0].is_readonly = true;
402 agent.initial_files[0].encoding = "base64".into();
403 agent.initial_files[0].content = "YWdlbnQ=".into();
404 let session = session(session_id, harness_id, Some(agent_id));
405
406 let snapshot =
407 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
408 assert_eq!(
409 snapshot.initial_files,
410 vec![
411 agent.initial_files[0].clone(),
412 harness.initial_files[1].clone()
413 ]
414 );
415 }
416
417 #[test]
418 fn precedence_mcp_servers_override_by_name() {
419 let (harness_id, agent_id, session_id) = ids();
420 let mut harness = harness();
421 harness.mcp_servers.insert(
422 "docs".into(),
423 ScopedMcpServer {
424 url: "https://harness.example.com/mcp".into(),
425 ..Default::default()
426 },
427 );
428 harness
429 .mcp_servers
430 .insert("retained".into(), ScopedMcpServer::default());
431 let agent = agent(agent_id, harness_id);
432 let mut session = session(session_id, harness_id, Some(agent_id));
433 session.mcp_servers.insert(
434 "docs".into(),
435 ScopedMcpServer {
436 url: "https://session.example.com/mcp".into(),
437 transport_type: McpServerTransportType::Stdio,
438 headers: [
439 ("Z-Header".into(), "z-value".into()),
440 ("A-Header".into(), "a-value".into()),
441 ]
442 .into(),
443 env: [("Z_ENV".into(), "z".into()), ("A_ENV".into(), "a".into())].into(),
444 auth_mode: McpServerAuthMode::OAuth,
445 protocol_mode: McpProtocolMode::V2025June,
446 oauth_provider_id: Some("session-provider".into()),
447 tool_discovery: false,
448 ..Default::default()
449 },
450 );
451
452 let snapshot =
453 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
454 assert_eq!(
455 snapshot.mcp_servers,
456 BTreeMap::from([
457 (
458 "docs".into(),
459 SnapshotMcpServer {
460 transport_type: McpServerTransportType::Stdio,
461 header_names: vec!["A-Header".into(), "Z-Header".into()],
462 env_names: vec!["A_ENV".into(), "Z_ENV".into()],
463 auth_mode: McpServerAuthMode::OAuth,
464 protocol_mode: McpProtocolMode::V2025June,
465 oauth_provider_id: Some("session-provider".into()),
466 tool_discovery: false,
467 }
468 ),
469 (
470 "retained".into(),
471 SnapshotMcpServer {
472 transport_type: McpServerTransportType::Http,
473 header_names: vec![],
474 env_names: vec![],
475 auth_mode: McpServerAuthMode::None,
476 protocol_mode: McpProtocolMode::Auto,
477 oauth_provider_id: None,
478 tool_discovery: true,
479 }
480 ),
481 ])
482 );
483 }
484
485 #[test]
486 fn precedence_network_access_narrows() {
487 let (harness_id, agent_id, session_id) = ids();
488 let mut harness = harness();
489 harness.network_access = Some(NetworkAccessList::allow_only([
490 "*.example.com",
491 "api.github.com",
492 ]));
493 harness
494 .network_access
495 .as_mut()
496 .unwrap()
497 .blocked
498 .push("private.example.com".into());
499 let agent = agent(agent_id, harness_id);
500 let mut session = session(session_id, harness_id, Some(agent_id));
501 session.network_access = Some(NetworkAccessList {
502 allowed: vec!["api.example.com".into(), "outside.net".into()],
503 blocked: vec!["blocked.example.com".into()],
504 });
505
506 let snapshot =
507 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
508 let acl = snapshot.network_access.unwrap();
509 assert_eq!(
510 acl,
511 NetworkAccessList {
512 allowed: vec!["api.example.com".into()],
513 blocked: vec!["private.example.com".into(), "blocked.example.com".into()]
514 }
515 );
516 assert!(acl.is_url_allowed("https://api.example.com"));
517 assert!(!acl.is_url_allowed("https://outside.net"));
518 }
519
520 #[test]
521 fn precedence_iteration_controls_leaf_wins() {
522 let (harness_id, agent_id, session_id) = ids();
523 let harness = harness();
524 let mut agent = agent(agent_id, harness_id);
525 agent.max_iterations = Some(40);
526 agent.parallel_tool_calls = Some(true);
527 let mut session = session(session_id, harness_id, Some(agent_id));
528 session.max_iterations = Some(0);
529 session.parallel_tool_calls = Some(false);
530
531 let snapshot =
532 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
533 assert_eq!(snapshot.max_iterations, Some(0));
534 assert_eq!(snapshot.parallel_tool_calls, Some(false));
535 }
536
537 #[test]
538 fn correlation_values_are_copied() {
539 let (harness_id, agent_id, session_id) = ids();
540 let mut harness = harness();
541 harness.embedder_metadata = [("embedder".into(), "fixture-host".into())].into();
542 let agent = agent(agent_id, harness_id);
543 let mut session = session(session_id, harness_id, Some(agent_id));
544 session.workspace_id = WorkspaceId::from_seed(71);
545 session.organization_id = "org_00000000000000000000000000000042".into();
546 session.blueprint_id = Some("review-blueprint".into());
547 session.blueprint_config = Some(serde_json::json!({"region":"eu","enabled":false}));
548 session.usage = Some(crate::events::TokenUsage {
549 input_tokens: 13,
550 output_tokens: 29,
551 cache_read_tokens: Some(7),
552 cache_creation_tokens: Some(3),
553 actual_cost_usd: Some(0.25),
554 estimated_cost_usd: Some(0.5),
555 effective_cost_usd: Some(0.75),
556 });
557
558 let snapshot =
559 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
560 assert_eq!(snapshot.session_id, session_id);
561 assert_eq!(snapshot.workspace_id, session.workspace_id);
562 assert_eq!(snapshot.harness_id, harness_id);
563 assert_eq!(snapshot.agent_id, Some(agent_id));
564 assert_eq!(snapshot.organization_id, session.organization_id);
565 assert_eq!(snapshot.locale.as_deref(), Some("en-US"));
566 assert_eq!(snapshot.tags, vec!["tag-a".to_string()]);
567 assert_eq!(snapshot.blueprint_id, session.blueprint_id);
568 assert_eq!(snapshot.blueprint_config, session.blueprint_config);
569 assert_eq!(
570 serde_json::to_value(snapshot.cumulative_usage).unwrap(),
571 serde_json::to_value(session.usage).unwrap()
572 );
573 assert_eq!(
574 snapshot.embedder_metadata,
575 BTreeMap::from([("embedder".into(), "fixture-host".into())])
576 );
577 }
578
579 #[test]
587 fn projection_fails_on_missing_agent() {
588 let (harness_id, agent_id, session_id) = ids();
589 let harness = harness();
590 let session = session(session_id, harness_id, Some(agent_id));
591 assert!(
592 matches!(ResolvedExecutionSnapshot::project(&harness, None, &session), Err(AgentLoopError::AgentNotFound(id)) if id == agent_id)
593 );
594 }
595
596 #[test]
597 fn projection_fails_on_mismatched_agent() {
598 let (harness_id, agent_id, session_id) = ids();
599 let harness = harness();
600 let other_agent = agent(AgentId::from_seed(99), harness_id);
601 let session = session(session_id, harness_id, Some(agent_id));
602 match ResolvedExecutionSnapshot::project(&harness, Some(&other_agent), &session)
603 .unwrap_err()
604 {
605 AgentLoopError::Configuration(message) => assert_eq!(
606 message,
607 format!(
608 "session {session_id} references agent {agent_id} but agent {} was provided",
609 other_agent.id
610 )
611 ),
612 other => panic!("wrong projection error: {other:?}"),
613 }
614 }
615
616 #[test]
617 fn snapshot_excludes_platform_metadata_and_credential_values() {
618 let (harness_id, agent_id, session_id) = ids();
619 let harness = harness();
620 let mut agent = agent(agent_id, harness_id);
621 agent.description = Some("UI-PREVIEW-MARKER".into());
622 let mut session = session(session_id, harness_id, Some(agent_id));
623 session.mcp_servers.insert(
624 "docs".into(),
625 ScopedMcpServer {
626 url: "https://user:SECRET-URL-MARKER@mcp.example.com".into(),
627 headers: [(
628 "Authorization".to_string(),
629 "Bearer SECRET-HEADER-MARKER".to_string(),
630 )]
631 .into_iter()
632 .collect(),
633 env: [("API_KEY".to_string(), "SECRET-ENV-MARKER".to_string())]
634 .into_iter()
635 .collect(),
636 command: Some("SECRET-COMMAND-MARKER".into()),
637 args: vec!["--api-key=SECRET-ARG-MARKER".into()],
638 ..Default::default()
639 },
640 );
641
642 let snapshot =
643 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
644
645 let serialized = serde_json::to_string(&snapshot).unwrap();
646 let debugged = format!("{snapshot:?}");
647 for surface in [serialized.as_str(), debugged.as_str()] {
648 assert!(!surface.contains("SECRET-HEADER-MARKER"), "{surface}");
650 assert!(!surface.contains("SECRET-ENV-MARKER"), "{surface}");
651 assert!(!surface.contains("SECRET-URL-MARKER"), "{surface}");
652 assert!(!surface.contains("SECRET-COMMAND-MARKER"), "{surface}");
653 assert!(!surface.contains("SECRET-ARG-MARKER"), "{surface}");
654 assert!(!surface.contains("UI-TITLE-MARKER"), "{surface}");
656 assert!(!surface.contains("UI-PREVIEW-MARKER"), "{surface}");
657 }
658
659 let docs = snapshot.mcp_servers.get("docs").unwrap();
661 assert_eq!(docs.header_names, vec!["Authorization".to_string()]);
662 assert_eq!(docs.env_names, vec!["API_KEY".to_string()]);
663 }
664
665 #[test]
666 fn snapshot_serialization_is_deterministic_and_round_trips() {
667 let (harness_id, agent_id, session_id) = ids();
668 let mut harness = harness();
669 harness.embedder_metadata = HashMap::from([
670 ("zeta".to_string(), "z".to_string()),
671 ("alpha".to_string(), "a".to_string()),
672 ]);
673 let agent = agent(agent_id, harness_id);
674 let session = session(session_id, harness_id, Some(agent_id));
675
676 let first = ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
677 let mut reversed = harness.clone();
680 reversed.embedder_metadata =
681 HashMap::from([("alpha".into(), "a".into()), ("zeta".into(), "z".into())]);
682 let second = ResolvedExecutionSnapshot::project(&reversed, Some(&agent), &session).unwrap();
683
684 let first_json = serde_json::to_string(&first).unwrap();
685 let second_json = serde_json::to_string(&second).unwrap();
686 assert_eq!(first_json, second_json);
687
688 let round_tripped: ResolvedExecutionSnapshot = serde_json::from_str(&first_json).unwrap();
689 assert_eq!(serde_json::to_string(&round_tripped).unwrap(), first_json);
690
691 let alpha = first_json.find("alpha").unwrap();
693 let zeta = first_json.find("zeta").unwrap();
694 assert!(alpha < zeta);
695 }
696 #[test]
697 fn unreferenced_agent_cannot_contribute_configuration() {
698 let (harness_id, agent_id, session_id) = ids();
699 let harness = harness();
700 let agent = agent(agent_id, harness_id);
701 let session = session(session_id, harness_id, None);
702 let without = ResolvedExecutionSnapshot::project(&harness, None, &session).unwrap();
703 let with_unreferenced =
704 ResolvedExecutionSnapshot::project(&harness, Some(&agent), &session).unwrap();
705 assert_eq!(
706 serde_json::to_value(with_unreferenced).unwrap(),
707 serde_json::to_value(without).unwrap()
708 );
709 }
710}