Skip to main content

agent_runtime_definition_builder/
lib.rs

1//! Ergonomic, implementation-independent builders for Runtime Agent definitions.
2//!
3//! This crate owns authoring conveniences only. It may assemble public
4//! Extension API values, but it must never load Runtime deployment settings,
5//! create Agent Infra clients, or start network listeners.
6
7use std::sync::Arc;
8
9use runtime_extension_api::{
10    AgentDefinition, CompletionMode, ContextHook, DefinitionManifest, Guard, Observer, Tool,
11};
12use runtime_types::DefinitionId;
13use sha2::{Digest, Sha256};
14use thiserror::Error;
15
16mod skill;
17mod workspace;
18
19pub use skill::StaticSkill;
20pub use workspace::WorkspaceToolSet;
21
22/// Process environment variable containing the behavior Definition ID.
23pub const AGENT_DEFINITION_ID_ENV: &str = "AGENT_DEFINITION_ID";
24/// Process environment variable containing the behavior Definition version.
25pub const AGENT_DEFINITION_VERSION_ENV: &str = "AGENT_DEFINITION_VERSION";
26/// Process environment variable containing the Definition's Runtime type.
27pub const AGENT_RUNTIME_TYPE_ENV: &str = "AGENT_RUNTIME_TYPE";
28
29/// Definition identity loaded from the process environment.
30///
31/// These values identify Agent behavior. They are deliberately separate from
32/// `AGENT_INFRA_AGENT_ID` and `AGENT_INFRA_RUNTIME_ID`, which identify Infra
33/// resources and are consumed by Runtime Config/Bootstrap instead.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct AgentEnvironment {
36    pub definition_id: String,
37    pub definition_version: String,
38    pub runtime_type: String,
39}
40
41impl AgentEnvironment {
42    /// Load the three Definition identity variables as one atomic group.
43    ///
44    /// `Ok(None)` means that none of the variables is present. Supplying only a
45    /// subset is rejected so a deployment cannot accidentally combine a new ID
46    /// with a source-default version or Runtime type.
47    pub fn load() -> Result<Option<Self>, DefinitionBuildError> {
48        read_environment(|name| match std::env::var(name) {
49            Ok(value) => Ok(Some(value)),
50            Err(std::env::VarError::NotPresent) => Ok(None),
51            Err(std::env::VarError::NotUnicode(_)) => {
52                Err(DefinitionBuildError::InvalidEnvironment { variable: name })
53            }
54        })
55    }
56
57    /// Require a complete Definition identity in the process environment.
58    pub fn require() -> Result<Self, DefinitionBuildError> {
59        Self::load()?.ok_or(DefinitionBuildError::MissingEnvironment {
60            variables: vec![
61                AGENT_DEFINITION_ID_ENV,
62                AGENT_DEFINITION_VERSION_ENV,
63                AGENT_RUNTIME_TYPE_ENV,
64            ],
65        })
66    }
67}
68
69#[derive(Debug, Error)]
70pub enum DefinitionBuildError {
71    #[error("invalid definition id: {0}")]
72    InvalidId(String),
73    #[error("definition version must not be empty")]
74    EmptyVersion,
75    #[error("runtime type must not be empty")]
76    EmptyRuntimeType,
77    #[error("system prompt must not be empty")]
78    EmptySystemPrompt,
79    #[error("duplicate tool: {0}")]
80    DuplicateTool(String),
81    #[error("duplicate context hook: {0}")]
82    DuplicateContextHook(String),
83    #[error("required completion tool is not registered: {0}")]
84    MissingCompletionTool(String),
85    #[error("Agent Definition environment is incomplete; set all of {variables:?}")]
86    MissingEnvironment { variables: Vec<&'static str> },
87    #[error("Agent Definition environment variable {variable} is not valid UTF-8")]
88    InvalidEnvironment { variable: &'static str },
89    #[error("invalid static skill: {0}")]
90    InvalidSkill(String),
91}
92
93/// Fluent builder for one immutable [`AgentDefinition`].
94///
95/// The builder is intentionally additive: a developer may register only a
96/// custom Tool, only a Skill, a Skill plus standard Workspace Tools, or any
97/// combination of Tool/Hook/Guard/Observer. No hidden default Tool or network
98/// capability is added.
99pub struct AgentDefinitionBuilder {
100    id: String,
101    version: String,
102    runtime_type: String,
103    system_prompt: String,
104    tools: Vec<Arc<dyn Tool>>,
105    context_hooks: Vec<Arc<dyn ContextHook>>,
106    guards: Vec<Arc<dyn Guard>>,
107    observers: Vec<Arc<dyn Observer>>,
108    completion_mode: CompletionMode,
109}
110
111impl AgentDefinitionBuilder {
112    /// Start from source-controlled Definition identity.
113    pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
114        Self {
115            id: id.into(),
116            version: version.into(),
117            runtime_type: "generic".into(),
118            system_prompt: String::new(),
119            tools: Vec::new(),
120            context_hooks: Vec::new(),
121            guards: Vec::new(),
122            observers: Vec::new(),
123            completion_mode: CompletionMode::ModelOrTool,
124        }
125    }
126
127    /// Start from the complete `AGENT_DEFINITION_*` environment identity.
128    ///
129    /// Prefer this constructor in a generic Runtime distribution whose exact
130    /// Definition identity is deployment-owned. A checked-in Agent may instead
131    /// use [`Self::new`] followed by [`Self::load_env`] to make environment
132    /// override support explicit while retaining source defaults.
133    pub fn from_env() -> Result<Self, DefinitionBuildError> {
134        let environment = AgentEnvironment::require()?;
135        Ok(
136            Self::new(environment.definition_id, environment.definition_version)
137                .runtime_type(environment.runtime_type),
138        )
139    }
140
141    /// Explicitly apply a complete Definition identity from the environment.
142    ///
143    /// If none of the three variables is set, the source identity is retained.
144    /// A partial environment is always an error. This method never reads Infra
145    /// IDs, model selection, workspace binding, URLs, or credentials.
146    pub fn load_env(mut self) -> Result<Self, DefinitionBuildError> {
147        if let Some(environment) = AgentEnvironment::load()? {
148            self.id = environment.definition_id;
149            self.version = environment.definition_version;
150            self.runtime_type = environment.runtime_type;
151        }
152        Ok(self)
153    }
154
155    /// Set the behavior family used by Bootstrap binding validation.
156    pub fn runtime_type(mut self, value: impl Into<String>) -> Self {
157        self.runtime_type = value.into();
158        self
159    }
160    pub fn system_prompt(mut self, value: impl Into<String>) -> Self {
161        self.system_prompt = value.into();
162        self
163    }
164    pub fn tool(mut self, value: Arc<dyn Tool>) -> Self {
165        self.tools.push(value);
166        self
167    }
168
169    /// Register one always-available static Skill as a Context Hook.
170    pub fn skill(mut self, value: StaticSkill) -> Self {
171        self.context_hooks.push(Arc::new(value));
172        self
173    }
174
175    /// Register a standard set of capability-scoped Workspace Tools.
176    ///
177    /// The tools operate exclusively through `ToolContext.workspace`; no local
178    /// path, endpoint, credential, or concrete Infra SDK client is exposed.
179    pub fn workspace_tools(mut self, value: WorkspaceToolSet) -> Self {
180        self.tools.extend(value.into_tools());
181        self
182    }
183
184    /// Register a dynamic Context Hook for stateful Skills, Memory, or Plan.
185    pub fn context_hook(mut self, value: Arc<dyn ContextHook>) -> Self {
186        self.context_hooks.push(value);
187        self
188    }
189    pub fn guard(mut self, value: Arc<dyn Guard>) -> Self {
190        self.guards.push(value);
191        self
192    }
193    pub fn observer(mut self, value: Arc<dyn Observer>) -> Self {
194        self.observers.push(value);
195        self
196    }
197    pub fn completion_mode(mut self, value: CompletionMode) -> Self {
198        self.completion_mode = value;
199        self
200    }
201
202    pub fn build(self) -> Result<Arc<AgentDefinition>, DefinitionBuildError> {
203        let id = DefinitionId::new(self.id)
204            .map_err(|error| DefinitionBuildError::InvalidId(error.to_string()))?;
205        if self.version.trim().is_empty() {
206            return Err(DefinitionBuildError::EmptyVersion);
207        }
208        if self.runtime_type.trim().is_empty() {
209            return Err(DefinitionBuildError::EmptyRuntimeType);
210        }
211        if self.system_prompt.trim().is_empty() {
212            return Err(DefinitionBuildError::EmptySystemPrompt);
213        }
214        let mut tools = std::collections::BTreeMap::new();
215        for tool in &self.tools {
216            let descriptor = tool.descriptor();
217            let name = descriptor.name.clone();
218            if tools
219                .insert(
220                    name.clone(),
221                    serde_json::to_string(&descriptor)
222                        .expect("ToolDescriptor serialization is infallible"),
223                )
224                .is_some()
225            {
226                return Err(DefinitionBuildError::DuplicateTool(name));
227            }
228        }
229        let mut hooks = std::collections::BTreeMap::new();
230        for hook in &self.context_hooks {
231            let name = hook.name().to_owned();
232            if hooks
233                .insert(name.clone(), hook.definition_fingerprint())
234                .is_some()
235            {
236                return Err(DefinitionBuildError::DuplicateContextHook(name));
237            }
238        }
239        if let CompletionMode::RequiredTool(name) = &self.completion_mode
240            && !tools.contains_key(name)
241        {
242            return Err(DefinitionBuildError::MissingCompletionTool(name.clone()));
243        }
244        let digest = definition_digest(
245            id.as_str(),
246            &self.version,
247            &self.runtime_type,
248            &self.system_prompt,
249            tools
250                .iter()
251                .map(|(name, fingerprint)| format!("tool:{name}:{fingerprint}"))
252                .chain(
253                    hooks
254                        .iter()
255                        .map(|(name, fingerprint)| format!("hook:{name}:{fingerprint}")),
256                ),
257        );
258        Ok(Arc::new(AgentDefinition {
259            manifest: DefinitionManifest {
260                id,
261                version: self.version,
262                runtime_type: self.runtime_type,
263                schema_version: 1,
264                digest,
265            },
266            system_prompt: self.system_prompt,
267            tools: self.tools,
268            context_hooks: self.context_hooks,
269            guards: self.guards,
270            observers: self.observers,
271            completion_mode: self.completion_mode,
272        }))
273    }
274}
275
276fn read_environment(
277    read: impl Fn(&'static str) -> Result<Option<String>, DefinitionBuildError>,
278) -> Result<Option<AgentEnvironment>, DefinitionBuildError> {
279    let id = read(AGENT_DEFINITION_ID_ENV)?;
280    let version = read(AGENT_DEFINITION_VERSION_ENV)?;
281    let runtime_type = read(AGENT_RUNTIME_TYPE_ENV)?;
282    let present = [id.is_some(), version.is_some(), runtime_type.is_some()];
283    if present.iter().all(|value| !value) {
284        return Ok(None);
285    }
286    if !present.iter().all(|value| *value) {
287        let variables = [
288            (AGENT_DEFINITION_ID_ENV, id.is_none()),
289            (AGENT_DEFINITION_VERSION_ENV, version.is_none()),
290            (AGENT_RUNTIME_TYPE_ENV, runtime_type.is_none()),
291        ]
292        .into_iter()
293        .filter_map(|(name, missing)| missing.then_some(name))
294        .collect();
295        return Err(DefinitionBuildError::MissingEnvironment { variables });
296    }
297    Ok(Some(AgentEnvironment {
298        definition_id: id.expect("presence checked"),
299        definition_version: version.expect("presence checked"),
300        runtime_type: runtime_type.expect("presence checked"),
301    }))
302}
303
304fn definition_digest(
305    id: &str,
306    version: &str,
307    runtime_type: &str,
308    prompt: &str,
309    components: impl Iterator<Item = String>,
310) -> String {
311    let mut digest = Sha256::new();
312    for value in [id, version, runtime_type, prompt] {
313        digest.update(value.len().to_be_bytes());
314        digest.update(value.as_bytes());
315    }
316    for component in components {
317        digest.update(component.len().to_be_bytes());
318        digest.update(component.as_bytes());
319    }
320    format!("sha256:{:x}", digest.finalize())
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn definition_digest_is_reproducible() {
329        let one = definition_digest(
330            "a",
331            "1",
332            "generic",
333            "prompt",
334            ["x".to_owned(), "y".to_owned()].into_iter(),
335        );
336        let two = definition_digest(
337            "a",
338            "1",
339            "generic",
340            "prompt",
341            ["x".to_owned(), "y".to_owned()].into_iter(),
342        );
343        assert_eq!(one, two);
344    }
345
346    #[test]
347    fn environment_identity_is_atomic() {
348        let complete = read_environment(|name| match name {
349            AGENT_DEFINITION_ID_ENV => Ok(Some("review-agent".into())),
350            AGENT_DEFINITION_VERSION_ENV => Ok(Some("1.2.3".into())),
351            AGENT_RUNTIME_TYPE_ENV => Ok(Some("review".into())),
352            _ => Ok(None),
353        })
354        .unwrap()
355        .unwrap();
356        assert_eq!(complete.definition_id, "review-agent");
357
358        let error = read_environment(|name| {
359            Ok((name == AGENT_DEFINITION_ID_ENV).then(|| "review-agent".into()))
360        })
361        .unwrap_err();
362        assert!(matches!(
363            error,
364            DefinitionBuildError::MissingEnvironment { .. }
365        ));
366    }
367
368    #[test]
369    fn skill_and_workspace_tools_are_independently_composable() {
370        let skill_only = AgentDefinitionBuilder::new("skill-only", "1")
371            .system_prompt("Use the skill.")
372            .skill(StaticSkill::new("review", "Review carefully.").unwrap())
373            .build()
374            .unwrap();
375        assert!(skill_only.tools.is_empty());
376        assert_eq!(skill_only.context_hooks.len(), 1);
377
378        let skill_and_workspace = AgentDefinitionBuilder::new("skill-workspace", "1")
379            .system_prompt("Inspect the workspace.")
380            .skill(StaticSkill::new("inspect", "Read evidence first.").unwrap())
381            .workspace_tools(WorkspaceToolSet::read_only())
382            .build()
383            .unwrap();
384        let names = skill_and_workspace
385            .tools
386            .iter()
387            .map(|tool| tool.descriptor().name)
388            .collect::<Vec<_>>();
389        assert_eq!(
390            names,
391            ["workspace.read", "workspace.list", "workspace.search"]
392        );
393
394        let changed_skill = AgentDefinitionBuilder::new("skill-workspace", "1")
395            .system_prompt("Inspect the workspace.")
396            .skill(StaticSkill::new("inspect", "Use different evidence.").unwrap())
397            .workspace_tools(WorkspaceToolSet::read_only())
398            .build()
399            .unwrap();
400        assert_ne!(
401            skill_and_workspace.manifest.digest,
402            changed_skill.manifest.digest
403        );
404    }
405}