Skip to main content

everruns_core/
harness.rs

1// Harness domain types
2//
3// Harness defines base rules and capabilities for sessions.
4// Agent (optional) provides domain-specific customizations on top.
5// Hierarchy: Harness → Agent → Session
6
7use 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/// Harness lifecycle status.
22/// - `active`: Harness is available for use
23/// - `archived`: Harness is hidden from listings and cannot be modified or assigned
24/// - `deleted`: Harness is a tombstone kept only for historical references
25#[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    /// Harness is available for use.
31    Active,
32    /// Harness is hidden from listings and cannot be modified or assigned.
33    Archived,
34    /// Harness is deleted and should only survive as a tombstone for references.
35    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/// Harness configuration for sessions.
59/// A harness defines the base behavior and capabilities that apply to all sessions.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(ToSchema))]
62pub struct Harness {
63    /// Unique identifier for the harness (format: harness_{32-hex}).
64    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
65    pub id: HarnessId,
66    /// Name, unique per org (e.g. "generic").
67    #[cfg_attr(feature = "openapi", schema(example = "generic"))]
68    pub name: String,
69    /// Human-readable display name shown in UI.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    #[cfg_attr(feature = "openapi", schema(example = "Generic Harness"))]
72    pub display_name: Option<String>,
73    /// Human-readable description of what the harness does.
74    #[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    /// System prompt that defines the harness's base behavior.
83    ///
84    /// Forms the foundation of the prompt stack. Optional: when absent the
85    /// harness contributes no base prompt, so the effective prompt comes
86    /// entirely from the parent harness (if any), the agent, the session, and
87    /// capability contributions. Empty/whitespace-only values normalize to
88    /// `None`.
89    #[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    /// Optional parent harness that this harness inherits from.
98    #[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    /// Default LLM model ID for this harness.
102    /// Lowest priority in chain: controls > session > agent > harness.
103    #[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    /// Tags for organizing and filtering harnesses.
107    #[serde(default)]
108    #[cfg_attr(feature = "openapi", schema(example = json!(["baseline", "production"])))]
109    pub tags: Vec<String>,
110    /// Capabilities enabled for this harness with per-harness configuration.
111    #[serde(default)]
112    pub capabilities: Vec<AgentCapabilityConfig>,
113    /// Starter files copied into each new session for this harness.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub initial_files: Vec<InitialFile>,
116    /// Network access list controlling which hosts/URLs sessions can reach.
117    /// Merged with agent and session layers (allowed: intersect, blocked: union).
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub network_access: Option<NetworkAccessList>,
120    /// Request-level parallel tool calling preference (EVE-598).
121    ///
122    /// `None` (default) preserves provider defaults. `Some(true)` signals the
123    /// provider that parallel tool calls are wanted; `Some(false)` requests at
124    /// most one tool call per turn and forces serial execution. Merged across
125    /// harness/agent/session layers (overlay wins).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    #[cfg_attr(feature = "openapi", schema(example = true))]
128    pub parallel_tool_calls: Option<bool>,
129    /// Remote MCP servers scoped to this harness and inherited by descendant layers.
130    #[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    /// Arbitrary key-value metadata injected into LLM requests for observability.
138    /// Keys from system context (session_id, org_id, etc.) always take precedence.
139    #[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    /// Whether this harness is built-in (system-managed, readonly).
143    /// Built-in harnesses are provisioned during org initialization and
144    /// cannot be modified or deleted via the API. Users can copy them.
145    #[serde(default)]
146    #[cfg_attr(feature = "openapi", schema(example = false))]
147    pub is_built_in: bool,
148    /// Current lifecycle status of the harness.
149    pub status: HarnessStatus,
150    /// Timestamp when the harness was created.
151    #[cfg_attr(feature = "openapi", schema(example = "2026-04-01T10:00:00Z"))]
152    pub created_at: DateTime<Utc>,
153    /// Timestamp when the harness was last updated.
154    #[cfg_attr(feature = "openapi", schema(example = "2026-05-20T14:00:00Z"))]
155    pub updated_at: DateTime<Utc>,
156    /// Timestamp when the harness was archived.
157    #[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    /// Timestamp when the harness was deleted.
161    #[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
166/// Merge a parent harness into a child harness, producing the effective child harness.
167///
168/// Metadata remains child-owned (`id`, `name`, `tags`, status, timestamps). Runtime-affecting
169/// fields compose via `AgentConfigOverlay::merge()` — same semantics used for the full
170/// harness → agent → session fold.
171pub 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        // Metadata: always child-owned
178        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        // Config: from merged overlay. `None` means no base prompt at this
191        // layer; `merge_system_prompts` already trims empties to `None`.
192        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: parent base, child keys win
200        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
213/// Merge a root-to-leaf chain of harnesses into one effective harness.
214pub 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()), // overrides parent
321            ("experiment".to_string(), "v2".to_string()), // child-only key
322        ]);
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        // A child with no base prompt inherits the parent's prompt verbatim.
362        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        // Two promptless layers compose to no base prompt at all.
371        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}