use std::sync::Arc;
use runtime_extension_api::{
AgentDefinition, CompletionMode, ContextHook, DefinitionManifest, Guard, Observer, Tool,
};
use runtime_types::DefinitionId;
use sha2::{Digest, Sha256};
use thiserror::Error;
mod skill;
mod workspace;
pub use skill::StaticSkill;
pub use workspace::WorkspaceToolSet;
pub const AGENT_DEFINITION_ID_ENV: &str = "AGENT_DEFINITION_ID";
pub const AGENT_DEFINITION_VERSION_ENV: &str = "AGENT_DEFINITION_VERSION";
pub const AGENT_RUNTIME_TYPE_ENV: &str = "AGENT_RUNTIME_TYPE";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentEnvironment {
pub definition_id: String,
pub definition_version: String,
pub runtime_type: String,
}
impl AgentEnvironment {
pub fn load() -> Result<Option<Self>, DefinitionBuildError> {
read_environment(|name| match std::env::var(name) {
Ok(value) => Ok(Some(value)),
Err(std::env::VarError::NotPresent) => Ok(None),
Err(std::env::VarError::NotUnicode(_)) => {
Err(DefinitionBuildError::InvalidEnvironment { variable: name })
}
})
}
pub fn require() -> Result<Self, DefinitionBuildError> {
Self::load()?.ok_or(DefinitionBuildError::MissingEnvironment {
variables: vec![
AGENT_DEFINITION_ID_ENV,
AGENT_DEFINITION_VERSION_ENV,
AGENT_RUNTIME_TYPE_ENV,
],
})
}
}
#[derive(Debug, Error)]
pub enum DefinitionBuildError {
#[error("invalid definition id: {0}")]
InvalidId(String),
#[error("definition version must not be empty")]
EmptyVersion,
#[error("runtime type must not be empty")]
EmptyRuntimeType,
#[error("system prompt must not be empty")]
EmptySystemPrompt,
#[error("duplicate tool: {0}")]
DuplicateTool(String),
#[error("duplicate context hook: {0}")]
DuplicateContextHook(String),
#[error("required completion tool is not registered: {0}")]
MissingCompletionTool(String),
#[error("Agent Definition environment is incomplete; set all of {variables:?}")]
MissingEnvironment { variables: Vec<&'static str> },
#[error("Agent Definition environment variable {variable} is not valid UTF-8")]
InvalidEnvironment { variable: &'static str },
#[error("invalid static skill: {0}")]
InvalidSkill(String),
}
pub struct AgentDefinitionBuilder {
id: String,
version: String,
runtime_type: String,
system_prompt: String,
tools: Vec<Arc<dyn Tool>>,
context_hooks: Vec<Arc<dyn ContextHook>>,
guards: Vec<Arc<dyn Guard>>,
observers: Vec<Arc<dyn Observer>>,
completion_mode: CompletionMode,
}
impl AgentDefinitionBuilder {
pub fn new(id: impl Into<String>, version: impl Into<String>) -> Self {
Self {
id: id.into(),
version: version.into(),
runtime_type: "generic".into(),
system_prompt: String::new(),
tools: Vec::new(),
context_hooks: Vec::new(),
guards: Vec::new(),
observers: Vec::new(),
completion_mode: CompletionMode::ModelOrTool,
}
}
pub fn from_env() -> Result<Self, DefinitionBuildError> {
let environment = AgentEnvironment::require()?;
Ok(
Self::new(environment.definition_id, environment.definition_version)
.runtime_type(environment.runtime_type),
)
}
pub fn load_env(mut self) -> Result<Self, DefinitionBuildError> {
if let Some(environment) = AgentEnvironment::load()? {
self.id = environment.definition_id;
self.version = environment.definition_version;
self.runtime_type = environment.runtime_type;
}
Ok(self)
}
pub fn runtime_type(mut self, value: impl Into<String>) -> Self {
self.runtime_type = value.into();
self
}
pub fn system_prompt(mut self, value: impl Into<String>) -> Self {
self.system_prompt = value.into();
self
}
pub fn tool(mut self, value: Arc<dyn Tool>) -> Self {
self.tools.push(value);
self
}
pub fn skill(mut self, value: StaticSkill) -> Self {
self.context_hooks.push(Arc::new(value));
self
}
pub fn workspace_tools(mut self, value: WorkspaceToolSet) -> Self {
self.tools.extend(value.into_tools());
self
}
pub fn context_hook(mut self, value: Arc<dyn ContextHook>) -> Self {
self.context_hooks.push(value);
self
}
pub fn guard(mut self, value: Arc<dyn Guard>) -> Self {
self.guards.push(value);
self
}
pub fn observer(mut self, value: Arc<dyn Observer>) -> Self {
self.observers.push(value);
self
}
pub fn completion_mode(mut self, value: CompletionMode) -> Self {
self.completion_mode = value;
self
}
pub fn build(self) -> Result<Arc<AgentDefinition>, DefinitionBuildError> {
let id = DefinitionId::new(self.id)
.map_err(|error| DefinitionBuildError::InvalidId(error.to_string()))?;
if self.version.trim().is_empty() {
return Err(DefinitionBuildError::EmptyVersion);
}
if self.runtime_type.trim().is_empty() {
return Err(DefinitionBuildError::EmptyRuntimeType);
}
if self.system_prompt.trim().is_empty() {
return Err(DefinitionBuildError::EmptySystemPrompt);
}
let mut tools = std::collections::BTreeMap::new();
for tool in &self.tools {
let descriptor = tool.descriptor();
let name = descriptor.name.clone();
if tools
.insert(
name.clone(),
serde_json::to_string(&descriptor)
.expect("ToolDescriptor serialization is infallible"),
)
.is_some()
{
return Err(DefinitionBuildError::DuplicateTool(name));
}
}
let mut hooks = std::collections::BTreeMap::new();
for hook in &self.context_hooks {
let name = hook.name().to_owned();
if hooks
.insert(name.clone(), hook.definition_fingerprint())
.is_some()
{
return Err(DefinitionBuildError::DuplicateContextHook(name));
}
}
if let CompletionMode::RequiredTool(name) = &self.completion_mode
&& !tools.contains_key(name)
{
return Err(DefinitionBuildError::MissingCompletionTool(name.clone()));
}
let digest = definition_digest(
id.as_str(),
&self.version,
&self.runtime_type,
&self.system_prompt,
tools
.iter()
.map(|(name, fingerprint)| format!("tool:{name}:{fingerprint}"))
.chain(
hooks
.iter()
.map(|(name, fingerprint)| format!("hook:{name}:{fingerprint}")),
),
);
Ok(Arc::new(AgentDefinition {
manifest: DefinitionManifest {
id,
version: self.version,
runtime_type: self.runtime_type,
schema_version: 1,
digest,
},
system_prompt: self.system_prompt,
tools: self.tools,
context_hooks: self.context_hooks,
guards: self.guards,
observers: self.observers,
completion_mode: self.completion_mode,
}))
}
}
fn read_environment(
read: impl Fn(&'static str) -> Result<Option<String>, DefinitionBuildError>,
) -> Result<Option<AgentEnvironment>, DefinitionBuildError> {
let id = read(AGENT_DEFINITION_ID_ENV)?;
let version = read(AGENT_DEFINITION_VERSION_ENV)?;
let runtime_type = read(AGENT_RUNTIME_TYPE_ENV)?;
let present = [id.is_some(), version.is_some(), runtime_type.is_some()];
if present.iter().all(|value| !value) {
return Ok(None);
}
if !present.iter().all(|value| *value) {
let variables = [
(AGENT_DEFINITION_ID_ENV, id.is_none()),
(AGENT_DEFINITION_VERSION_ENV, version.is_none()),
(AGENT_RUNTIME_TYPE_ENV, runtime_type.is_none()),
]
.into_iter()
.filter_map(|(name, missing)| missing.then_some(name))
.collect();
return Err(DefinitionBuildError::MissingEnvironment { variables });
}
Ok(Some(AgentEnvironment {
definition_id: id.expect("presence checked"),
definition_version: version.expect("presence checked"),
runtime_type: runtime_type.expect("presence checked"),
}))
}
fn definition_digest(
id: &str,
version: &str,
runtime_type: &str,
prompt: &str,
components: impl Iterator<Item = String>,
) -> String {
let mut digest = Sha256::new();
for value in [id, version, runtime_type, prompt] {
digest.update(value.len().to_be_bytes());
digest.update(value.as_bytes());
}
for component in components {
digest.update(component.len().to_be_bytes());
digest.update(component.as_bytes());
}
format!("sha256:{:x}", digest.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn definition_digest_is_reproducible() {
let one = definition_digest(
"a",
"1",
"generic",
"prompt",
["x".to_owned(), "y".to_owned()].into_iter(),
);
let two = definition_digest(
"a",
"1",
"generic",
"prompt",
["x".to_owned(), "y".to_owned()].into_iter(),
);
assert_eq!(one, two);
}
#[test]
fn environment_identity_is_atomic() {
let complete = read_environment(|name| match name {
AGENT_DEFINITION_ID_ENV => Ok(Some("review-agent".into())),
AGENT_DEFINITION_VERSION_ENV => Ok(Some("1.2.3".into())),
AGENT_RUNTIME_TYPE_ENV => Ok(Some("review".into())),
_ => Ok(None),
})
.unwrap()
.unwrap();
assert_eq!(complete.definition_id, "review-agent");
let error = read_environment(|name| {
Ok((name == AGENT_DEFINITION_ID_ENV).then(|| "review-agent".into()))
})
.unwrap_err();
assert!(matches!(
error,
DefinitionBuildError::MissingEnvironment { .. }
));
}
#[test]
fn skill_and_workspace_tools_are_independently_composable() {
let skill_only = AgentDefinitionBuilder::new("skill-only", "1")
.system_prompt("Use the skill.")
.skill(StaticSkill::new("review", "Review carefully.").unwrap())
.build()
.unwrap();
assert!(skill_only.tools.is_empty());
assert_eq!(skill_only.context_hooks.len(), 1);
let skill_and_workspace = AgentDefinitionBuilder::new("skill-workspace", "1")
.system_prompt("Inspect the workspace.")
.skill(StaticSkill::new("inspect", "Read evidence first.").unwrap())
.workspace_tools(WorkspaceToolSet::read_only())
.build()
.unwrap();
let names = skill_and_workspace
.tools
.iter()
.map(|tool| tool.descriptor().name)
.collect::<Vec<_>>();
assert_eq!(
names,
["workspace.read", "workspace.list", "workspace.search"]
);
let changed_skill = AgentDefinitionBuilder::new("skill-workspace", "1")
.system_prompt("Inspect the workspace.")
.skill(StaticSkill::new("inspect", "Use different evidence.").unwrap())
.workspace_tools(WorkspaceToolSet::read_only())
.build()
.unwrap();
assert_ne!(
skill_and_workspace.manifest.digest,
changed_skill.manifest.digest
);
}
}