use std::collections::{BTreeMap, BTreeSet};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use ingot_compiler::Compilation;
use ingot_mcp::{AgentTools, McpConfig, McpToolHost};
use ingot_runtime::{
run as run_agent, AgentRegistry, ApprovalHandler, ApprovalMode, ApprovalRequest, Artifact,
Cassette, DenyAllTools, EventSink, ModelConfig, ModelProvider, RecordingProvider,
RecordingTools, ReplayProvider, ReplayToolHost as ReplayTools, RoutingProvider, RunError,
RunEvent, RunOptions, RunReport, ToolHost,
};
use serde_json::Value;
use crate::contained::Containment;
const MCP_TRANSPORT: &str = "mcp";
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum ProviderChoice {
Auto,
Anthropic,
Openai,
Google,
Replay,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, clap::ValueEnum)]
pub enum EventFormat {
Text,
Json,
Quiet,
}
pub const SNAPSHOTS_DIR: &str = "snapshots";
pub struct RunConfig {
pub inputs: Vec<String>,
pub provider: ProviderChoice,
pub cassette: Option<PathBuf>,
pub record: Option<PathBuf>,
#[cfg_attr(not(feature = "providers"), allow(dead_code))]
pub model: Option<String>,
#[cfg_attr(not(feature = "providers"), allow(dead_code))]
pub effort: Option<String>,
pub agent: Option<String>,
pub out_dir: Option<PathBuf>,
pub history: Option<PathBuf>,
pub events: EventFormat,
pub build_dir: Option<PathBuf>,
pub stop_at: Option<String>,
pub resume: Option<PathBuf>,
pub snapshot: Option<PathBuf>,
pub memory: Option<PathBuf>,
pub memory_mode: crate::memory::MemoryMode,
pub yes: bool,
pub max_steps: u32,
pub root: PathBuf,
pub mcp: McpConfig,
pub no_tools: bool,
pub sandbox: bool,
pub sandbox_allow_unenforced: bool,
pub allow_unenforced_scopes: bool,
pub workspace: PathBuf,
pub models: ModelConfig,
pub contained: bool,
pub supervised: bool,
pub image: Option<String>,
pub timeout_seconds: Option<u64>,
}
impl RunConfig {
pub fn containment(&self) -> Option<Containment> {
match (self.contained, self.supervised) {
(true, _) => Some(Containment::Bounded),
(false, true) => Some(Containment::Unbounded),
(false, false) => None,
}
}
fn selection(&self) -> ProviderSelection {
ProviderSelection {
choice: self.provider,
cassette: self.cassette.clone(),
model: self.model.clone(),
effort: self.effort.clone(),
models: self.models.clone(),
replay_from: 0,
strict_replay: true,
}
}
}
pub struct ProviderSelection {
pub choice: ProviderChoice,
pub cassette: Option<PathBuf>,
#[cfg_attr(not(feature = "providers"), allow(dead_code))]
pub model: Option<String>,
#[cfg_attr(not(feature = "providers"), allow(dead_code))]
pub effort: Option<String>,
pub models: ModelConfig,
pub replay_from: usize,
pub strict_replay: bool,
}
pub const CASSETTE_DIR: &str = "tests/cassettes";
pub fn project_cassette(root: &Path) -> Result<PathBuf> {
let directory = root.join(CASSETTE_DIR);
let mut found: Vec<PathBuf> = std::fs::read_dir(&directory)
.map_err(|_| {
anyhow::anyhow!(
"`--provider replay` needs a cassette, and {} does not exist\n \
record one with: ingot run --record {CASSETTE_DIR}/<name>.json --input ...",
directory.display()
)
})?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().map(|ext| ext == "json").unwrap_or(false))
.collect();
found.sort();
match found.len() {
0 => bail!(
"`--provider replay` needs a cassette, and {} holds none\n \
record one with: ingot run --record {CASSETTE_DIR}/<name>.json --input ...",
directory.display()
),
1 => Ok(found.remove(0)),
_ => bail!(
"{} holds {} cassettes, so `--cassette <FILE>` has to say which:\n{}",
directory.display(),
found.len(),
found
.iter()
.map(|path| format!(" {}", path.display()))
.collect::<Vec<_>>()
.join("\n")
),
}
}
pub fn build_model_provider(selection: &ProviderSelection) -> Result<Box<dyn ModelProvider>> {
match selection.choice {
ProviderChoice::Replay => {
let Some(path) = &selection.cassette else {
bail!(
"`--provider replay` needs `--cassette <FILE>`\n\
record one first with: ingot run --record <FILE>"
);
};
let cassette = Cassette::load(path).map_err(anyhow::Error::msg)?;
let provider = ReplayProvider::new(cassette).skipping(selection.replay_from);
Ok(Box::new(if selection.strict_replay {
provider
} else {
provider.lenient()
}))
}
ProviderChoice::Anthropic => anthropic(selection),
ProviderChoice::Openai => openai(selection),
ProviderChoice::Google => google(selection),
ProviderChoice::Auto => auto(selection),
}
}
fn boundable(config: &RunConfig) -> &'static [&'static str] {
if config.sandbox || config.containment().is_some() {
&["filesystem_read", "filesystem_write"]
} else {
&[]
}
}
fn check_declared_reach(
ir: &ingot_ir::AgentIr,
registry: &AgentRegistry,
config: &RunConfig,
) -> Result<()> {
let boundable = boundable(config);
let mut unkept: Vec<String> = Vec::new();
let mut any_network = false;
for agent in std::iter::once(ir).chain(registry.values()) {
for tool in &agent.tools {
for (effect, values) in &tool.scopes {
if boundable.contains(&effect.as_str()) {
continue;
}
any_network |= effect == "network";
unkept.push(format!(
" `{}` declares {effect}({})\n in agent `{}`",
tool.name,
values
.iter()
.map(|value| format!("{value:?}"))
.collect::<Vec<_>>()
.join(", "),
agent.agent
));
}
}
}
unkept.sort();
unkept.dedup();
if unkept.is_empty() {
return Ok(());
}
if !config.allow_unenforced_scopes {
let advice = if any_network {
"bounding egress to a host needs a proxy no arrangement has yet (GAP-001)"
} else {
"this run has no boundary, so nothing bounds a tool to anything; \
run with --sandbox"
};
bail!(
"this program states where its tools may reach, and this run cannot keep it:\n{}\n\n \
{advice}\n \
pass --allow-unenforced-scopes to proceed knowing the declaration is advisory here",
unkept.join("\n")
);
}
for note in &unkept {
eprintln!("warning: proceeding with a reach nothing enforces\n{note}");
}
Ok(())
}
pub fn execute(compilation: &Compilation, config: &RunConfig) -> Result<u8> {
let (ir, registry) = select_agent(compilation, config.agent.as_deref())?;
check_declared_reach(&ir, ®istry, config)?;
let inputs = parse_inputs(&config.inputs)?;
let mut approval = approval_mode(config);
if let Some(mode) = config.containment() {
if config.record.is_some() {
bail!(
"`--record` cannot be combined with a supervised run\n \
the cassette would record the model exchanges, which happen out here, and omit \
the tool results, which happen in there — a recording that claims to be of a \
contained run and is not\n \
record without --contained, or replay into one with --provider replay"
);
}
let command = crate::contained::prepare(compilation, config, mode, &ir)?;
let mut provider = build_provider(config, &ir.agent, &inputs, 0)?;
return crate::contained::execute(
command,
compilation,
config,
&ir,
inputs,
provider.as_mut(),
&mut approval,
);
}
let mut tools = Tools::new(tool_host(compilation, config)?, config.record.is_some());
let resume = match &config.resume {
Some(path) => {
let snapshot = ingot_runtime::Resumption::load(path).map_err(anyhow::Error::msg)?;
snapshot.check(&ir).map_err(anyhow::Error::msg)?;
Some(snapshot)
}
None => None,
};
let replay_from = resume
.as_ref()
.map(|snapshot| snapshot.model_calls as usize)
.unwrap_or(0);
let mut provider = build_provider(config, &ir.agent, &inputs, replay_from)?;
let mut sink = RunSink {
printer: printer_for(config, compilation, false),
};
let store = crate::memory::open(
&ir,
config.memory.as_deref(),
config.build_dir.as_deref(),
config.memory_mode.clone(),
)?;
if !store.note.is_empty() && config.events != EventFormat::Quiet {
eprintln!("{}", store.note);
}
if let Some(dropped) = &store.dropped {
eprintln!("{dropped}");
}
let result = run_agent(
&ir,
®istry,
provider.as_mut(),
tools.as_mut(),
&mut sink,
RunOptions {
inputs,
approval,
max_steps: config.max_steps,
memory: store.fields,
stop_at: config.stop_at.clone(),
resume,
pricing: config.models.pricing(),
},
);
if let Some(path) = &config.record {
if let Some(mut cassette) = provider.finish_recording() {
cassette.tool_calls = tools.finish_recording();
cassette.save(path).map_err(anyhow::Error::msg)?;
eprintln!(
"recorded {} interaction(s) and {} tool call(s) to {}",
cassette.interactions.len(),
cassette.tool_calls.len(),
path.display()
);
}
}
let report = match result {
Ok(report) => report,
Err(error) => {
sink.printer.finish_record(crate::runs::Outcome::Failed {
reason: &error.to_string(),
});
report_failure(&error);
return Ok(super::EXIT_DIAGNOSTICS);
}
};
if let Some(path) = &store.path {
crate::memory::save(path, &ir, &report.memory)?;
}
if let Some(snapshot) = &report.stopped {
let path = snapshot_path(config, &ir.agent, &snapshot.label);
snapshot.save(&path).map_err(anyhow::Error::msg)?;
sink.printer.finish_record(crate::runs::Outcome::Finished {
steps: report.steps,
usage: report.usage,
cost: report.spend.rendered(),
});
eprintln!(
"stopped at \"{}\"\n resume with: ingot run --resume {}",
snapshot.label,
path.display()
);
return Ok(super::EXIT_OK);
}
sink.printer.finish_record(crate::runs::Outcome::Finished {
steps: report.steps,
usage: report.usage,
cost: report.spend.rendered(),
});
report_cost(&report);
write_outputs(&report, config)?;
Ok(super::EXIT_OK)
}
fn snapshot_path(config: &RunConfig, agent: &str, label: &str) -> PathBuf {
if let Some(path) = &config.snapshot {
return path.clone();
}
let safe = |text: &str| -> String {
text.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' {
ch
} else {
'_'
}
})
.collect()
};
let base = config
.build_dir
.clone()
.unwrap_or_else(|| PathBuf::from("."));
base.join(SNAPSHOTS_DIR)
.join(format!("{}-{}.json", safe(agent), safe(label)))
}
pub(crate) fn printer_for(
config: &RunConfig,
compilation: &Compilation,
contained: bool,
) -> EventPrinter {
let printer = EventPrinter::new(config.events, compilation);
match &config.history {
Some(out_dir) => printer.recording_to(out_dir, contained),
None => printer,
}
}
pub(crate) struct EventPrinter {
format: EventFormat,
trace: crate::trace::HumanTrace,
streaming: bool,
history: Option<History>,
}
struct History {
out_dir: PathBuf,
contained: bool,
recorder: Option<crate::runs::RunRecorder>,
}
impl EventPrinter {
pub(crate) fn new(format: EventFormat, compilation: &Compilation) -> Self {
Self {
format,
trace: crate::trace::HumanTrace::with_sources(
&compilation.agents,
&compilation.sources,
compilation.file,
),
streaming: false,
history: None,
}
}
pub(crate) fn recording_to(mut self, out_dir: &Path, contained: bool) -> Self {
self.history = Some(History {
out_dir: out_dir.to_path_buf(),
contained,
recorder: None,
});
self
}
pub(crate) fn print(&mut self, event: &RunEvent) {
self.close_stream();
self.record(event);
match self.format {
EventFormat::Text => eprintln!("{}", self.trace.render(event)),
EventFormat::Json => eprintln!("{}", event.to_json_line()),
EventFormat::Quiet => {}
}
}
fn record(&mut self, event: &RunEvent) {
let Some(history) = &mut self.history else {
return;
};
if history.recorder.is_none() {
let RunEvent::RunStarted { agent, provider } = event else {
return;
};
history.recorder = crate::runs::RunRecorder::begin(
&history.out_dir,
agent,
provider,
history.contained,
);
}
if let Some(recorder) = &mut history.recorder {
recorder.event(event);
}
}
pub(crate) fn finish_record(&mut self, outcome: crate::runs::Outcome<'_>) {
let Some(history) = &mut self.history else {
return;
};
let Some(recorder) = &mut history.recorder else {
return;
};
recorder.finish(outcome);
if self.format != EventFormat::Quiet {
eprintln!("history {}", recorder.path().display());
}
}
pub(crate) fn delta(&mut self, node: &str, text: &str) {
match self.format {
EventFormat::Text => {
if !self.streaming {
eprint!(" ");
self.streaming = true;
}
eprint!("{}", text.replace('\n', "\n "));
let _ = std::io::stderr().flush();
}
EventFormat::Json => {
self.streaming = true;
eprintln!(
"{}",
serde_json::json!({ "delta": { "node": node, "text": text } })
);
}
EventFormat::Quiet => {}
}
}
pub(crate) fn settled(&mut self, node: &str, kept: bool) {
match self.format {
EventFormat::Text => {
self.close_stream();
if !kept {
eprintln!(" (discarded: that text is not the answer)");
}
}
EventFormat::Json => {
self.streaming = false;
eprintln!(
"{}",
serde_json::json!({ "settled": { "node": node, "kept": kept } })
);
}
EventFormat::Quiet => {}
}
}
fn close_stream(&mut self) {
if self.streaming && self.format == EventFormat::Text {
eprintln!();
}
self.streaming = false;
}
}
pub(crate) struct RunSink {
printer: EventPrinter,
}
impl EventSink for RunSink {
fn emit(&mut self, event: RunEvent) {
self.printer.print(&event);
}
fn delta(&mut self, node: &str, text: &str) {
self.printer.delta(node, text);
}
fn settled(&mut self, node: &str, kept: bool) {
self.printer.settled(node, kept);
}
}
fn report_failure(error: &RunError) {
eprintln!("error: {error}");
if error.is_operator_error() {
eprintln!(
"hint: this is a problem with how the run was invoked, not with the agent itself"
);
}
if let RunError::CapabilityDenied {
effect, explicit, ..
} = error
{
if *explicit {
eprintln!("hint: the artifact's policy denies `{effect}`; rebuild it with the capability granted");
} else {
eprintln!(
"hint: add `{} allow [...]` to the agent's policy block and rebuild",
policy_subject(effect)
);
}
}
}
fn policy_subject(effect: &str) -> &str {
match effect {
"secret_access" => "secrets",
other => other,
}
}
fn select_agent(
compilation: &Compilation,
requested: Option<&str>,
) -> Result<(ingot_ir::AgentIr, AgentRegistry)> {
if compilation.agents.is_empty() {
bail!("the program declares no agent");
}
let registry: AgentRegistry = compilation
.agents
.iter()
.map(|agent| (agent.agent.clone(), agent.clone()))
.collect();
let ir = match requested {
Some(name) => compilation.agent(name).cloned().with_context(|| {
let available: Vec<&str> = compilation
.agents
.iter()
.map(|agent| agent.agent.as_str())
.collect();
format!(
"no agent named `{name}`; this file declares: {}",
available.join(", ")
)
})?,
None => {
compilation
.agents
.last()
.cloned()
.expect("checked non-empty above")
}
};
Ok((ir, registry))
}
fn parse_inputs(raw: &[String]) -> Result<BTreeMap<String, Value>> {
let mut inputs = BTreeMap::new();
for entry in raw {
let Some((name, value)) = entry.split_once('=') else {
bail!("`--input {entry}` is not `name=value`");
};
let name = name.trim().to_string();
let value = value.trim();
let parsed = if let Some(path) = value.strip_prefix('@') {
let text = std::fs::read_to_string(path)
.with_context(|| format!("reading input file {path}"))?;
Value::String(text)
} else {
serde_json::from_str(value).unwrap_or_else(|_| Value::String(value.to_string()))
};
inputs.insert(name, parsed);
}
Ok(inputs)
}
fn approval_mode(config: &RunConfig) -> ApprovalMode {
if config.yes {
return ApprovalMode::AssumeYes;
}
if std::io::stdin().is_terminal() {
ApprovalMode::Ask(Box::new(TerminalApprovals))
} else {
ApprovalMode::Deny
}
}
struct TerminalApprovals;
impl ApprovalHandler for TerminalApprovals {
fn approve(&mut self, request: &ApprovalRequest) -> bool {
eprintln!();
eprintln!(" APPROVAL REQUIRED at node {}", request.node);
eprintln!(" {}", request.reason);
eprintln!(" effects: {}", request.effects.join(", "));
eprint!(" allow? [y/N] ");
let _ = std::io::stderr().flush();
let mut answer = String::new();
if std::io::stdin().read_line(&mut answer).is_err() {
return false;
}
matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
}
}
fn refuse_remote_under_a_boundary(config: &RunConfig) -> Result<()> {
let named = |flag: &str| -> Result<()> {
let server = config
.mcp
.servers
.iter()
.find(|server| server.is_remote())
.map(|server| server.name.clone())
.unwrap_or_default();
bail!(
"MCP server `{server}` is reached over a network, which `{flag}` cannot cover\n \
{}\n \
help: run it locally with a `command`, or drop `{flag}`",
if flag == "--sandbox" {
"a boundary bounds a process this machine starts, and there is none here"
} else {
"the supervisor channel carries a model call and an approval gate; \
there is no channel for a tool call out of the box"
}
)
};
if config.sandbox {
return named("--sandbox");
}
if config.contained {
return named("--contained");
}
Ok(())
}
pub(crate) fn required_tools(compilation: &Compilation) -> BTreeSet<String> {
compilation
.agents
.iter()
.flat_map(|agent| agent.tools.iter())
.filter(|tool| tool.transport == MCP_TRANSPORT)
.map(|tool| tool.name.clone())
.collect()
}
fn tools_per_agent(compilation: &Compilation) -> Vec<AgentTools> {
compilation
.agents
.iter()
.map(|agent| {
AgentTools::new(
agent.agent.clone(),
agent
.tools
.iter()
.filter(|tool| tool.transport == MCP_TRANSPORT)
.map(|tool| tool.name.clone())
.collect(),
)
.with_network(network_grant(agent))
})
.collect()
}
fn network_grant(agent: &ingot_ir::AgentIr) -> ingot_mcp::NetworkGrant {
match agent.policy.get("network") {
Some(rule) => ingot_mcp::NetworkGrant {
allowed: matches!(
rule.decision,
ingot_ir::Decision::Allow | ingot_ir::Decision::RequireApproval
),
hosts: rule.values.iter().cloned().collect(),
},
None => ingot_mcp::NetworkGrant::default(),
}
}
fn boundary_name(compilation: &Compilation) -> String {
let agent = compilation
.agents
.first()
.map(|agent| agent.agent.as_str())
.unwrap_or("run");
let slug: String = agent
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
format!("{}-{}", slug.to_ascii_lowercase(), std::process::id())
}
fn contained_host(compilation: &Compilation, config: &RunConfig) -> Result<McpToolHost> {
let detected = ingot_sandbox::detect();
let egress_image = std::env::var("INGOT_EGRESS_IMAGE")
.unwrap_or_else(|_| ingot_sandbox::DEFAULT_EGRESS_IMAGE.to_string());
let can_filter = detected
.as_ref()
.ok()
.and_then(|runtime| ingot_sandbox::image_exists(runtime, &egress_image).ok())
.unwrap_or(false);
let plans =
crate::sandbox::plan_all_with(compilation, &config.mcp, &config.workspace, can_filter)
.map_err(|problems| anyhow::anyhow!("{}", problems.join("\n")))?;
let hosts = crate::sandbox::allowed_hosts(&plans);
if !hosts.is_empty() && !can_filter {
eprintln!(
"note: `{egress_image}` is not present, so the host allowlist cannot be kept\n \
build it with: docker build -f tools/egress.Dockerfile -t {egress_image} ."
);
}
let unenforced: Vec<String> = plans
.values()
.filter(|plan| !plan.is_fully_enforced())
.flat_map(|plan| {
plan.unenforceable
.iter()
.map(move |note| format!(" {} ({})\n {}", note.policy, plan.agent, note.reason))
})
.collect();
if !unenforced.is_empty() && !config.sandbox_allow_unenforced {
bail!(
"the boundary cannot honour every rule this program states:\n{}\n\n\
run `ingot sandbox` to see the whole picture, tighten the policy, or pass \
--sandbox-allow-unenforced to proceed knowing which limits are advisory",
unenforced.join("\n")
);
}
for note in &unenforced {
eprintln!("warning: proceeding with an unenforced rule\n{note}");
}
let runtime = detected.map_err(|error| anyhow::anyhow!("{error}"))?;
let mut launcher =
crate::sandbox::ContainerLauncher::new(runtime.clone(), config.workspace.clone(), plans);
if can_filter && !hosts.is_empty() {
let name = boundary_name(compilation);
let boundary = ingot_sandbox::EgressBoundary::start(&runtime, &name, &hosts, &egress_image)
.map_err(|error| anyhow::anyhow!("{error}"))?;
eprintln!("egress bounded to {} by a proxy", hosts.join(", "));
launcher = launcher.through(boundary);
}
McpToolHost::connect_agents(
&config.mcp,
&config.root,
&tools_per_agent(compilation),
&launcher,
)
.map_err(|error| anyhow::anyhow!("{error}"))
}
fn tool_host(compilation: &Compilation, config: &RunConfig) -> Result<Box<dyn ToolHost>> {
let required = required_tools(compilation);
if config.no_tools || config.mcp.is_empty() {
if !required.is_empty() && !config.no_tools {
eprintln!(
"warning: this program declares {} tool(s) and the manifest configures no MCP \
server, so any call will stop the run",
required.len()
);
eprintln!("hint: run `ingot tools` to see what is missing");
}
return Ok(Box::new(DenyAllTools));
}
let remote = config.mcp.servers.iter().any(|server| server.is_remote());
if remote {
refuse_remote_under_a_boundary(config)?;
}
let host = if config.sandbox {
contained_host(compilation, config)?
} else if remote {
McpToolHost::connect_agents(
&config.mcp,
&config.root,
&tools_per_agent(compilation),
&ingot_mcp::DirectLauncher,
)
.map_err(|error| anyhow::anyhow!("{error}"))?
} else {
McpToolHost::connect(&config.mcp, &config.root, &required)
.map_err(|error| anyhow::anyhow!("{error}"))?
};
for server in &config.mcp.servers {
if server.is_remote()
&& !server.url.as_deref().unwrap_or("").starts_with("https://")
&& !server.is_loopback()
{
eprintln!(
"warning: MCP server `{}` is reached over plain HTTP at {}\n \
tool arguments and results cross the network unencrypted",
server.name,
server.url.as_deref().unwrap_or("")
);
}
}
eprintln!("{}", host.launcher());
for tool in host.resolved() {
eprintln!(
"tool {} <- {}:{}{}",
tool.tool,
tool.server,
tool.remote,
if tool.aliased { " (aliased)" } else { "" }
);
}
for missing in host.unresolved(&required) {
eprintln!("warning: no configured server provides `{missing}`");
}
Ok(Box::new(host))
}
enum Tools {
Plain(Box<dyn ToolHost>),
Recording(RecordingTools<Box<dyn ToolHost>>),
}
impl Tools {
fn new(inner: Box<dyn ToolHost>, record: bool) -> Tools {
if record {
Tools::Recording(RecordingTools::new(inner))
} else {
Tools::Plain(inner)
}
}
fn as_mut(&mut self) -> &mut dyn ToolHost {
match self {
Tools::Plain(inner) => inner.as_mut(),
Tools::Recording(inner) => inner,
}
}
fn finish_recording(self) -> Vec<ingot_runtime::ToolExchange> {
match self {
Tools::Plain(_) => Vec::new(),
Tools::Recording(inner) => inner.finish(),
}
}
}
pub(crate) enum Provider {
Plain(Box<dyn ModelProvider>),
Recording(RecordingProvider<Box<dyn ModelProvider>>),
}
impl Provider {
pub(crate) fn new(inner: Box<dyn ModelProvider>, record: bool, agent: &str) -> Provider {
if record {
Provider::Recording(RecordingProvider::new(inner, agent))
} else {
Provider::Plain(inner)
}
}
pub(crate) fn as_mut(&mut self) -> &mut dyn ModelProvider {
match self {
Provider::Plain(inner) => inner.as_mut(),
Provider::Recording(inner) => inner,
}
}
pub(crate) fn finish_recording(self) -> Option<Cassette> {
match self {
Provider::Plain(_) => None,
Provider::Recording(inner) => Some(inner.finish()),
}
}
}
fn build_provider(
config: &RunConfig,
agent: &str,
inputs: &BTreeMap<String, Value>,
replay_from: usize,
) -> Result<Provider> {
let inner = build_model_provider(&ProviderSelection {
replay_from,
..config.selection()
})?;
Ok(if config.record.is_some() {
Provider::Recording(RecordingProvider::new(inner, agent).with_inputs(inputs.clone()))
} else {
Provider::Plain(inner)
})
}
pub const BUILT_IN_PROVIDERS: &[&str] = &["anthropic", "google", "openai"];
pub struct BuiltIn {
pub name: &'static str,
pub protocol: &'static str,
pub variables: &'static [&'static str],
pub included: bool,
}
pub const BUILT_IN: &[BuiltIn] = &[
BuiltIn {
name: "anthropic",
protocol: "anthropic",
variables: &["ANTHROPIC_API_KEY"],
included: cfg!(feature = "anthropic"),
},
BuiltIn {
name: "google",
protocol: "google",
variables: &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
included: cfg!(feature = "google"),
},
BuiltIn {
name: "openai",
protocol: "openai",
variables: &["OPENAI_API_KEY"],
included: cfg!(feature = "openai"),
},
];
#[cfg(feature = "google")]
pub fn google_key_is_set() -> bool {
["GEMINI_API_KEY", "GOOGLE_API_KEY"]
.iter()
.any(|name| std::env::var_os(name).is_some())
}
fn available(selection: &ProviderSelection) -> Result<Vec<(String, Box<dyn ModelProvider>)>> {
selection
.models
.validate(BUILT_IN_PROVIDERS)
.map_err(|reason| anyhow::anyhow!("{reason}"))?;
let declared: BTreeSet<&str> = selection
.models
.providers
.iter()
.map(|provider| provider.name.as_str())
.collect();
#[allow(unused_mut)]
let mut providers: Vec<(String, Box<dyn ModelProvider>)> = Vec::new();
#[cfg(feature = "anthropic")]
if !declared.contains("anthropic") && std::env::var_os("ANTHROPIC_API_KEY").is_some() {
if let Ok(provider) = ingot_runtime::anthropic::AnthropicProvider::from_env() {
providers.push((
ingot_runtime::anthropic::PROVIDER.to_string(),
Box::new(
provider
.with_model(selection.model.clone())
.with_effort(selection.effort.clone()),
),
));
}
}
#[cfg(feature = "openai")]
if !declared.contains("openai") && std::env::var_os("OPENAI_API_KEY").is_some() {
if let Ok(provider) = ingot_runtime::openai::OpenAiProvider::from_env() {
providers.push((
ingot_runtime::openai::PROVIDER.to_string(),
Box::new(
provider
.with_model(selection.model.clone())
.with_effort(selection.effort.clone()),
),
));
}
}
#[cfg(feature = "google")]
if !declared.contains("google") && google_key_is_set() {
if let Ok(provider) = ingot_runtime::google::GoogleProvider::from_env() {
providers.push((
ingot_runtime::google::PROVIDER.to_string(),
Box::new(
provider
.with_model(selection.model.clone())
.with_effort(selection.effort.clone()),
),
));
}
}
#[cfg(feature = "providers")]
for declaration in &selection.models.providers {
let provider = ingot_runtime::catalogue::build(
declaration,
selection.model.clone(),
selection.effort.clone(),
)
.map_err(|error| anyhow::anyhow!("{error}"))?;
providers.push((declaration.name.clone(), provider));
}
#[cfg(not(feature = "providers"))]
if let Some(declaration) = selection.models.providers.first() {
bail!(
"the manifest declares the model provider `{}`, and this build has no HTTP provider \
to reach it with\n \
rebuild with `--features openai` (or `anthropic`), or use \
`--provider replay --cassette <FILE>`",
declaration.name
);
}
let _ = declared;
Ok(providers)
}
fn auto(selection: &ProviderSelection) -> Result<Box<dyn ModelProvider>> {
let mut providers = available(selection)?;
if providers.is_empty() {
bail!(
"no model provider is available\n \
export ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY, declare one with \
`[[model.provider]]` in {}, or use `--provider replay --cassette <FILE>`",
super::MANIFEST_NAME
);
}
let chosen_default = selection
.models
.default
.clone()
.or_else(|| (providers.len() == 1).then(|| providers[0].0.clone()));
let mut router = RoutingProvider::new();
if let Some(name) = &chosen_default {
if let Some(index) = providers.iter().position(|(vendor, _)| vendor == name) {
let (vendor, provider) = providers.remove(index);
router = router.or_else(vendor, provider);
}
}
for (vendor, provider) in providers {
router = router.with(vendor, provider);
}
eprintln!("{}", router.describe());
Ok(Box::new(router))
}
fn anthropic(selection: &ProviderSelection) -> Result<Box<dyn ModelProvider>> {
#[cfg(feature = "anthropic")]
{
Ok(Box::new(
ingot_runtime::anthropic::AnthropicProvider::from_env()
.map_err(anyhow::Error::msg)?
.with_model(selection.model.clone())
.with_effort(selection.effort.clone())
.with_catalogue(selection.models.clone()),
))
}
#[cfg(not(feature = "anthropic"))]
{
let _ = selection;
bail!(
"this build has no Anthropic provider\n\
rebuild with `cargo build --features anthropic`, or use \
`--provider replay --cassette <FILE>`"
);
}
}
fn openai(selection: &ProviderSelection) -> Result<Box<dyn ModelProvider>> {
#[cfg(feature = "openai")]
{
Ok(Box::new(
ingot_runtime::openai::OpenAiProvider::from_env()
.map_err(anyhow::Error::msg)?
.with_model(selection.model.clone())
.with_effort(selection.effort.clone())
.with_catalogue(selection.models.clone()),
))
}
#[cfg(not(feature = "openai"))]
{
let _ = selection;
bail!(
"this build has no OpenAI provider\n\
rebuild with `cargo build --features openai`, or use \
`--provider replay --cassette <FILE>`"
);
}
}
fn google(selection: &ProviderSelection) -> Result<Box<dyn ModelProvider>> {
#[cfg(feature = "google")]
{
Ok(Box::new(
ingot_runtime::google::GoogleProvider::from_env()
.map_err(anyhow::Error::msg)?
.with_model(selection.model.clone())
.with_effort(selection.effort.clone())
.with_catalogue(selection.models.clone()),
))
}
#[cfg(not(feature = "google"))]
{
let _ = selection;
bail!(
"this build has no Google provider\n\
rebuild with `cargo build --features google`, or use \
`--provider replay --cassette <FILE>`"
);
}
}
fn report_cost(report: &RunReport) {
let spend = &report.spend;
if let Some(rendered) = spend.rendered() {
eprintln!("cost {rendered}");
}
for (model, reason) in spend.unpriced() {
eprintln!("cost not charged for `{model}`: {reason}");
}
if !spend.is_complete() {
eprintln!(" the budget was not enforced; add `[[model.price]]` to charge it");
}
}
pub(crate) fn write_outputs(report: &RunReport, config: &RunConfig) -> Result<()> {
let Some(dir) = &config.out_dir else {
let mut stdout = std::io::stdout().lock();
for artifact in report.outputs.values() {
stdout
.write_all(&artifact.to_bytes())
.context("writing to standard output")?;
if !artifact.to_bytes().ends_with(b"\n") {
stdout.write_all(b"\n").ok();
}
}
return Ok(());
};
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
for artifact in report.outputs.values() {
let path = artifact_path(dir, artifact);
std::fs::write(&path, artifact.to_bytes())
.with_context(|| format!("writing {}", path.display()))?;
println!("{} -> {}", artifact.name, path.display());
}
Ok(())
}
fn artifact_path(dir: &Path, artifact: &Artifact) -> PathBuf {
dir.join(format!("{}.{}", artifact.name, artifact.extension()))
}
pub struct TestConfig {
pub cassette_dir: PathBuf,
pub filter: Option<String>,
pub pricing: ingot_runtime::price::Pricing,
}
pub fn test(compilation: &Compilation, config: &TestConfig) -> Result<u8> {
if !config.cassette_dir.is_dir() {
eprintln!(
"no cassettes in {} — nothing to test",
config.cassette_dir.display()
);
eprintln!(
"record one with: ingot run --provider anthropic --record {}/<name>.json --input ...",
config.cassette_dir.display()
);
return Ok(super::EXIT_OK);
}
let cassettes =
ingot_runtime::load_directory(&config.cassette_dir).map_err(anyhow::Error::msg)?;
if cassettes.is_empty() {
eprintln!("no cassettes in {}", config.cassette_dir.display());
return Ok(super::EXIT_OK);
}
let registry: AgentRegistry = compilation
.agents
.iter()
.map(|agent| (agent.agent.clone(), agent.clone()))
.collect();
let mut passed = 0usize;
let mut failed = 0usize;
for (name, cassette) in cassettes {
if let Some(filter) = &config.filter {
if !name.contains(filter.as_str()) {
continue;
}
}
let Some(ir) = registry.get(&cassette.agent) else {
eprintln!(
"FAIL {name}: the cassette targets `{}`, which this program does not declare",
cassette.agent
);
failed += 1;
continue;
};
let inputs = cassette.inputs.clone();
let recorded_tools = cassette.tool_calls.clone();
let mut provider = ReplayProvider::new(cassette);
let mut tools = ReplayTools::new(recorded_tools);
let mut sink = ingot_runtime::CollectingSink::default();
let result = run_agent(
ir,
®istry,
&mut provider,
&mut tools,
&mut sink,
RunOptions {
inputs,
approval: ApprovalMode::Deny,
max_steps: 1_000,
memory: std::collections::BTreeMap::new(),
stop_at: None,
resume: None,
pricing: config.pricing.clone(),
},
);
match result {
Ok(report) => {
let unused = provider.remaining();
let unused_tools = tools.remaining();
if unused > 0 || unused_tools > 0 {
if unused > 0 {
eprintln!(
"FAIL {name}: {unused} recorded interaction(s) were never played"
);
}
if unused_tools > 0 {
eprintln!(
"FAIL {name}: {unused_tools} recorded tool call(s) were never played"
);
}
failed += 1;
} else {
let cost = match report.spend.rendered() {
Some(rendered) => format!(", {rendered}"),
None => String::new(),
};
for (model, reason) in report.spend.unpriced() {
eprintln!(" {name}: cost not charged for `{model}`: {reason}");
}
println!(
"ok {name} ({} step(s), {} token(s){cost})",
report.steps,
report.usage.total()
);
passed += 1;
}
}
Err(error) => {
eprintln!("FAIL {name}: {error}");
failed += 1;
}
}
}
if failed == 0 {
println!("{passed} passed");
Ok(super::EXIT_OK)
} else {
eprintln!("{passed} passed, {failed} failed");
Ok(super::EXIT_DIAGNOSTICS)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inputs_accept_bare_strings() {
let inputs = parse_inputs(&["topic=compilers".to_string()]).unwrap();
assert_eq!(inputs["topic"], Value::String("compilers".into()));
}
#[test]
fn inputs_accept_json() {
let inputs = parse_inputs(&["items=[\"a\",\"b\"]".to_string(), "n=3".to_string()]).unwrap();
assert_eq!(inputs["items"], serde_json::json!(["a", "b"]));
assert_eq!(inputs["n"], serde_json::json!(3));
}
#[test]
fn a_value_containing_equals_is_not_split_twice() {
let inputs = parse_inputs(&["q=a=b".to_string()]).unwrap();
assert_eq!(inputs["q"], Value::String("a=b".into()));
}
#[test]
fn a_malformed_input_is_reported() {
let error = parse_inputs(&["nonsense".to_string()]).unwrap_err();
assert!(error.to_string().contains("name=value"), "{error}");
}
#[test]
fn artifact_paths_use_the_content_type_extension() {
let artifact = Artifact {
name: "report".into(),
content_type: "markdown".into(),
value: Value::String("x".into()),
};
let path = artifact_path(Path::new("out"), &artifact);
assert!(path.ends_with("report.md"), "{}", path.display());
}
}