use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use clap::{Args, Parser, Subcommand, ValueEnum};
use ingot_compiler::{compile_path, compile_source, format_source, Compilation};
use ingot_diagnostics::{codes, ColorChoice as RenderColor};
mod authoring;
mod conform;
mod contained;
mod dev;
mod diff;
mod doctor;
mod image;
mod launch;
mod manifest;
mod memory;
mod package;
mod run;
mod runs;
mod sandbox;
mod studio;
mod tools;
mod trace;
use manifest::{resolve_target, Manifest, Target, MANIFEST_NAME};
use run::{EventFormat, ProviderChoice, RunConfig, TestConfig};
use sandbox::SandboxConfig;
pub(crate) const EXIT_OK: u8 = 0;
pub(crate) const EXIT_DIAGNOSTICS: u8 = 1;
pub(crate) const EXIT_FAILURE: u8 = 2;
#[derive(Parser, Debug)]
#[command(
name = "ingot",
version,
about = "Compile Ingot agent sources to portable Agent IR",
long_about = "Ingot compiles a statically typed agent language to a target-neutral \
Agent IR. Types, effects, policy and budgets are checked before an \
agent ever runs."
)]
struct Cli {
#[command(subcommand)]
command: Command,
#[arg(long, value_enum, default_value_t = ColorMode::Auto, global = true)]
color: ColorMode,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ColorMode {
Auto,
Always,
Never,
}
impl ColorMode {
fn resolve(self) -> RenderColor {
match self {
ColorMode::Always => RenderColor::Always,
ColorMode::Never => RenderColor::Never,
ColorMode::Auto => {
if std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none() {
RenderColor::Always
} else {
RenderColor::Never
}
}
}
}
}
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand, Debug)]
enum Command {
Init(InitArgs),
New(NewArgs),
Check(PathArgs),
Fmt(FmtArgs),
Build(BuildArgs),
Package(PackageArgs),
Ir(IrArgs),
Run(RunArgs),
Test(TestArgs),
Doctor(DoctorArgs),
Image(ImageArgs),
Dev(DevArgs),
Studio(StudioArgs),
Conform(ConformArgs),
Tools(ToolsArgs),
Sandbox(SandboxArgs),
Explain(ExplainArgs),
#[command(hide = true)]
Exec,
#[command(hide = true)]
Egress(EgressArgs),
}
#[derive(Args, Debug)]
struct EgressArgs {
#[arg(long = "allow", value_name = "HOST")]
allow: Vec<String>,
#[arg(long, default_value = "127.0.0.1:0", value_name = "ADDR")]
bind: String,
}
#[derive(Args, Debug)]
struct InitArgs {
name: PathBuf,
#[arg(long, value_enum, default_value_t = StarterTemplate::Brief)]
template: StarterTemplate,
}
#[derive(Args, Debug)]
struct NewArgs {
workflow: Vec<String>,
#[arg(long, value_name = "DIR", conflicts_with_all = ["previous", "project"])]
out_dir: Option<PathBuf>,
#[arg(long, value_enum, conflicts_with_all = ["previous", "project", "provider"])]
template: Option<StarterTemplate>,
#[arg(long, value_name = "DIR", conflicts_with = "previous")]
project: Option<PathBuf>,
#[arg(long, requires = "project")]
apply: bool,
#[arg(long, value_name = "PATH", requires = "candidate")]
previous: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "previous")]
candidate: Option<PathBuf>,
#[arg(long = "repair-candidate", value_name = "PATH", requires = "candidate")]
repair_candidates: Vec<PathBuf>,
#[arg(long, value_name = "N", default_value_t = 2)]
max_repairs: usize,
#[arg(long, value_enum, conflicts_with = "previous")]
provider: Option<ProviderChoice>,
#[arg(long, value_name = "FILE", requires = "provider")]
cassette: Option<PathBuf>,
#[arg(long, value_name = "FILE", requires = "provider")]
record: Option<PathBuf>,
#[arg(long, value_name = "MODEL", requires = "provider")]
model: Option<String>,
#[arg(long, value_name = "LEVEL", requires = "provider")]
effort: Option<String>,
#[arg(long)]
accept_policy: bool,
}
#[derive(Args, Debug)]
struct ImageArgs {
#[command(subcommand)]
command: ImageCommand,
}
#[derive(Subcommand, Debug)]
enum ImageCommand {
Build(ImageBuildArgs),
}
#[derive(Args, Debug)]
struct ImageBuildArgs {
#[arg(value_name = "SOURCE")]
source: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum StarterTemplate {
Brief,
DocumentWorkflow,
}
#[derive(Args, Debug)]
struct PathArgs {
path: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct ToolsArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long)]
json: bool,
#[arg(long)]
propose: bool,
}
#[derive(Args, Debug)]
struct FmtArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long)]
check: bool,
}
#[derive(Args, Debug)]
struct BuildArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long, value_name = "DIR")]
out_dir: Option<PathBuf>,
#[arg(long = "target", value_enum, default_value_t = BuildTarget::Ir)]
backend: BuildTarget,
#[arg(long)]
allow_unimplemented: bool,
#[arg(long)]
json: bool,
#[arg(long, value_name = "FILE")]
from_ir: Option<PathBuf>,
}
#[derive(Args, Debug)]
struct PackageArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long, value_name = "DIR")]
out_dir: Option<PathBuf>,
#[arg(long = "report", value_enum, value_name = "TARGET")]
reports: Vec<ReportTarget>,
#[arg(long)]
verify: bool,
#[arg(long)]
json: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum ReportTarget {
Python,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
enum BuildTarget {
Ir,
Python,
}
#[derive(Args, Debug)]
struct IrArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long, value_name = "NAME")]
agent: Option<String>,
}
#[derive(Args, Debug)]
struct RunArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long = "input", short = 'i', value_name = "NAME=VALUE")]
inputs: Vec<String>,
#[arg(long, value_enum, default_value_t = ProviderChoice::Auto)]
provider: ProviderChoice,
#[arg(long, value_name = "FILE")]
cassette: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
record: Option<PathBuf>,
#[arg(long)]
no_history: bool,
#[arg(long, value_name = "FILE", conflicts_with = "no_memory")]
memory: Option<PathBuf>,
#[arg(long, conflicts_with = "migrate_memory")]
no_memory: bool,
#[arg(long)]
migrate_memory: bool,
#[arg(long, value_name = "LABEL", conflicts_with = "resume")]
stop_at: Option<String>,
#[arg(long, value_name = "FILE", requires = "stop_at")]
snapshot: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
resume: Option<PathBuf>,
#[arg(long, value_name = "MODEL")]
model: Option<String>,
#[arg(long, value_name = "LEVEL")]
effort: Option<String>,
#[arg(long, value_name = "NAME")]
agent: Option<String>,
#[arg(long, value_name = "DIR")]
out_dir: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = EventFormat::Text)]
events: EventFormat,
#[arg(long)]
yes: bool,
#[arg(long)]
no_tools: bool,
#[arg(long, conflicts_with_all = ["contained", "supervised"])]
sandbox: bool,
#[arg(long)]
contained: bool,
#[arg(long, value_name = "IMAGE")]
image: Option<String>,
#[arg(long, hide = true, conflicts_with = "contained")]
supervised: bool,
#[arg(long)]
sandbox_allow_unenforced: bool,
#[arg(long)]
allow_unenforced_scopes: bool,
#[arg(long, value_name = "DIR")]
workspace: Option<PathBuf>,
#[arg(long, default_value_t = 1000, value_name = "N")]
max_steps: u32,
#[arg(long, value_name = "SECONDS")]
timeout: Option<u64>,
}
#[derive(Args, Debug)]
struct TestArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long, value_name = "DIR", default_value = run::CASSETTE_DIR)]
cassettes: PathBuf,
#[arg(value_name = "FILTER")]
filter: Option<String>,
}
#[derive(Args, Debug)]
struct DoctorArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long)]
json: bool,
}
#[derive(Args, Debug)]
struct DevArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long)]
run: bool,
#[arg(long)]
allow_unenforced_scopes: bool,
#[arg(
long = "input",
short = 'i',
value_name = "NAME=VALUE",
requires = "run"
)]
inputs: Vec<String>,
#[arg(long, value_enum, default_value_t = ProviderChoice::Auto)]
provider: ProviderChoice,
#[arg(long, value_name = "FILE", requires = "run")]
cassette: Option<PathBuf>,
#[arg(long, value_name = "NAME", requires = "run")]
agent: Option<String>,
#[arg(long, value_enum, default_value_t = EventFormat::Quiet)]
events: EventFormat,
#[arg(long, requires = "run")]
yes: bool,
#[arg(long, default_value_t = 1000, value_name = "N")]
max_steps: u32,
}
#[derive(Args, Debug)]
struct SandboxArgs {
#[command(flatten)]
target: PathArgs,
#[arg(long, value_name = "DIR")]
workspace: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Args, Debug)]
struct ExplainArgs {
code: String,
}
#[derive(Args, Debug)]
struct StudioArgs {
#[arg(long, value_name = "ADDR")]
bind: Option<String>,
}
#[derive(Args, Debug)]
struct ConformArgs {
#[arg(long, value_name = "COMMAND")]
backend: Option<String>,
#[arg(long, value_name = "NAME")]
case: Option<String>,
#[arg(long, value_name = "DIR")]
suite: Option<PathBuf>,
#[arg(long, value_name = "DIR")]
export: Option<PathBuf>,
#[arg(long)]
list: bool,
#[arg(long, value_name = "FILE")]
adapter: Option<PathBuf>,
#[arg(long)]
json: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
let color = cli.color.resolve();
let result = match &cli.command {
Command::Init(args) => run_init(args),
Command::New(args) => run_new(args, color),
Command::Check(args) => run_check(args, color),
Command::Fmt(args) => run_fmt(args, color),
Command::Build(args) => run_build(args, color),
Command::Package(args) => run_package(args, color),
Command::Ir(args) => run_ir(args, color),
Command::Run(args) => run_run(args, color),
Command::Test(args) => run_test(args, color),
Command::Doctor(args) => run_doctor(args, color),
Command::Image(args) => run_image(args),
Command::Dev(args) => run_dev(args, color),
Command::Studio(args) => studio::serve(&studio::StudioConfig {
bind: args.bind.clone(),
}),
Command::Conform(args) => run_conform(args),
Command::Tools(args) => run_tools(args, color),
Command::Sandbox(args) => run_sandbox(args, color),
Command::Explain(args) => run_explain(args),
Command::Egress(args) => run_egress(args),
Command::Exec => contained::exec(),
};
match result {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("error: {error:#}");
ExitCode::from(EXIT_FAILURE)
}
}
}
fn run_init(args: &InitArgs) -> Result<u8> {
let dir = &args.name;
let name = project_name_for_dir(dir);
create_starter_project(dir, &name, args.template, args.template.description())?;
println!(
"Created agent project `{name}` from template `{}` in {}",
args.template.as_str(),
dir.display()
);
println!();
println!("Next steps — none of these need an API key:");
if dir != Path::new(".") {
println!(" cd {}", dir.display());
}
println!(" ingot check # types, effects, policy, budgets");
println!(" ingot test # replay the recorded fixture");
println!(" ingot studio # the whole project, in one page");
println!();
println!("Then run it, and read what it made:");
println!(" {}", args.template.replay_command());
Ok(EXIT_OK)
}
const AUTHORING_AGENT: &str = "ingot.authoring";
fn run_new(args: &NewArgs, color: RenderColor) -> Result<u8> {
if args.previous.is_some() {
return review_candidate_files(args, color);
}
if let Some(project) = &args.project {
return propose_into_project(args, project, color);
}
if !args.repair_candidates.is_empty() {
bail!("--repair-candidate requires --previous and --candidate");
}
create_from_workflow(args, color)
}
fn workflow_words(args: &NewArgs) -> Result<String> {
let workflow = args.workflow.join(" ");
if workflow.trim().is_empty() {
bail!(
"describe the workflow to author, or pass --previous and --candidate to review a \
proposal"
);
}
if let Some(finding) = ingot_package::secrets::scan(&workflow) {
bail!(
"the workflow description contains {} and was not sent anywhere\n \
remove it and describe the credential by name instead; a value in a workflow \
would reach the prompt, the manifest and this terminal's history",
finding.shape
);
}
Ok(workflow)
}
fn review_candidate_files(args: &NewArgs, color: RenderColor) -> Result<u8> {
let previous = args.previous.as_ref().expect("checked by the caller");
let candidate = args
.candidate
.as_ref()
.expect("clap requires --candidate with --previous");
let previous_source = std::fs::read_to_string(previous)
.with_context(|| format!("reading {}", previous.display()))?;
let candidate_source = std::fs::read_to_string(candidate)
.with_context(|| format!("reading {}", candidate.display()))?;
let repair_sources = args
.repair_candidates
.iter()
.map(|path| {
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))
})
.collect::<Result<Vec<_>>>()?;
let mut candidates = authoring::FixedCandidates::new(&candidate_source, &repair_sources);
let repair = authoring::author(
&previous_source,
&mut candidates,
&authoring::ToolContext::Unchecked,
limits(args),
)?;
if let Some(code) = report_authoring(&repair, 0, color) {
return Ok(code);
}
let source = repair
.accepted_source()
.expect("a compiled loop has source");
println!("candidate source contains no new policy proposal");
println!(
"compiler-verified authoring completed after {} attempt(s)",
repair.attempts().len()
);
print_authoring_attempts(&repair);
print_proposed_diff(&previous.display().to_string(), &previous_source, source);
Ok(EXIT_OK)
}
fn propose_into_project(args: &NewArgs, project: &Path, color: RenderColor) -> Result<u8> {
if args.provider.is_none() {
bail!(
"--project needs --provider: a maintained template can start a project, but only a \
model can propose a change to one that already exists\n \
use `--provider auto` with a key exported, or `--provider replay --cassette <FILE>`"
);
}
let workflow = workflow_words(args)?;
let target = resolve_target(Some(project))?;
let previous_source = std::fs::read_to_string(&target.entry)
.with_context(|| format!("reading {}", target.entry.display()))?;
let mcp = target.mcp();
let tools = if mcp.is_empty() {
authoring::ToolContext::NoServers
} else {
authoring::ToolContext::Routed(tools::routed(&tools::ToolsConfig {
root: target.root.clone(),
mcp,
})?)
};
let package = target
.manifest
.as_ref()
.and_then(|manifest| package_name(&manifest.project.name));
let session = author_with_model(args, &workflow, &previous_source, package, &tools)?;
if let Some(code) = report_authoring(&session.repair, session.calls, color) {
return Ok(code);
}
let source = session
.repair
.accepted_source()
.expect("a compiled loop has source");
let entry = target.entry.display().to_string();
if !print_proposed_diff(&entry, &previous_source, source) {
println!("the proposal makes no change to {entry}");
return Ok(EXIT_OK);
}
if !args.apply {
println!();
println!("nothing was written; re-run with --apply to write this to {entry}");
return Ok(EXIT_OK);
}
std::fs::write(&target.entry, source).with_context(|| format!("writing {entry}"))?;
println!();
println!("wrote {entry}");
println!("check the result and re-record any cassette the change invalidates:");
println!(" ingot check");
println!(" ingot test");
Ok(EXIT_OK)
}
fn create_from_workflow(args: &NewArgs, color: RenderColor) -> Result<u8> {
let workflow = workflow_words(args)?;
let dir = args
.out_dir
.clone()
.unwrap_or_else(|| PathBuf::from(project_slug(&workflow)));
let name = project_name_for_dir(&dir);
let description = format!("Authored from workflow: {workflow}");
let Some(_) = args.provider else {
let template = args
.template
.unwrap_or_else(|| StarterTemplate::for_workflow(&workflow));
create_starter_project(&dir, &name, template, &description)?;
println!(
"Created compiler-verified agent project `{name}` from workflow in {}",
dir.display()
);
println!("Workflow: {workflow}");
println!("Template: {}", template.as_str());
println!();
println!("Next steps:");
if dir != Path::new(".") {
println!(" cd {}", dir.display());
}
println!(" ingot check");
println!(" ingot build");
println!(" ingot test");
return Ok(EXIT_OK);
};
if dir.join(MANIFEST_NAME).exists() {
bail!("{} already contains an {MANIFEST_NAME}", dir.display());
}
let tools = authoring::ToolContext::NoServers;
let session = author_with_model(args, &workflow, "", package_name(&name), &tools)?;
if let Some(code) = report_authoring(&session.repair, session.calls, color) {
return Ok(code);
}
let source = session
.repair
.accepted_source()
.expect("a compiled loop has source");
let compilation = compile_source("main.ing", source);
let inputs = compilation
.agents
.first()
.map(|agent| agent.inputs.clone())
.unwrap_or_default();
let mut manifest = Manifest::new(&name);
manifest.project.description = Some(description);
std::fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
write_new(&dir.join(MANIFEST_NAME), &manifest.to_toml())?;
write_new(&dir.join("main.ing"), source)?;
write_new(&dir.join(".gitignore"), "/target\n")?;
for (input, ty) in &inputs {
if let Some(path) = example_input_path(input, ty) {
write_new(&dir.join(&path), &example_input(input, &workflow))?;
}
}
write_new(
&dir.join("README.md"),
&authored_readme(&name, &workflow, &inputs),
)?;
println!(
"Created compiler-verified agent project `{name}` from workflow in {}",
dir.display()
);
println!("Workflow: {workflow}");
println!("Authored by: {}", provider_label(args));
println!();
println!("Next steps:");
if dir != Path::new(".") {
println!(" cd {}", dir.display());
}
println!(" ingot check");
println!(" ingot build");
println!(" ingot test");
println!();
println!(
"`ingot test` has no cassette to replay yet, and one is not invented: a recorded \
answer nothing produced would be a test that proves nothing."
);
println!("Record the offline test once, against a configured provider:");
println!(" {}", record_command(&inputs));
Ok(EXIT_OK)
}
fn run_egress(args: &EgressArgs) -> Result<u8> {
let bind: std::net::SocketAddr = args
.bind
.parse()
.with_context(|| format!("`--bind {}` is not an address:port", args.bind))?;
let allow = ingot_egress::Allowlist::new(&args.allow);
if allow.is_empty() {
eprintln!("egress: no hosts allowed; every request will be refused");
} else {
eprintln!("egress: allowing {}", allow.hosts().join(", "));
}
let proxy = ingot_egress::Proxy::start(bind, allow, |decision| eprintln!("egress: {decision}"))
.context("starting the egress proxy")?;
println!("{}", proxy.address());
loop {
std::thread::park();
}
}
fn provider_label(args: &NewArgs) -> &'static str {
match args.provider {
Some(ProviderChoice::Replay) => "a replayed authoring cassette",
Some(_) => "a model, verified by the compiler",
None => "a maintained template",
}
}
struct AuthoringSession {
repair: authoring::RepairLoop,
calls: usize,
}
fn limits(args: &NewArgs) -> authoring::Limits {
authoring::Limits {
max_repairs: args.max_repairs,
accept_policy: args.accept_policy,
}
}
fn author_with_model(
args: &NewArgs,
workflow: &str,
previous_source: &str,
package: Option<String>,
tools: &authoring::ToolContext,
) -> Result<AuthoringSession> {
let selection = run::ProviderSelection {
replay_from: 0,
choice: args.provider.expect("checked by the caller"),
cassette: args.cassette.clone(),
model: args.model.clone(),
effort: args.effort.clone(),
models: ingot_runtime::ModelConfig::default(),
strict_replay: false,
};
let mut provider = run::Provider::new(
run::build_model_provider(&selection)?,
args.record.is_some(),
AUTHORING_AGENT,
);
let (repair, calls) = {
let request = authoring::AuthoringRequest {
workflow: workflow.to_string(),
previous_source: previous_source.to_string(),
package,
};
let mut model = authoring::ModelAuthor::new(provider.as_mut(), request, tools);
let repair = authoring::author(previous_source, &mut model, tools, limits(args));
(repair, model.calls())
};
if let Some(path) = &args.record {
if let Some(cassette) = provider.finish_recording() {
cassette.save(path).map_err(anyhow::Error::msg)?;
eprintln!("recorded the authoring session to {}", path.display());
}
}
Ok(AuthoringSession {
repair: repair?,
calls,
})
}
fn report_authoring(
repair: &authoring::RepairLoop,
calls: usize,
color: RenderColor,
) -> Option<u8> {
if calls > 0 {
let usage = repair.usage();
eprintln!(
"authoring made {calls} model call(s), using {} input and {} output token(s)",
usage.input_tokens, usage.output_tokens
);
}
for proposal in repair.accepted_proposals() {
println!(
"accepted policy grant: agent {}: {} {}",
proposal.agent, proposal.subject, proposal.action
);
}
match repair.outcome() {
authoring::RepairOutcome::Compiled { .. } => None,
authoring::RepairOutcome::PolicyProposals { proposals } => {
println!("candidate source requests policy changes");
println!("these are not part of automatic compiler repair:");
for proposal in proposals {
println!(
" agent {}: {} {}",
proposal.agent, proposal.subject, proposal.action
);
}
println!();
println!("review and accept policy changes explicitly before continuing");
println!("re-run with --accept-policy to accept exactly these grants");
Some(EXIT_DIAGNOSTICS)
}
authoring::RepairOutcome::RetryCeilingReached => {
print_authoring_attempts(repair);
println!(
"compiler repair reached retry ceiling after {} attempt(s)",
repair.attempts().len()
);
if let Some(last) = repair.attempts().last() {
println!("last source:");
println!("{}", last.source);
let compilation = compile_source("candidate.ing", &last.source);
eprint!("{}", compilation.render_diagnostics(color));
}
Some(EXIT_DIAGNOSTICS)
}
authoring::RepairOutcome::CredentialRefused { finding } => {
println!(
"the proposed source contains {} on line {}",
finding.shape, finding.line
);
println!("nothing was written, and the source was not sent back to the model");
println!(
"a credential belongs in the environment, named by `pass-env` in {MANIFEST_NAME}, \
never in source"
);
Some(EXIT_DIAGNOSTICS)
}
}
}
fn print_authoring_attempts(repair: &authoring::RepairLoop) {
for attempt in repair.attempts() {
if attempt.has_errors() {
println!("attempt {} failed compiler verification", attempt.number);
for diagnostic in &attempt.diagnostics {
println!(" {}: {}", diagnostic.code, diagnostic.message);
}
} else {
println!("attempt {} passed compiler verification", attempt.number);
}
}
}
fn print_proposed_diff(label: &str, previous: &str, proposed: &str) -> bool {
match diff::unified(label, previous, "proposed", proposed, diff::CONTEXT) {
Some(rendered) => {
print!("{rendered}");
true
}
None => false,
}
}
fn run_image(args: &ImageArgs) -> Result<u8> {
match &args.command {
ImageCommand::Build(args) => image::build(args.source.as_deref()),
}
}
fn create_starter_project(
dir: &Path,
name: &str,
template: StarterTemplate,
description: &str,
) -> Result<()> {
if dir.join(MANIFEST_NAME).exists() {
bail!("{} already contains an {MANIFEST_NAME}", dir.display());
}
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let mut manifest = Manifest::new(name);
manifest.project.description = Some(description.to_string());
write_new(&dir.join(MANIFEST_NAME), &manifest.to_toml())?;
write_new(&dir.join("main.ing"), &starter_source(name, template))?;
write_new(&dir.join(".gitignore"), "/target\n")?;
write_new(&dir.join("README.md"), &starter_readme(name, template))?;
write_new(
&dir.join("tests/cassettes/example.json"),
&starter_cassette(name, template).to_canonical_json(),
)?;
if let Some((path, contents)) = template.example_file() {
write_new(&dir.join(path), contents)?;
}
Ok(())
}
fn write_new(path: &Path, contents: &str) -> Result<()> {
if path.exists() {
bail!("{} already exists", path.display());
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating {}", parent.display()))?;
}
std::fs::write(path, contents).with_context(|| format!("writing {}", path.display()))
}
const EXAMPLE_DOCUMENT: &str = "Ingot compiles a typed agent language to portable Agent IR. \
The same checked artifact can run through independent backends. Policies and \
budgets travel with the artifact so each backend can enforce them.\n";
impl StarterTemplate {
fn as_str(self) -> &'static str {
match self {
StarterTemplate::Brief => "brief",
StarterTemplate::DocumentWorkflow => "document-workflow",
}
}
fn replay_command(self) -> &'static str {
match self {
StarterTemplate::Brief => {
"ingot run --provider replay --input topic=\"compiler design\""
}
StarterTemplate::DocumentWorkflow => {
"ingot run --provider replay --input document=@examples/document.txt --input audience=\"project leads\""
}
}
}
fn description(self) -> &'static str {
match self {
StarterTemplate::Brief => "A small typed agent that turns a topic into a brief.",
StarterTemplate::DocumentWorkflow => {
"A document transformation workflow with two typed inputs."
}
}
}
fn agent(self) -> &'static str {
match self {
StarterTemplate::Brief => "Brief",
StarterTemplate::DocumentWorkflow => "DocumentWorkflow",
}
}
fn example_file(self) -> Option<(&'static str, &'static str)> {
match self {
StarterTemplate::Brief => None,
StarterTemplate::DocumentWorkflow => Some(("examples/document.txt", EXAMPLE_DOCUMENT)),
}
}
fn for_workflow(workflow: &str) -> StarterTemplate {
let lowered = workflow.to_ascii_lowercase();
if [
"document",
"documents",
"doc",
"docs",
"file",
"files",
"audience",
"summarise",
"summarize",
]
.iter()
.any(|needle| lowered.contains(needle))
{
StarterTemplate::DocumentWorkflow
} else {
StarterTemplate::Brief
}
}
}
pub(crate) fn project_name_for_dir(dir: &Path) -> String {
dir.file_name()
.map(|name| name.to_string_lossy().to_string())
.filter(|name| name != ".")
.or_else(|| {
std::env::current_dir()
.ok()
.and_then(|dir| dir.file_name().map(|n| n.to_string_lossy().to_string()))
})
.unwrap_or_else(|| "agent".to_string())
}
fn project_slug(workflow: &str) -> String {
let words: Vec<String> = workflow
.split(|ch: char| !ch.is_ascii_alphanumeric())
.filter(|word| !word.is_empty())
.take(5)
.map(|word| word.to_ascii_lowercase())
.collect();
if words.is_empty() {
"authored-agent".to_string()
} else {
words.join("-")
}
}
fn package_name(name: &str) -> Option<String> {
let sanitised: String = name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
let sanitised = sanitised.trim_matches('_').to_string();
if sanitised.is_empty() {
return None;
}
if sanitised.starts_with(|ch: char| ch.is_ascii_digit()) {
return None;
}
if ingot_lexer::KEYWORDS.contains(&sanitised.as_str()) {
return None;
}
Some(sanitised)
}
fn starter_source(name: &str, template: StarterTemplate) -> String {
let package = match package_name(name) {
Some(package) => format!(
"package {package}
"
),
None => String::new(),
};
match template {
StarterTemplate::Brief => format!(
r#"language 0.1
{package}
/// Summarises a topic into a short markdown brief.
agent Brief(topic: string) -> brief<markdown> {{
model requires {{
structured_output
}}
budget {{
steps <= 4
tokens <= 20000
}}
policy {{
network deny
}}
flow {{
emit brief = ask<markdown>(
"Write a short, factual brief about ${{topic}}. Use headings and bullet points."
)
}}
}}
"#
),
StarterTemplate::DocumentWorkflow => format!(
r#"language 0.1
{package}
/// Rewrites a document for a named audience without changing its facts.
agent DocumentWorkflow(document: text, audience: string) -> summary<markdown> {{
model requires {{
structured_output
}}
budget {{
steps <= 4
tokens <= 20000
}}
policy {{
network deny
}}
flow {{
emit summary = ask<markdown>(
"Summarise the following document for ${{audience}}. Preserve the important facts.\n\n${{document}}"
)
}}
}}
"#
),
}
}
fn starter_readme(name: &str, template: StarterTemplate) -> String {
let replay = template.replay_command();
let dev_replay = replay.replacen("ingot run", "ingot dev --run", 1);
format!(
r#"# {name}
An agent written in Ingot from the `{}` template. `main.ing` is the source of
truth: the template, compiler, test and runtime do not hide another workflow
representation behind it.
## First run
These commands work without a model API key:
```bash
ingot check
ingot build
ingot test
{replay}
```
`ingot test` replays the reviewed fixture in `tests/cassettes/`. The final
command runs that same fixture and prints the artifact. Change `main.ing`, then
record a new cassette against a configured provider before accepting its diff.
## Develop
Keep `check` and the canonical IR build current while editing:
```bash
ingot dev
```
Running is opt-in, so a save does not silently call a model. This command runs
each successful revision against the checked-in cassette and example inputs:
```bash
{dev_replay}
```
`ingot build` writes `target/ingot/{}.ir.json`. Agent IR is the canonical,
target-neutral artifact consumed by every backend.
"#,
template.as_str(),
template.agent()
)
}
fn example_input_path(name: &str, ty: &str) -> Option<PathBuf> {
matches!(ty, "text" | "markdown").then(|| PathBuf::from(format!("examples/{name}.txt")))
}
fn example_input(name: &str, workflow: &str) -> String {
format!(
"Example `{name}` for: {workflow}\n\n\
Replace this with real content. It exists so the first run has something \
to read, and so the recorded cassette is made against a value you chose.\n"
)
}
fn input_flag(name: &str, ty: &str) -> String {
let value = match ty {
"text" | "markdown" => format!("@examples/{name}.txt"),
"string" => "\"...\"".to_string(),
"int" => "0".to_string(),
"float" => "0.0".to_string(),
"bool" => "true".to_string(),
ty if ty.ends_with("[]") => "[]".to_string(),
_ => "{}".to_string(),
};
format!("--input {name}={value}")
}
fn input_flags(inputs: &std::collections::BTreeMap<String, String>) -> String {
inputs
.iter()
.map(|(name, ty)| input_flag(name, ty))
.collect::<Vec<_>>()
.join(" ")
}
fn record_command(inputs: &std::collections::BTreeMap<String, String>) -> String {
let flags = input_flags(inputs);
let separator = if flags.is_empty() { "" } else { " " };
format!("ingot run --record tests/cassettes/example.json{separator}{flags}")
}
fn authored_readme(
name: &str,
workflow: &str,
inputs: &std::collections::BTreeMap<String, String>,
) -> String {
let record = record_command(inputs);
let replay = {
let flags = input_flags(inputs);
let separator = if flags.is_empty() { "" } else { " " };
format!(
"ingot run --provider replay --cassette tests/cassettes/example.json{separator}{flags}"
)
};
format!(
r#"# {name}
An agent written in Ingot, authored from this workflow:
> {workflow}
`main.ing` is the source of truth. The authoring model wrote it once and has no
further part in this project: the compiler, tests and runtime never call it, and
every command below works without one.
## First run
These commands need no model API key:
```bash
ingot check
ingot build
```
`ingot build` writes the canonical, target-neutral Agent IR under
`target/ingot/`.
## The offline test
There is no cassette yet, and one was not invented for you: a recorded answer
that no model produced would be a test that proves nothing. Record one against a
configured provider, review the answer it captured, and commit it:
```bash
{record}
```
After that, the project replays with no key and no network:
```bash
ingot test
{replay}
```
## Develop
```bash
ingot dev
```
Keeps `check` and the IR build current while you edit. Running is opt-in, so
saving a prompt never silently calls a model. When you change a prompt, the
recorded cassette stops matching on purpose — re-record it and review the diff.
## Changing it with the model again
```bash
ingot new --project . --provider auto "what you want changed"
```
That prints a diff and writes nothing until you pass `--apply`.
"#
)
}
fn starter_cassette(name: &str, template: StarterTemplate) -> ingot_runtime::Cassette {
use std::collections::BTreeMap;
use ingot_runtime::{
schema::ResponseShape, CompletionRequest, Interaction, ModelSelection, Usage,
};
use serde_json::json;
let (inputs, prompt, value) = match template {
StarterTemplate::Brief => {
let inputs: BTreeMap<String, serde_json::Value> =
[("topic".to_string(), json!("compiler design"))].into();
(
inputs,
"Write a short, factual brief about compiler design. Use headings and bullet points."
.to_string(),
json!("# Compiler design\n\n- A front end understands source.\n- An intermediate representation connects analysis to execution.\n- Backends let one checked program reach more than one target."),
)
}
StarterTemplate::DocumentWorkflow => {
let inputs: BTreeMap<String, serde_json::Value> = [
("audience".to_string(), json!("project leads")),
("document".to_string(), json!(EXAMPLE_DOCUMENT)),
]
.into();
(
inputs,
format!(
"Summarise the following document for project leads. Preserve the important facts.\n\n{EXAMPLE_DOCUMENT}"
),
json!("# Project brief\n\nIngot turns typed agent source into portable Agent IR. Independent backends consume the same checked artifact, including its policy and budget declarations."),
)
}
};
let request = CompletionRequest {
node: "n0".to_string(),
model: ModelSelection::Capabilities {
capabilities: vec!["structured_output".to_string()],
min_context_tokens: None,
},
system: None,
prompt,
context: Vec::new(),
response_type: "markdown".to_string(),
shape: ResponseShape::Prose,
max_tokens: 20_000,
};
let qualified_agent = match package_name(name) {
Some(package) => format!("{package}.{}", template.agent()),
None => template.agent().to_string(),
};
let mut cassette = ingot_runtime::Cassette::new(qualified_agent);
cassette.inputs = inputs;
cassette.interactions.push(Interaction {
index: 0,
node: request.node.clone(),
request_digest: request.digest(),
response_type: request.response_type,
value,
usage: Usage {
input_tokens: 120,
output_tokens: 60,
cache_read_tokens: 0,
},
model: Some("template/replay".to_string()),
});
cassette
}
fn run_check(args: &PathArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if !compilation.has_errors() {
warn_unchargeable_cost(&compilation, &target);
}
Ok(exit_code(&compilation))
}
fn run_fmt(args: &FmtArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let original = std::fs::read_to_string(&target.entry)
.with_context(|| format!("reading {}", target.entry.display()))?;
let name = target.entry.display().to_string();
let result = format_source(name.clone(), original.clone());
if result.diagnostics.has_errors() {
eprint!(
"{}",
ingot_diagnostics::render_all(&result.sources, &result.diagnostics, color)
);
eprintln!("cannot format {name}: fix the syntax errors first");
return Ok(EXIT_DIAGNOSTICS);
}
let Some(formatted) = result.formatted else {
return Ok(EXIT_DIAGNOSTICS);
};
if formatted == original {
if !args.check {
println!("{name} is already formatted");
}
return Ok(EXIT_OK);
}
if args.check {
eprintln!("{name} is not formatted");
eprintln!("run `ingot fmt` to rewrite it");
return Ok(EXIT_DIAGNOSTICS);
}
std::fs::write(&target.entry, &formatted)
.with_context(|| format!("writing {}", target.entry.display()))?;
println!("formatted {name}");
Ok(EXIT_OK)
}
fn run_build(args: &BuildArgs, color: RenderColor) -> Result<u8> {
if (args.json || args.allow_unimplemented) && args.backend == BuildTarget::Ir {
bail!(
"--json and --allow-unimplemented belong to a portability report, and the `ir` \
target has nothing to report: the IR is what every backend reads, so nothing can \
fail to express it\n \
pass --target python"
);
}
if let Some(document) = &args.from_ir {
return build_from_ir(document, args);
}
let mut target = resolve_target(args.target.path.as_deref())?;
if let Some(out_dir) = &args.out_dir {
target.out_dir = out_dir.clone();
}
if !args.json {
if let Some(manifest) = &target.manifest {
println!(
"building {} {}",
manifest.project.name, manifest.project.version
);
}
}
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
if compilation.agents.is_empty() {
println!("nothing to build: the program declares no agent");
return Ok(EXIT_OK);
}
warn_unchargeable_cost(&compilation, &target);
package::scan_project(&compilation, &target)?;
std::fs::create_dir_all(&target.out_dir)
.with_context(|| format!("creating {}", target.out_dir.display()))?;
match args.backend {
BuildTarget::Ir => build_ir(&compilation, &target),
BuildTarget::Python => build_python(&compilation.agents, &target.out_dir, args),
}
}
fn build_from_ir(document: &Path, args: &BuildArgs) -> Result<u8> {
if args.backend != BuildTarget::Python {
bail!(
"--from-ir needs a target to build *to*, and the `ir` target's input and output \
would be the same document\n \
pass --target python"
);
}
if args.target.path.is_some() {
bail!(
"--from-ir names the document to build, so there is no source path to give as \
well\n \
drop one of them: the two would have to agree and nothing checks that they do"
);
}
let text = std::fs::read_to_string(document)
.with_context(|| format!("reading {}", document.display()))?;
let ir = ingot_ir::AgentIr::from_json(&text)
.map_err(|error| anyhow::anyhow!("{}: {error}", document.display()))?;
let out_dir = args
.out_dir
.clone()
.unwrap_or_else(|| PathBuf::from("target").join("ingot"));
std::fs::create_dir_all(&out_dir).with_context(|| format!("creating {}", out_dir.display()))?;
build_python(&[ir], &out_dir, args)
}
pub(crate) fn build_ir(compilation: &Compilation, target: &Target) -> Result<u8> {
for agent in &compilation.agents {
let path = target
.out_dir
.join(format!("{}.ir.json", short_name(agent)));
std::fs::write(&path, agent.to_canonical_json())
.with_context(|| format!("writing {}", path.display()))?;
println!("{} -> {}", agent.agent, path.display());
}
Ok(EXIT_OK)
}
fn build_python(agents: &[ingot_ir::AgentIr], out_dir: &Path, args: &BuildArgs) -> Result<u8> {
use ingot_backend_python as python;
let report = python::analyse(python::TARGET, agents);
if args.json {
let payload = serde_json::json!({
"target": report.target,
"buildable": report.buildable(),
"unimplemented": report.unimplemented(),
"agents": report.agents,
});
println!("{}", serde_json::to_string_pretty(&payload)?);
} else {
eprintln!("{}", report.render());
eprintln!();
}
if !report.buildable() && !args.allow_unimplemented {
let blocked: Vec<&str> = report
.blocked()
.iter()
.map(|agent| agent.agent.as_str())
.collect();
bail!(
"`{}` does not implement everything these agents use: {}\n \
fix the agent, or pass --allow-unimplemented to build one that will not do it",
python::TARGET,
blocked.join(", ")
);
}
for agent in agents {
let source = match python::emit(agent) {
Ok(source) => source,
Err(error) => bail!(
"{} cannot be built for `{}`: {error}",
agent.agent,
python::TARGET
),
};
let path = out_dir.join(format!("{}.{}", short_name(agent), python::EXTENSION));
std::fs::write(&path, &source).with_context(|| format!("writing {}", path.display()))?;
if !args.json {
println!("{} -> {}", agent.agent, path.display());
}
}
Ok(EXIT_OK)
}
fn run_package(args: &PackageArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
if !args.json {
report(&compilation, color);
}
if compilation.has_errors() {
if args.json {
report(&compilation, color);
}
return Ok(EXIT_DIAGNOSTICS);
}
package::scan_project(&compilation, &target)?;
package::run(
&compilation,
&target,
&package::PackageConfig {
out_dir: args.out_dir.clone(),
reports: args.reports.clone(),
verify: args.verify,
json: args.json,
},
)
}
fn short_name(agent: &ingot_ir::AgentIr) -> &str {
agent.agent.rsplit('.').next().unwrap_or(&agent.agent)
}
fn run_ir(args: &IrArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
let agent = match &args.agent {
Some(name) => compilation.agent(name).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 => match compilation.agents.len() {
0 => bail!("the program declares no agent"),
1 => &compilation.agents[0],
_ => {
let available: Vec<&str> = compilation
.agents
.iter()
.map(|agent| agent.agent.as_str())
.collect();
bail!(
"this file declares several agents; pass --agent <name>\navailable: {}",
available.join(", ")
)
}
},
};
let mut stdout = std::io::stdout().lock();
stdout
.write_all(agent.to_canonical_json().as_bytes())
.context("writing IR to standard output")?;
Ok(EXIT_OK)
}
fn run_run(args: &RunArgs, color: RenderColor) -> Result<u8> {
if args.sandbox_allow_unenforced && !args.sandbox && !args.contained {
bail!(
"--sandbox-allow-unenforced only means something with --sandbox or --contained; \
without a boundary there is nothing to leave unenforced"
);
}
if args.image.is_some() && !args.contained {
bail!("--image only applies to --contained; a run on the host has no image");
}
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
warn_unchargeable_cost(&compilation, &target);
let cassette = match (&args.cassette, args.provider) {
(None, ProviderChoice::Replay) => Some(run::project_cassette(&target.root)?),
(chosen, _) => chosen.clone(),
};
run::execute(
&compilation,
&RunConfig {
inputs: args.inputs.clone(),
provider: args.provider,
cassette,
record: args.record.clone(),
model: args.model.clone(),
effort: args.effort.clone(),
agent: args.agent.clone(),
out_dir: args.out_dir.clone(),
history: (!args.no_history).then(|| target.out_dir.clone()),
events: args.events,
build_dir: Some(target.out_dir.clone()),
stop_at: args.stop_at.clone(),
resume: args.resume.clone(),
snapshot: args.snapshot.clone(),
memory: args.memory.clone(),
memory_mode: match (args.no_memory, args.migrate_memory) {
(true, _) => memory::MemoryMode::Disabled,
(_, true) => memory::MemoryMode::Migrate,
_ => memory::MemoryMode::Open,
},
yes: args.yes,
max_steps: args.max_steps,
mcp: target.mcp(),
root: target.root.clone(),
no_tools: args.no_tools,
sandbox: args.sandbox,
sandbox_allow_unenforced: args.sandbox_allow_unenforced,
allow_unenforced_scopes: args.allow_unenforced_scopes,
workspace: workspace(args.workspace.as_deref(), &target)?,
models: target.model(),
contained: args.contained,
supervised: args.supervised,
image: args
.image
.clone()
.or_else(|| target.image())
.or_else(|| args.contained.then(image::reference_image)),
timeout_seconds: args.timeout.or_else(|| target.timeout_seconds()),
},
)
}
pub(crate) fn workspace(flag: Option<&Path>, target: &Target) -> Result<PathBuf> {
let chosen = flag
.map(Path::to_path_buf)
.unwrap_or_else(|| target.workspace());
chosen
.canonicalize()
.with_context(|| format!("resolving the workspace {}", chosen.display()))
}
fn run_tools(args: &ToolsArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
tools::inspect(
&compilation,
&tools::ToolsConfig {
mcp: target.mcp(),
root: target.root.clone(),
},
args.json,
args.propose,
)
}
fn run_test(args: &TestArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
let project_root = target
.entry
.parent()
.unwrap_or(Path::new("."))
.to_path_buf();
let cassette_dir = if args.cassettes.is_absolute() {
args.cassettes.clone()
} else {
project_root.join(&args.cassettes)
};
run::test(
&compilation,
&TestConfig {
cassette_dir,
filter: args.filter.clone(),
pricing: target.model().pricing(),
},
)
}
fn run_doctor(args: &DoctorArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
if !args.json {
report(&compilation, color);
}
doctor::inspect(&target, &compilation, args.json)
}
fn run_conform(args: &ConformArgs) -> Result<u8> {
if let Some(request) = &args.adapter {
return conform::adapt(request);
}
if let Some(dir) = &args.export {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
conform::materialise(dir)?;
println!(
"wrote {} case(s) to {}\n run them with: ingot conform --suite {}",
conform::cases(dir)?.len(),
dir.display(),
dir.display()
);
return Ok(EXIT_OK);
}
let unpacked;
let suite = match &args.suite {
Some(path) => path.clone(),
None => match find_checkout() {
Some(path) => path,
None => {
unpacked = Unpacked::new()?;
conform::materialise(unpacked.path())?
}
},
};
if args.list {
let described = conform::describe(&suite)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&described)?);
} else {
for case in described["cases"].as_array().into_iter().flatten() {
println!(
"{}\n {}",
case["case"].as_str().unwrap_or_default(),
case["what"].as_str().unwrap_or_default()
);
for pin in case["pins"].as_array().into_iter().flatten() {
println!(" pins {}", pin.as_str().unwrap_or_default());
}
}
}
return Ok(EXIT_OK);
}
let backend = match &args.backend {
Some(command) => command.clone(),
None => {
let exe = std::env::current_exe().context("locating this executable")?;
format!("{} conform --adapter", exe.display())
}
};
let work = std::env::temp_dir().join(format!("ingot-conform-{}", std::process::id()));
let report = conform::run(&suite, &backend, args.case.as_deref(), &work)?;
let _ = std::fs::remove_dir_all(&work);
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", report.render());
}
Ok(if report.conformant() {
EXIT_OK
} else {
EXIT_DIAGNOSTICS
})
}
fn find_checkout() -> Option<PathBuf> {
let start = std::env::current_dir().ok()?;
let mut here = start.as_path();
loop {
let candidate = here.join("crates").join("ingot-conformance");
if candidate.join("cases").is_dir() {
return Some(candidate);
}
here = here.parent()?;
}
}
struct Unpacked(PathBuf);
impl Unpacked {
fn new() -> Result<Unpacked> {
let path = std::env::temp_dir().join(format!("ingot-suite-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).with_context(|| format!("creating {}", path.display()))?;
Ok(Unpacked(path))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for Unpacked {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn run_dev(args: &DevArgs, color: RenderColor) -> Result<u8> {
let initial = resolve_target(args.target.path.as_deref())?;
let cassette = match (&args.cassette, args.provider, args.run) {
(None, ProviderChoice::Replay, true) => Some(run::project_cassette(&initial.root)?),
(chosen, _, _) => chosen.clone(),
};
dev::watch(
args.target.path.as_deref(),
initial,
&dev::DevConfig {
run: args.run,
inputs: args.inputs.clone(),
provider: args.provider,
cassette,
agent: args.agent.clone(),
events: args.events,
yes: args.yes,
max_steps: args.max_steps,
allow_unenforced_scopes: args.allow_unenforced_scopes,
color,
},
)
}
fn run_sandbox(args: &SandboxArgs, color: RenderColor) -> Result<u8> {
let target = resolve_target(args.target.path.as_deref())?;
let compilation = compile(&target)?;
report(&compilation, color);
if compilation.has_errors() {
return Ok(EXIT_DIAGNOSTICS);
}
sandbox::inspect(
&compilation,
&SandboxConfig {
workspace: workspace(args.workspace.as_deref(), &target)?,
mcp: target.mcp(),
json: args.json,
},
)
}
fn run_explain(args: &ExplainArgs) -> Result<u8> {
match codes::explain(&args.code) {
Some(text) => {
println!("{}\n", args.code.to_ascii_uppercase());
println!("{text}");
Ok(EXIT_OK)
}
None => {
eprintln!("no long-form explanation for `{}`", args.code);
eprintln!();
eprintln!("explained codes: {}", codes::EXPLAINED_CODES.join(", "));
Ok(EXIT_FAILURE)
}
}
}
pub(crate) fn compile(target: &Target) -> Result<Compilation> {
compile_path(&target.entry).with_context(|| format!("compiling {}", target.entry.display()))
}
pub(crate) fn warn_unchargeable_cost(compilation: &Compilation, target: &Target) {
if !target.model().prices.is_empty() {
return;
}
let budgeted: Vec<&str> = compilation
.agents
.iter()
.filter(|agent| agent.budget.cost.is_some())
.map(|agent| agent.agent.as_str())
.collect();
let Some(first) = budgeted.first() else {
return;
};
eprintln!(
"warning[{}]: `{}` states a cost budget that nothing can charge",
codes::COST_BUDGET_NOT_CHARGED,
first
);
for agent in budgeted.iter().skip(1) {
eprintln!(" so does `{agent}`");
}
eprintln!(" = note: this project configures no `[[model.price]]`, so the budget is");
eprintln!(" reported as uncharged rather than enforced");
eprintln!(
" = help: add a price, or remove the budget; `ingot explain {}`",
codes::COST_BUDGET_NOT_CHARGED
);
}
pub(crate) fn report(compilation: &Compilation, color: RenderColor) {
if !compilation.diagnostics.is_empty() {
eprint!("{}", compilation.render_diagnostics(color));
}
let errors = compilation.error_count();
let warnings = compilation.warning_count();
match (errors, warnings) {
(0, 0) => eprintln!("ok"),
(0, warnings) => eprintln!("ok, {warnings} warning(s)"),
(errors, 0) => eprintln!("failed: {errors} error(s)"),
(errors, warnings) => eprintln!("failed: {errors} error(s), {warnings} warning(s)"),
}
}
fn exit_code(compilation: &Compilation) -> u8 {
if compilation.has_errors() {
EXIT_DIAGNOSTICS
} else {
EXIT_OK
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_plain_name_becomes_a_package() {
assert_eq!(
package_name("research-agent").as_deref(),
Some("research_agent")
);
assert_eq!(package_name("My Agent").as_deref(), Some("my_agent"));
}
#[test]
fn a_reserved_word_yields_no_package() {
for reserved in ["agent", "tool", "flow", "type", "policy"] {
assert_eq!(package_name(reserved), None, "`{reserved}` is reserved");
}
}
#[test]
fn a_name_that_cannot_start_an_identifier_yields_no_package() {
assert_eq!(package_name("2fa"), None);
assert_eq!(package_name("---"), None);
assert_eq!(package_name(""), None);
}
#[test]
fn the_generated_source_omits_an_unusable_package_line() {
let source = starter_source("agent", StarterTemplate::Brief);
assert!(!source.contains("package"), "{source}");
assert!(
source.starts_with(
"language 0.1
"
),
"{source}"
);
}
#[test]
fn the_generated_source_keeps_a_usable_package_line() {
let source = starter_source("research-agent", StarterTemplate::Brief);
assert!(source.contains("package research_agent"), "{source}");
}
}