#![doc = include_str!("../README.md")]
#[cfg(not(any(feature = "python", feature = "native")))]
compile_error!(
"At least one backend feature must be enabled for `agy-bridge`. \
Enable `python` (default) or `native` (pure-Rust standalone harness)."
);
extern crate self as agy_bridge;
pub mod agent;
pub mod config;
pub mod content;
pub mod error;
pub mod hooks;
pub mod policies;
#[cfg(feature = "native")]
pub mod proto;
pub mod runtime;
pub mod streaming;
pub mod tools;
pub mod triggers;
pub mod types;
pub use config::{
AgentConfig, BuiltinTools, CapabilitiesConfig, GeminiConfig, LocalAgentConfig, McpConfigError,
McpConfigFile, McpServer, McpServerSpec, McpSseServer, McpStdioServer, McpStreamableHttpServer,
SystemInstructions,
};
pub use content::{Audio, Content, ContentPrimitive, Document, Image, Video};
pub use error::Error;
pub use hooks::{HookCallback, HookEntry, HookPoint, HookResult, HookSet, Hooks};
pub use llm_tool_macros::llm_tool;
pub use policies::{AskUserHandler, PolicyDecision, PolicyRule, PolicySet};
pub use runtime::{BackendLogLevel, RuntimeConfig};
pub use streaming::{ChatResponseHandle, ChatResult, ResponseEvent, StreamChunk};
pub use tools::{
AvailableTool, RustTool, ToolContext, ToolDefinition, ToolError, ToolOutput, ToolRegistry,
ToolSource,
};
pub use triggers::{TriggerConfig, TriggerEntry};
pub use types::{ConversationMessage, MessageRole, Step, UsageMetadata};
pub mod prelude {
pub use llm_tool_macros::llm_tool;
pub use crate::{
Agent, AgyBridge,
config::{
AgentConfig, BuiltinTools, CapabilitiesConfig, GeminiConfig, LocalAgentConfig,
McpConfigError, McpConfigFile, McpServer, McpServerSpec, McpSseServer, McpStdioServer,
McpStreamableHttpServer, SystemInstructions,
},
content::{Audio, Content, ContentPrimitive, Document, Image, Video},
error::Error,
hooks::{HookPoint, HookResult, Hooks},
policies::{AskUserHandler, PolicyDecision, PolicyRule, PolicySet},
runtime::BackendLogLevel,
streaming::{ChatResponseHandle, ChatResult, ResponseEvent, StreamChunk},
tools::{
AvailableTool, RustTool, ToolContext, ToolDefinition, ToolError, ToolOutput,
ToolRegistry, ToolSource,
},
triggers::{TriggerConfig, TriggerEntry},
types::{ConversationMessage, MessageRole, Step, UsageMetadata},
};
}
use std::sync::Arc;
pub fn load_dotenv() -> &'static std::collections::HashMap<String, String> {
use std::sync::OnceLock;
static CACHED: OnceLock<std::collections::HashMap<String, String>> = OnceLock::new();
CACHED.get_or_init(|| {
let start = std::env::var_os("CARGO_MANIFEST_DIR").map_or_else(
|| {
std::env::current_dir().unwrap_or_else(|e| {
tracing::debug!("load_dotenv: current_dir() failed: {e}, using fallback \".\"");
std::path::PathBuf::from(".")
})
},
std::path::PathBuf::from,
);
let mut dir = start.as_path();
loop {
let candidate = dir.join(".env");
if candidate.is_file() {
let mut env_map = std::collections::HashMap::new();
match std::fs::read_to_string(&candidate) {
Ok(contents) => {
for line in contents.lines() {
if let Some((k, v)) = parse_dotenv_line(line)
&& std::env::var_os(k).is_none()
{
unsafe {
std::env::set_var(k, v);
}
env_map.insert(k.to_owned(), v.to_owned());
}
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to read .env file at {}", candidate.display());
}
}
return env_map;
}
match dir.parent() {
Some(parent) => dir = parent,
None => return std::collections::HashMap::new(),
}
}
})
}
pub(crate) fn parse_dotenv_line(line: &str) -> Option<(&str, &str)> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let (k, v) = line.split_once('=')?;
let k = k.trim();
if k.is_empty() {
return None;
}
let v = v.trim();
let v = v
.strip_prefix('"')
.and_then(|s| s.strip_suffix('"'))
.or_else(|| v.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
.unwrap_or(v);
Some((k, v))
}
#[cfg(feature = "python")]
pub type DefaultRuntime = runtime::PythonRuntime;
#[cfg(all(not(feature = "python"), feature = "native"))]
pub type DefaultRuntime = runtime::NativeRuntime;
#[cfg(feature = "python")]
pub type PythonAgent = agent::AgentHandle<runtime::PythonRuntime>;
#[cfg(feature = "native")]
pub type NativeAgent = agent::AgentHandle<runtime::NativeRuntime>;
pub type Agent = agent::AgentHandle<DefaultRuntime>;
pub struct AgyBridge<R: agent::Runtime + 'static = DefaultRuntime> {
runtime: Arc<R>,
}
pub struct AgyBridgeBuilder {
config: runtime::RuntimeConfig,
}
impl AgyBridgeBuilder {
#[must_use]
pub fn channel_capacity(mut self, capacity: usize) -> Self {
self.config.channel_capacity = capacity;
self
}
#[must_use]
pub fn shutdown_timeout(mut self, timeout: std::time::Duration) -> Self {
self.config.shutdown_timeout = timeout;
self
}
#[must_use]
pub fn inter_agent_delay(mut self, delay: std::time::Duration) -> Self {
self.config.inter_agent_delay = delay;
self
}
#[must_use]
pub fn backend_log_level(mut self, level: runtime::BackendLogLevel) -> Self {
self.config.backend_log_level = level;
self
}
#[must_use]
pub fn runtime_config(mut self, config: runtime::RuntimeConfig) -> Self {
self.config = config;
self
}
#[must_use]
pub fn max_consecutive_model_errors(mut self, limit: u32) -> Self {
self.config.max_consecutive_model_errors = Some(limit);
self
}
#[must_use]
pub fn max_consecutive_empty_steps(mut self, limit: u32) -> Self {
self.config.max_consecutive_empty_steps = Some(limit);
self
}
#[must_use]
pub fn streaming_channel_buffer(mut self, size: usize) -> Self {
self.config.streaming_channel_buffer = Some(size);
self
}
#[must_use]
pub fn harness_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.config.harness_binary_path = Some(path.into());
self
}
#[cfg(feature = "python")]
pub fn build_python(self) -> Result<AgyBridge<runtime::PythonRuntime>, error::Error> {
Ok(AgyBridge {
runtime: Arc::new(runtime::PythonRuntime::new(self.config)?),
})
}
#[cfg(feature = "native")]
pub fn build_native(self) -> Result<AgyBridge<runtime::NativeRuntime>, error::Error> {
Ok(AgyBridge {
runtime: Arc::new(runtime::NativeRuntime::new(self.config)),
})
}
pub fn build(self) -> Result<AgyBridge<DefaultRuntime>, error::Error> {
#[cfg(feature = "python")]
{
self.build_python()
}
#[cfg(all(not(feature = "python"), feature = "native"))]
{
self.build_native()
}
}
}
impl<R: agent::Runtime + 'static> AgyBridge<R> {
#[must_use]
pub fn new(runtime: Arc<R>) -> Self {
Self { runtime }
}
#[must_use]
pub fn agent(&self, config: config::AgentConfig) -> AgentBuilder<'_, R> {
AgentBuilder {
bridge: self,
config,
registry: None,
hooks: None,
policy_handler: None,
}
}
#[must_use]
pub fn default_agent(&self) -> AgentBuilder<'_, R> {
self.agent(config::AgentConfig::default())
}
#[must_use]
pub fn runtime(&self) -> &Arc<R> {
&self.runtime
}
}
#[cfg(feature = "python")]
impl AgyBridge<runtime::PythonRuntime> {
pub async fn active_agent_count(&self) -> Result<usize, error::Error> {
self.runtime.active_agent_count().await
}
}
impl AgyBridge<DefaultRuntime> {
#[must_use]
pub fn builder() -> AgyBridgeBuilder {
AgyBridgeBuilder {
config: runtime::RuntimeConfig::default(),
}
}
}
#[cfg(feature = "native")]
impl AgyBridge<runtime::NativeRuntime> {
#[must_use]
pub fn native_builder() -> AgyBridgeBuilder {
AgyBridgeBuilder {
config: runtime::RuntimeConfig::default(),
}
}
pub async fn active_agent_count(&self) -> Result<usize, error::Error> {
self.runtime.active_agent_count().await
}
}
pub struct AgentBuilder<'a, R: agent::Runtime + 'static = DefaultRuntime> {
bridge: &'a AgyBridge<R>,
config: config::AgentConfig,
registry: Option<tools::ToolRegistry>,
hooks: Option<hooks::Hooks>,
policy_handler: Option<Arc<dyn policies::AskUserHandler>>,
}
impl<R: agent::Runtime + 'static> AgentBuilder<'_, R> {
#[must_use]
pub fn tools(mut self, registry: tools::ToolRegistry) -> Self {
self.registry = Some(registry);
self
}
#[must_use]
pub fn hooks(mut self, hooks: hooks::Hooks) -> Self {
self.hooks = Some(hooks);
self
}
#[must_use]
pub fn policy_handler(mut self, handler: impl policies::AskUserHandler + 'static) -> Self {
self.policy_handler = Some(Arc::new(handler));
self
}
#[must_use]
pub fn conversation_id(mut self, id: impl Into<String>) -> Self {
self.config.conversation_id = Some(id.into());
self
}
#[must_use]
pub fn model(mut self, model: impl Into<String>) -> Self {
self.config.model = model.into();
self
}
#[must_use]
pub fn api_key(mut self, key: impl Into<String>) -> Self {
self.config.api_key = Some(key.into());
self
}
#[must_use]
pub fn system_instructions(
mut self,
instructions: impl Into<config::SystemInstructions>,
) -> Self {
self.config.system_instructions = Some(instructions.into());
self
}
#[must_use]
pub fn capabilities(mut self, capabilities: config::CapabilitiesConfig) -> Self {
self.config.capabilities = Some(capabilities);
self
}
#[must_use]
pub fn workspaces(
mut self,
workspaces: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
) -> Self {
self.config
.workspaces
.extend(workspaces.into_iter().map(Into::into));
self
}
#[must_use]
pub fn policies(
mut self,
policies: impl IntoIterator<Item = impl Into<policies::PolicyRule>>,
) -> Self {
self.config
.policies
.extend(policies.into_iter().map(Into::into));
self
}
#[must_use]
pub fn triggers(
mut self,
triggers: impl IntoIterator<Item = impl Into<triggers::TriggerEntry>>,
) -> Self {
self.config
.triggers
.extend(triggers.into_iter().map(Into::into));
self
}
#[must_use]
pub fn mcp_servers(
mut self,
servers: impl IntoIterator<Item = impl Into<config::McpServer>>,
) -> Self {
self.config
.mcp_servers
.extend(servers.into_iter().map(Into::into));
self
}
#[must_use]
pub fn skills(
mut self,
skills: impl IntoIterator<Item = impl Into<std::path::PathBuf>>,
) -> Self {
self.config
.skills
.extend(skills.into_iter().map(Into::into));
self
}
pub async fn build(mut self) -> Result<agent::AgentHandle<R>, error::Error> {
if let Some(ref caps) = self.config.capabilities {
caps.validate().map_err(|msg| error::Error::InvalidConfig {
message: msg.to_string(),
})?;
}
if let Some(ref schema) = self.config.response_schema {
schema
.validate()
.map_err(|msg| error::Error::InvalidConfig {
message: msg.to_string(),
})?;
}
let arc_registry = if let Some(registry) = self.registry {
if !self.config.tools.is_empty() {
return Err(error::Error::InvalidConfig {
message: "config.tools is non-empty and a ToolRegistry was also provided; \
pass tools via the registry or via config.tools, not both"
.to_string(),
});
}
self.config.tools = registry.definitions();
Some(Arc::new(registry))
} else {
None
};
let arc_hooks = if let Some(hooks) = self.hooks {
if !self.config.hooks.is_empty() {
return Err(error::Error::InvalidConfig {
message: "config.hooks is non-empty and a Hooks instance was also provided; \
configure hooks via Hooks or config.hooks, not both"
.to_string(),
});
}
self.config.hooks = hooks.entries();
Some(Arc::new(hooks))
} else {
None
};
let arc_policy = self.policy_handler;
agent::AgentHandle::new(
Arc::clone(&self.bridge.runtime),
self.config,
arc_registry,
arc_hooks,
arc_policy,
)
.await
}
}
impl<'a, R: agent::Runtime + 'static> std::future::IntoFuture for AgentBuilder<'a, R> {
type Output = Result<agent::AgentHandle<R>, error::Error>;
type IntoFuture =
std::pin::Pin<Box<dyn std::future::Future<Output = Self::Output> + Send + 'a>>;
fn into_future(self) -> Self::IntoFuture {
Box::pin(self.build())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotenv_strips_double_quotes() {
let (k, v) = parse_dotenv_line(r#"API_KEY="my-secret""#).unwrap();
assert_eq!(k, "API_KEY");
assert_eq!(v, "my-secret");
}
#[test]
fn dotenv_strips_single_quotes() {
let (k, v) = parse_dotenv_line("TOKEN='abc123'").unwrap();
assert_eq!(k, "TOKEN");
assert_eq!(v, "abc123");
}
#[test]
fn dotenv_unquoted_value_unchanged() {
let (k, v) = parse_dotenv_line("FOO=bar").unwrap();
assert_eq!(k, "FOO");
assert_eq!(v, "bar");
}
#[test]
fn dotenv_mismatched_quotes_preserved() {
let (k, v) = parse_dotenv_line(r#"KEY="value'"#).unwrap();
assert_eq!(k, "KEY");
assert_eq!(v, r#""value'"#);
}
#[test]
fn dotenv_empty_quoted_value() {
let (k, v) = parse_dotenv_line(r#"EMPTY="""#).unwrap();
assert_eq!(k, "EMPTY");
assert_eq!(v, "");
}
#[test]
fn dotenv_whitespace_around_key_value() {
let (k, v) = parse_dotenv_line(" MY_VAR = \"hello world\" ").unwrap();
assert_eq!(k, "MY_VAR");
assert_eq!(v, "hello world");
}
#[test]
fn dotenv_comment_line_is_none() {
assert!(parse_dotenv_line("# this is a comment").is_none());
}
#[test]
fn dotenv_blank_line_is_none() {
assert!(parse_dotenv_line(" ").is_none());
}
#[test]
fn dotenv_empty_key_is_none() {
assert!(parse_dotenv_line("=value").is_none());
}
#[test]
fn dotenv_no_equals_is_none() {
assert!(parse_dotenv_line("JUSTKEY").is_none());
}
#[test]
fn dotenv_value_with_internal_equals() {
let (k, v) = parse_dotenv_line("DSN=postgres://host:5432/db?opt=1").unwrap();
assert_eq!(k, "DSN");
assert_eq!(v, "postgres://host:5432/db?opt=1");
}
#[test]
fn dotenv_value_with_embedded_quotes_not_stripped() {
let (k, v) = parse_dotenv_line(r#"MSG=say "hello""#).unwrap();
assert_eq!(k, "MSG");
assert_eq!(v, r#"say "hello""#);
}
#[test]
fn test_load_dotenv_returns_static_reference_identity() {
let map1 = load_dotenv();
let map2 = load_dotenv();
assert!(std::ptr::eq(map1, map2));
}
}