agent_runtime_definition_builder/
lib.rs1use 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
22pub const AGENT_DEFINITION_ID_ENV: &str = "AGENT_DEFINITION_ID";
24pub const AGENT_DEFINITION_VERSION_ENV: &str = "AGENT_DEFINITION_VERSION";
26pub const AGENT_RUNTIME_TYPE_ENV: &str = "AGENT_RUNTIME_TYPE";
28
29#[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 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 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
93pub 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 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 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 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 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 pub fn skill(mut self, value: StaticSkill) -> Self {
171 self.context_hooks.push(Arc::new(value));
172 self
173 }
174
175 pub fn workspace_tools(mut self, value: WorkspaceToolSet) -> Self {
180 self.tools.extend(value.into_tools());
181 self
182 }
183
184 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}