use std::collections::HashSet;
use std::fmt;
use std::sync::Arc;
use everruns_core::llmsim_driver::LlmSimConfig;
use everruns_core::{AgentCapabilityConfig, DriverId, InitialFile, ResolvedModel, SessionId};
use everruns_runtime::{
AgentBuilder as RuntimeAgentBuilder, EventBus, HarnessBuilder, InProcessRuntime,
InProcessRuntimeBuilder, RuntimeBackends, RuntimeMessageStore, SessionBuilder,
};
use crate::tool::{FunctionTool, IntoTool, Tool, validate_tool_name, validate_tool_schema};
#[derive(Clone)]
pub struct Model {
resolved: ResolvedModel,
sim: Option<LlmSimConfig>,
}
impl Model {
pub fn simulated(response: impl Into<String>) -> Self {
Self {
resolved: ResolvedModel {
model: "llmsim-model".to_string(),
provider_type: DriverId::LlmSim,
api_key: Some("fake-key".to_string()),
base_url: None,
provider_metadata: None,
},
sim: Some(LlmSimConfig::fixed(response)),
}
}
#[cfg(feature = "openai")]
pub(crate) fn openai(config: crate::providers::openai::OpenAI) -> Self {
let (model, api_key, base_url) = config.into_parts();
Self {
resolved: ResolvedModel {
model,
provider_type: DriverId::OpenAI,
api_key: Some(api_key),
base_url,
provider_metadata: None,
},
sim: None,
}
}
#[cfg(feature = "openai")]
fn is_openai(&self) -> bool {
self.resolved.provider_type == DriverId::OpenAI
}
}
#[cfg(test)]
impl Model {
pub(crate) fn simulated_capturing(
response: impl Into<String>,
capture: std::sync::Arc<std::sync::Mutex<Vec<Vec<everruns_core::LlmMessage>>>>,
) -> Self {
let mut sim = LlmSimConfig::fixed(response);
sim.message_capture = Some(capture);
Self {
resolved: ResolvedModel {
model: "llmsim-model".to_string(),
provider_type: DriverId::LlmSim,
api_key: Some("fake-key".to_string()),
base_url: None,
provider_metadata: None,
},
sim: Some(sim),
}
}
pub(crate) fn simulated_delayed(
response: impl Into<String>,
delay: std::time::Duration,
) -> Self {
let sim = LlmSimConfig::fixed(response).with_response_delay(delay);
Self {
resolved: ResolvedModel {
model: "llmsim-model".to_string(),
provider_type: DriverId::LlmSim,
api_key: Some("fake-key".to_string()),
base_url: None,
provider_metadata: None,
},
sim: Some(sim),
}
}
pub(crate) fn simulated_scripted(
response: impl Into<String>,
tool_call_sequence: Vec<Vec<everruns_core::ToolCall>>,
) -> Self {
let sim = LlmSimConfig::fixed(response).with_tool_call_sequence(tool_call_sequence);
Self {
resolved: ResolvedModel {
model: "llmsim-model".to_string(),
provider_type: DriverId::LlmSim,
api_key: Some("fake-key".to_string()),
base_url: None,
provider_metadata: None,
},
sim: Some(sim),
}
}
}
impl fmt::Debug for Model {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Model")
.field("model", &self.resolved.model)
.field("provider_type", &self.resolved.provider_type)
.field("simulated", &self.sim.is_some())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum BuildError {
BlankInstructions,
MissingModel,
InvalidToolName {
name: String,
reason: String,
},
InvalidToolSchema {
name: String,
reason: String,
},
DuplicateTool {
name: String,
},
}
impl fmt::Display for BuildError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
BuildError::BlankInstructions => {
write!(f, "agent instructions must not be blank")
}
BuildError::MissingModel => write!(f, "agent requires a model"),
BuildError::InvalidToolName { name, reason } => {
write!(f, "invalid tool name {name:?}: {reason}")
}
BuildError::InvalidToolSchema { name, reason } => {
write!(f, "invalid JSON schema for tool {name:?}: {reason}")
}
BuildError::DuplicateTool { name } => {
write!(f, "duplicate tool name {name:?}")
}
}
}
}
impl std::error::Error for BuildError {}
#[derive(Clone, Debug)]
pub struct Agent {
name: String,
instructions: String,
model: Model,
capabilities: Vec<AgentCapabilityConfig>,
function_tools: Vec<FunctionTool>,
initial_files: Vec<InitialFile>,
parallel_tool_calls: Option<bool>,
}
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::default()
}
pub fn session(&self) -> crate::Session {
crate::Session::new(self.clone(), SessionId::new())
}
#[cfg(feature = "jsonl")]
pub fn session_with_store(
&self,
store: Arc<crate::persistence::JsonlSessionStore>,
) -> crate::Session {
crate::Session::with_message_store(self.clone(), SessionId::new(), store)
}
#[cfg(feature = "jsonl")]
pub fn resume_session(
&self,
store: Arc<crate::persistence::JsonlSessionStore>,
session_id: &str,
) -> Result<crate::Session, crate::persistence::JsonlError> {
let id: SessionId = session_id.parse().map_err(|_| {
crate::persistence::JsonlError::InvalidSessionId(session_id.to_string())
})?;
Ok(crate::Session::with_message_store(self.clone(), id, store))
}
pub(crate) async fn build_runtime_with_event_bus(
&self,
session_id: SessionId,
event_bus: Arc<dyn EventBus>,
message_store: Option<Arc<dyn RuntimeMessageStore>>,
) -> Result<InProcessRuntime, everruns_core::AgentLoopError> {
self.build_runtime_with_backends(session_id, Some(event_bus), message_store)
.await
}
async fn build_runtime_with_backends(
&self,
session_id: SessionId,
event_bus: Option<Arc<dyn EventBus>>,
message_store: Option<Arc<dyn RuntimeMessageStore>>,
) -> Result<InProcessRuntime, everruns_core::AgentLoopError> {
let mut harness = HarnessBuilder::new(&self.name, &self.instructions)
.capabilities(self.capabilities.clone());
if let Some(parallel) = self.parallel_tool_calls {
harness = harness.parallel_tool_calls(parallel);
}
for file in &self.initial_files {
harness = harness.initial_file(file.clone());
}
let harness_id = harness.harness_id();
let harness = harness.build();
let mut agent = RuntimeAgentBuilder::new(&self.name, &self.instructions)
.harness_id(harness_id)
.capabilities(self.capabilities.clone());
if let Some(parallel) = self.parallel_tool_calls {
agent = agent.parallel_tool_calls(parallel);
}
let agent_id = agent.agent_id();
let agent = agent.build();
let mut session = SessionBuilder::new(harness_id)
.id(session_id)
.agent(agent_id)
.capabilities(self.capabilities.clone());
if let Some(parallel) = self.parallel_tool_calls {
session = session.parallel_tool_calls(parallel);
}
for file in &self.initial_files {
session = session.initial_file(file.clone());
}
let session = session.build();
let mut builder = InProcessRuntimeBuilder::new()
.harness(harness)
.agent(agent)
.session(session)
.default_model(self.model.resolved.clone());
if event_bus.is_some() || message_store.is_some() {
let mut backends = RuntimeBackends::in_memory();
if let Some(event_bus) = event_bus {
backends = backends.with_event_bus(event_bus);
}
if let Some(message_store) = message_store {
backends = backends.with_message_store(message_store);
}
builder = builder.backends(backends);
}
for tool in &self.function_tools {
builder = builder.capability(tool.clone().into_capability());
}
if let Some(sim) = &self.model.sim {
builder = builder.llm_sim(sim.clone());
}
#[cfg(feature = "openai")]
if self.model.is_openai() {
let mut registry = everruns_core::DriverRegistry::new();
everruns_openai::register_driver(&mut registry);
builder = builder.driver_registry(registry);
}
builder.build().await
}
}
#[derive(Clone, Debug, Default)]
pub struct AgentBuilder {
name: Option<String>,
instructions: Option<String>,
model: Option<Model>,
capabilities: Vec<AgentCapabilityConfig>,
tools: Vec<Tool>,
initial_files: Vec<InitialFile>,
parallel_tool_calls: Option<bool>,
}
impl AgentBuilder {
pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
self.instructions = Some(instructions.into());
self
}
pub fn model(mut self, model: impl Into<Model>) -> Self {
self.model = Some(model.into());
self
}
pub fn name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn tool(mut self, tool: impl IntoTool) -> Self {
self.tools.push(tool.into_tool());
self
}
pub fn capability(mut self, capability: impl Into<AgentCapabilityConfig>) -> Self {
self.capabilities.push(capability.into());
self
}
pub fn parallel_tool_calls(mut self, parallel: bool) -> Self {
self.parallel_tool_calls = Some(parallel);
self
}
pub fn initial_file(mut self, file: InitialFile) -> Self {
self.initial_files.push(file);
self
}
pub fn build(self) -> Result<Agent, BuildError> {
let instructions = self.instructions.unwrap_or_default();
if instructions.trim().is_empty() {
return Err(BuildError::BlankInstructions);
}
let model = self.model.ok_or(BuildError::MissingModel)?;
let name = self.name.unwrap_or_else(|| "agent".to_string());
let mut capabilities = self.capabilities;
let mut function_tools = Vec::new();
let mut seen_tool_names: HashSet<String> = HashSet::new();
for tool in self.tools {
let tool_name = tool.name().to_string();
if !seen_tool_names.insert(tool_name.clone()) {
return Err(BuildError::DuplicateTool { name: tool_name });
}
match tool {
Tool::Capability(config) => capabilities.push(config),
Tool::Function(function_tool) => {
validate_tool_name(function_tool.name()).map_err(|reason| {
BuildError::InvalidToolName {
name: tool_name.clone(),
reason,
}
})?;
validate_tool_schema(function_tool.schema()).map_err(|reason| {
BuildError::InvalidToolSchema {
name: tool_name.clone(),
reason,
}
})?;
capabilities.push(AgentCapabilityConfig::new(function_tool.name()));
function_tools.push(function_tool);
}
}
}
Ok(Agent {
name,
instructions,
model,
capabilities,
function_tools,
initial_files: self.initial_files,
parallel_tool_calls: self.parallel_tool_calls,
})
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use everruns_core::ToolCall;
use serde_json::{Value, json};
use super::*;
use crate::FunctionTool;
fn obj_schema() -> Value {
json!({ "type": "object", "properties": {}, "additionalProperties": false })
}
#[test]
fn build_rejects_invalid_tool_name() {
let err = Agent::builder()
.instructions("You are concise.")
.model(Model::simulated("ok"))
.tool(FunctionTool::new(
"bad name",
"desc",
obj_schema(),
|_: Value| async move { Ok::<_, String>(json!({})) },
))
.build()
.unwrap_err();
assert!(
matches!(err, BuildError::InvalidToolName { ref name, .. } if name == "bad name"),
"got {err:?}"
);
}
#[test]
fn build_rejects_invalid_tool_schema() {
let err = Agent::builder()
.instructions("You are concise.")
.model(Model::simulated("ok"))
.tool(FunctionTool::new(
"arr",
"desc",
json!({ "type": "array" }),
|_: Value| async move { Ok::<_, String>(json!({})) },
))
.build()
.unwrap_err();
assert!(
matches!(err, BuildError::InvalidToolSchema { ref name, .. } if name == "arr"),
"got {err:?}"
);
}
#[test]
fn build_rejects_duplicate_tool_names() {
let make = || {
FunctionTool::new("dup", "desc", obj_schema(), |_: Value| async move {
Ok::<_, String>(json!({}))
})
};
let err = Agent::builder()
.instructions("You are concise.")
.model(Model::simulated("ok"))
.tool(make())
.tool(make())
.build()
.unwrap_err();
assert_eq!(
err,
BuildError::DuplicateTool {
name: "dup".to_string()
}
);
}
#[tokio::test]
async fn function_tool_executes_end_to_end() {
let received: Arc<Mutex<Option<Value>>> = Arc::new(Mutex::new(None));
let sink = received.clone();
let tool = FunctionTool::new(
"greet",
"Greet a person by name.",
json!({
"type": "object",
"properties": { "name": { "type": "string" } },
"required": ["name"],
}),
move |args: Value| {
let sink = sink.clone();
async move {
*sink.lock().unwrap() = Some(args.clone());
let name = args["name"].as_str().unwrap_or("world");
Ok::<_, String>(json!({ "greeting": format!("Hello, {name}!") }))
}
},
);
let agent = Agent::builder()
.instructions("Call greet when asked to greet someone.")
.model(Model::simulated_scripted(
"All done.",
vec![
vec![ToolCall {
id: "call_greet_1".into(),
name: "greet".into(),
arguments: json!({ "name": "Ada" }),
}],
vec![],
],
))
.tool(tool)
.build()
.expect("valid agent");
let mut session = agent.session();
let turn = session.run("Please greet Ada.").await.expect("turn runs");
assert!(turn.success, "turn should succeed: {:?}", turn.error);
assert_eq!(turn.tool_calls, 1, "the function tool must have executed");
assert_eq!(turn.response, "All done.");
assert_eq!(
received.lock().unwrap().as_ref().expect("handler ran")["name"],
json!("Ada"),
"handler must receive the model's call arguments",
);
}
#[tokio::test]
async fn function_tool_handler_error_is_model_visible_not_a_panic() {
let tool = FunctionTool::new(
"always_fails",
"Always returns an error.",
obj_schema(),
|_: Value| async move { Err::<Value, String>("boom".to_string()) },
);
let agent = Agent::builder()
.instructions("Call the tool.")
.model(Model::simulated_scripted(
"Handled.",
vec![
vec![ToolCall {
id: "call_fail_1".into(),
name: "always_fails".into(),
arguments: json!({}),
}],
vec![],
],
))
.tool(tool)
.build()
.expect("valid agent");
let mut session = agent.session();
let turn = session.run("go").await.expect("turn runs");
assert!(turn.success, "turn should recover from a tool error");
assert_eq!(turn.tool_calls, 1);
}
#[test]
fn build_rejects_blank_instructions() {
let err = Agent::builder()
.instructions(" ")
.model(Model::simulated("hi"))
.build()
.unwrap_err();
assert_eq!(err, BuildError::BlankInstructions);
}
#[test]
fn build_rejects_missing_model() {
let err = Agent::builder()
.instructions("You are concise.")
.build()
.unwrap_err();
assert_eq!(err, BuildError::MissingModel);
}
#[test]
fn build_succeeds_with_simulator() {
let agent = Agent::builder()
.instructions("You are concise.")
.model(Model::simulated("Sure."))
.name("assistant")
.build()
.expect("valid agent");
assert_eq!(agent.name, "assistant");
}
#[cfg(feature = "openai")]
#[test]
fn openai_model_reports_provider_without_leaking_key() {
use crate::providers::openai::OpenAI;
let model = Model::openai(OpenAI::new("gpt-5-mini", "sk-super-secret"));
assert!(model.is_openai());
assert_eq!(model.resolved.model, "gpt-5-mini");
assert!(model.sim.is_none(), "an OpenAI model uses no simulator");
let rendered = format!("{model:?}");
assert!(!rendered.contains("sk-super-secret"), "got {rendered}");
}
#[cfg(feature = "openai")]
#[tokio::test]
async fn openai_agent_builds_runtime_offline() {
use crate::providers::openai::OpenAI;
let agent = Agent::builder()
.instructions("You are concise.")
.model(OpenAI::new("gpt-5-mini", "sk-test"))
.build()
.expect("valid agent");
let runtime = agent
.build_runtime_with_backends(SessionId::new(), None, None)
.await
.expect("openai runtime builds offline");
let _ = runtime;
}
#[tokio::test]
async fn build_runtime_seeds_the_requested_session_id() {
let agent = Agent::builder()
.instructions("You are concise.")
.model(Model::simulated("Sure."))
.build()
.expect("valid agent");
let session_id = SessionId::new();
let runtime = agent
.build_runtime_with_backends(session_id, None, None)
.await
.expect("runtime builds");
let result = runtime
.run_turn(session_id, everruns_core::InputMessage::user("hi"))
.await
.expect("turn runs");
assert!(result.success);
assert_eq!(result.response, "Sure.");
}
}