1use std::collections::HashMap;
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::capability_types::AgentCapabilityConfig;
13use crate::mcp_server::{ScopedMcpServers, scoped_mcp_servers_is_empty};
14use crate::network_access::NetworkAccessList;
15use crate::session_file::InitialFile;
16use crate::typed_id::{HarnessId, ModelId};
17
18#[cfg(feature = "openapi")]
19use utoipa::ToSchema;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
26#[cfg_attr(feature = "openapi", derive(ToSchema))]
27#[cfg_attr(feature = "openapi", schema(example = "active"))]
28#[serde(rename_all = "lowercase")]
29pub enum HarnessStatus {
30 Active,
32 Archived,
34 Deleted,
36}
37
38impl std::fmt::Display for HarnessStatus {
39 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40 match self {
41 HarnessStatus::Active => write!(f, "active"),
42 HarnessStatus::Archived => write!(f, "archived"),
43 HarnessStatus::Deleted => write!(f, "deleted"),
44 }
45 }
46}
47
48impl From<&str> for HarnessStatus {
49 fn from(s: &str) -> Self {
50 match s {
51 "archived" => HarnessStatus::Archived,
52 "deleted" => HarnessStatus::Deleted,
53 _ => HarnessStatus::Active,
54 }
55 }
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(ToSchema))]
62pub struct Harness {
63 #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
65 pub id: HarnessId,
66 #[cfg_attr(feature = "openapi", schema(example = "generic"))]
68 pub name: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
71 #[cfg_attr(feature = "openapi", schema(example = "Generic Harness"))]
72 pub display_name: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 #[cfg_attr(
76 feature = "openapi",
77 schema(
78 example = "Default harness with file-system + secrets capabilities; safe baseline for new agents."
79 )
80 )]
81 pub description: Option<String>,
82 #[serde(default, skip_serializing_if = "Option::is_none")]
90 #[cfg_attr(
91 feature = "openapi",
92 schema(
93 example = "You are an Everruns agent. Be concise, cite sources when possible, and decline tasks outside your assigned scope."
94 )
95 )]
96 pub system_prompt: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "harness_01933b5a000070008000000000000602"))]
100 pub parent_harness_id: Option<HarnessId>,
101 #[serde(skip_serializing_if = "Option::is_none")]
104 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "model_01933b5a00007000800000000000001"))]
105 pub default_model_id: Option<ModelId>,
106 #[serde(default)]
108 #[cfg_attr(feature = "openapi", schema(example = json!(["baseline", "production"])))]
109 pub tags: Vec<String>,
110 #[serde(default)]
112 pub capabilities: Vec<AgentCapabilityConfig>,
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub initial_files: Vec<InitialFile>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub network_access: Option<NetworkAccessList>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
127 #[cfg_attr(feature = "openapi", schema(example = true))]
128 pub parallel_tool_calls: Option<bool>,
129 #[serde(
131 default,
132 rename = "mcpServers",
133 alias = "mcp_servers",
134 skip_serializing_if = "scoped_mcp_servers_is_empty"
135 )]
136 pub mcp_servers: ScopedMcpServers,
137 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
140 #[cfg_attr(feature = "openapi", schema(example = json!({"env": "production", "team": "platform"})))]
141 pub embedder_metadata: HashMap<String, String>,
142 #[serde(default)]
146 #[cfg_attr(feature = "openapi", schema(example = false))]
147 pub is_built_in: bool,
148 pub status: HarnessStatus,
150 #[cfg_attr(feature = "openapi", schema(example = "2026-04-01T10:00:00Z"))]
152 pub created_at: DateTime<Utc>,
153 #[cfg_attr(feature = "openapi", schema(example = "2026-05-20T14:00:00Z"))]
155 pub updated_at: DateTime<Utc>,
156 #[serde(skip_serializing_if = "Option::is_none")]
158 #[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
159 pub archived_at: Option<DateTime<Utc>>,
160 #[serde(skip_serializing_if = "Option::is_none")]
162 #[cfg_attr(feature = "openapi", schema(example = "2026-05-26T00:00:00Z"))]
163 pub deleted_at: Option<DateTime<Utc>>,
164}
165
166pub fn merge_harness(parent: &Harness, child: &Harness) -> Harness {
172 use crate::config_layer::AgentConfigOverlay;
173
174 let effective = AgentConfigOverlay::from(parent).merge(AgentConfigOverlay::from(child));
175
176 Harness {
177 id: child.id,
179 name: child.name.clone(),
180 display_name: child.display_name.clone(),
181 description: child.description.clone(),
182 parent_harness_id: child.parent_harness_id,
183 tags: child.tags.clone(),
184 is_built_in: child.is_built_in,
185 status: child.status.clone(),
186 created_at: child.created_at,
187 updated_at: child.updated_at,
188 archived_at: child.archived_at,
189 deleted_at: child.deleted_at,
190 system_prompt: effective.system_prompt,
193 default_model_id: effective.default_model_id,
194 capabilities: effective.capabilities,
195 initial_files: effective.initial_files,
196 network_access: effective.network_access,
197 parallel_tool_calls: effective.parallel_tool_calls,
198 mcp_servers: effective.mcp_servers,
199 embedder_metadata: {
201 let mut m = parent.embedder_metadata.clone();
202 m.extend(
203 child
204 .embedder_metadata
205 .iter()
206 .map(|(k, v)| (k.clone(), v.clone())),
207 );
208 m
209 },
210 }
211}
212
213pub fn merge_harness_chain(chain: &[Harness]) -> Option<Harness> {
215 let mut iter = chain.iter();
216 let first = iter.next()?.clone();
217 Some(iter.fold(first, |effective, layer| merge_harness(&effective, layer)))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 fn test_harness(id_seed: u128, system_prompt: &str) -> Harness {
225 Harness {
226 id: HarnessId::from_uuid(uuid::Uuid::from_u128(id_seed)),
227 name: format!("harness-{id_seed}"),
228 display_name: Some(format!("Harness {id_seed}")),
229 description: None,
230 system_prompt: Some(system_prompt.to_string()),
231 parent_harness_id: None,
232 default_model_id: None,
233 tags: vec![],
234 capabilities: vec![],
235 initial_files: vec![],
236 network_access: None,
237 parallel_tool_calls: None,
238 mcp_servers: ScopedMcpServers::default(),
239 embedder_metadata: HashMap::new(),
240 is_built_in: false,
241 status: HarnessStatus::Active,
242 created_at: Utc::now(),
243 updated_at: Utc::now(),
244 archived_at: None,
245 deleted_at: None,
246 }
247 }
248
249 #[test]
250 fn merges_prompt_capabilities_and_initial_files() {
251 let mut parent = test_harness(1, "Parent prompt.");
252 parent.capabilities = vec![
253 AgentCapabilityConfig::new("session_file_system"),
254 AgentCapabilityConfig::with_config(
255 "web_fetch",
256 serde_json::json!({"enable_file_download": true}),
257 ),
258 ];
259 parent.initial_files = vec![InitialFile {
260 path: "/workspace/README.md".to_string(),
261 content: "parent".to_string(),
262 encoding: "text".to_string(),
263 is_readonly: true,
264 }];
265
266 let mut child = test_harness(2, "Child prompt.");
267 child.parent_harness_id = Some(parent.id);
268 child.capabilities = vec![
269 AgentCapabilityConfig::with_config(
270 "web_fetch",
271 serde_json::json!({"enable_file_download": false}),
272 ),
273 AgentCapabilityConfig::new("platform_management"),
274 ];
275 child.initial_files = vec![
276 InitialFile {
277 path: "README.md".to_string(),
278 content: "child".to_string(),
279 encoding: "text".to_string(),
280 is_readonly: false,
281 },
282 InitialFile {
283 path: "/notes.txt".to_string(),
284 content: "notes".to_string(),
285 encoding: "text".to_string(),
286 is_readonly: false,
287 },
288 ];
289
290 let merged = merge_harness(&parent, &child);
291
292 assert_eq!(
293 merged.system_prompt.as_deref(),
294 Some("Parent prompt.\n\nChild prompt.")
295 );
296 assert_eq!(merged.capabilities.len(), 3);
297 assert_eq!(
298 merged.capabilities[1],
299 AgentCapabilityConfig::with_config(
300 "web_fetch",
301 serde_json::json!({"enable_file_download": false}),
302 )
303 );
304 assert_eq!(merged.initial_files.len(), 2);
305 assert_eq!(merged.initial_files[0].content, "child");
306 assert_eq!(merged.initial_files[1].path, "/notes.txt");
307 }
308
309 #[test]
310 fn merges_embedder_metadata_parent_first_child_wins() {
311 let mut parent = test_harness(1, "Parent.");
312 parent.embedder_metadata = HashMap::from([
313 ("env".to_string(), "production".to_string()),
314 ("team".to_string(), "platform".to_string()),
315 ]);
316
317 let mut child = test_harness(2, "Child.");
318 child.parent_harness_id = Some(parent.id);
319 child.embedder_metadata = HashMap::from([
320 ("team".to_string(), "ai".to_string()), ("experiment".to_string(), "v2".to_string()), ]);
323
324 let merged = merge_harness(&parent, &child);
325
326 assert_eq!(
327 merged.embedder_metadata.get("env").map(String::as_str),
328 Some("production")
329 );
330 assert_eq!(
331 merged.embedder_metadata.get("team").map(String::as_str),
332 Some("ai"),
333 "child wins on collision"
334 );
335 assert_eq!(
336 merged
337 .embedder_metadata
338 .get("experiment")
339 .map(String::as_str),
340 Some("v2")
341 );
342 }
343
344 #[test]
345 fn merges_chain_root_to_leaf() {
346 let root = test_harness(1, "Root");
347 let mut middle = test_harness(2, "Middle");
348 middle.parent_harness_id = Some(root.id);
349 let mut leaf = test_harness(3, "Leaf");
350 leaf.parent_harness_id = Some(middle.id);
351
352 let merged = merge_harness_chain(&[root, middle, leaf]).expect("merged harness");
353 assert_eq!(
354 merged.system_prompt.as_deref(),
355 Some("Root\n\nMiddle\n\nLeaf")
356 );
357 }
358
359 #[test]
360 fn merges_optional_prompts_skipping_empty_layers() {
361 let parent = test_harness(1, "Parent prompt.");
363 let mut child = test_harness(2, "");
364 child.system_prompt = None;
365 child.parent_harness_id = Some(parent.id);
366
367 let merged = merge_harness(&parent, &child);
368 assert_eq!(merged.system_prompt.as_deref(), Some("Parent prompt."));
369
370 let mut root = test_harness(1, "");
372 root.system_prompt = None;
373 let mut leaf = test_harness(2, "");
374 leaf.system_prompt = None;
375 leaf.parent_harness_id = Some(root.id);
376
377 let merged = merge_harness(&root, &leaf);
378 assert_eq!(merged.system_prompt, None);
379 }
380}