YAML configuration, init without a wizard, and openai-chat-completions
ci / check (push) Successful in 15m52s

- /config/agent.yaml and /config/boot.yaml replace the JSON files, read
  with serde_yaml_ng (also in the agent, inside WASM).
- sausage init asks nothing and reads no SAUSAGE_* variables. It writes
  commented templates with an example for each API, and prints the path
  to edit. Until the agent is configured, agent.chat names the missing
  field. allow_no_token: true permits a local model without a key.
- The agent speaks openai-chat-completions, the format of OpenRouter,
  Ollama, and most providers. base_url follows each SDK: with /v1 for
  the OpenAI shapes, without it for Anthropic.
- Checked against the proxy with glm-5.3-flash: the agent wrote, built,
  and activated a counter plugin in 55 s.
This commit is contained in:
2026-10-23 11:27:48 +01:00
parent ab1adbd5e3
commit b536beb069
19 changed files with 481 additions and 123 deletions
+46
View File
@@ -0,0 +1,46 @@
# The configuration of the agent. Edit it before the first chat.
# api selects the request format. Choose one complete example below.
api: openai-responses
# OpenAI base URLs include /v1. Do not include /responses or /chat/completions.
base_url: https://api.openai.com/v1
# model is the provider's model identifier.
model: gpt-4.1
# token is the API key. Replace CHANGE_ME with your key.
# An empty token requires allow_no_token: true. Then no authentication header is sent.
token: CHANGE_ME
allow_no_token: false
# Optional output token cap. Anthropic defaults to 32000 if omitted.
# max_tokens: 32000
# Optional thinking effort: off, low, medium, high, xhigh, or max.
# The agent retries a rejected effort with the nearest accepted level.
# thinking: high
# Optional OpenAI override for thinking effort.
# reasoning_effort: high
# OpenAI Responses example (base_url includes the version):
# api: openai-responses
# base_url: https://api.openai.com/v1
# model: gpt-4.1
# token: YOUR_OPENAI_KEY
# allow_no_token: false
# OpenRouter example (OpenAI Chat Completions, the format of most providers):
# api: openai-chat-completions
# base_url: https://openrouter.ai/api/v1
# model: anthropic/claude-sonnet-4.5
# token: YOUR_OPENROUTER_KEY
# allow_no_token: false
# Local Ollama example (OpenAI Chat Completions, no key):
# api: openai-chat-completions
# base_url: http://localhost:11434/v1
# model: llama3.2
# token: ""
# allow_no_token: true
# Anthropic Messages example (base_url excludes the version):
# api: anthropic-messages
# base_url: https://api.anthropic.com
# model: claude-sonnet-4-5
# token: YOUR_ANTHROPIC_KEY
# allow_no_token: false
+9
View File
@@ -0,0 +1,9 @@
# The core starts these services in order. An empty list starts no services.
# Each method is a plugin.method name. params contains its named arguments.
# The core waits for each result before it starts the next service.
# After a plugin upgrade, the core restarts that plugin's services.
# Example (replace [] with this list):
# - method: www.start
# params:
# port: 8080
[]
+23 -40
View File
@@ -3,18 +3,14 @@ use anyhow::{Result, bail};
use clap::{Parser, Subcommand};
use include_dir::{Dir, include_dir};
use sausage::{Host, world::World};
use serde_json::json;
use std::{
io::Write,
path::{Path, PathBuf},
};
use std::path::{Path, PathBuf};
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
mod test_runner;
#[derive(Parser)]
#[command(version)]
struct Cli {
#[arg(long, global = true, env = "SAUSAGE_HEADLESS", value_parser = headless)]
#[arg(long, global = true)]
headless: bool,
#[arg(long, global = true)]
cache_dir: Option<PathBuf>,
@@ -60,33 +56,8 @@ impl Drop for TerminalMode {
}
}
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
if let Ok(v) = std::env::var(var)
&& !v.is_empty()
{
return Ok(v);
}
print!("{question}: ");
std::io::stdout().flush()?;
let mut line = String::new();
std::io::stdin().read_line(&mut line)?;
if !line.trim().is_empty() {
return Ok(line.trim().into());
}
match default {
Some(v) => Ok(v.into()),
None => bail!("{question} is required"),
}
}
/// Creates a configured world and builds its embedded agent through the standalone builder.
/// Creates a world with editable configuration and builds its embedded agent.
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
let api = setting("SAUSAGE_API", "API shape", Some("openai-responses"))?;
if api != "openai-responses" && api != "anthropic-messages" {
bail!("unknown API shape {api:?}");
}
let base_url = setting("SAUSAGE_BASE_URL", "Base URL", None)?;
let model = setting("SAUSAGE_MODEL", "Model", None)?;
let token = setting("SAUSAGE_API_TOKEN", "API token", None)?;
if dir.exists() {
bail!("world already exists: {}", dir.display());
}
@@ -100,10 +71,12 @@ async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
.tempdir_in(parent)?;
let world = World::create(&staging.path().join("world"))?;
std::fs::write(
world.root.join("config/agent.json"),
serde_json::to_vec_pretty(
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
)?,
world.root.join("config/agent.yaml"),
include_str!("agent.yaml"),
)?;
std::fs::write(
world.root.join("config/boot.yaml"),
include_str!("boot.yaml"),
)?;
let cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
let base = std::env::var_os("XDG_CACHE_HOME")
@@ -129,6 +102,10 @@ async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
let output = builder.build(&source).await?;
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
std::fs::rename(&world.root, dir)?;
println!(
"Edit {} before chatting.",
dir.join("config/agent.yaml").display()
);
println!("Start the world with: sausage run {}", dir.display());
Ok(())
}
@@ -140,7 +117,7 @@ async fn main() -> Result<std::process::ExitCode> {
Some(command) => command,
None => {
let dir = PathBuf::from("world");
if !dir.join("config/boot.json").is_file() {
if !dir.join("config/boot.yaml").is_file() {
init(&dir, cli.cache_dir.as_deref()).await?;
}
Cmd::Run { dir }
@@ -154,6 +131,12 @@ async fn main() -> Result<std::process::ExitCode> {
}
Cmd::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
Cmd::Run { dir } => {
let headless = cli.headless
|| std::env::var("SAUSAGE_HEADLESS")
.ok()
.map(|value| headless(&value).map_err(anyhow::Error::msg))
.transpose()?
.unwrap_or(false);
let mut term =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
let mut interrupt =
@@ -170,7 +153,7 @@ async fn main() -> Result<std::process::ExitCode> {
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
};
let mut notifications = host.subscribe();
let notification_task = cli.headless.then(|| {
let notification_task = headless.then(|| {
tokio::spawn(async move {
loop {
match notifications.recv().await {
@@ -186,8 +169,8 @@ async fn main() -> Result<std::process::ExitCode> {
let exit = tokio::select! {
_=host.exit_requested()=>true,
_=term.recv()=>false,
_=interrupt.recv(), if cli.headless=>false,
result = console, if !cli.headless => {
_=interrupt.recv(), if headless=>false,
result = console, if !headless => {
console_result=result;
false
},
+2 -2
View File
@@ -12,8 +12,8 @@ impl Host {
/// Starts discovery in the background, then waits for each boot service in order.
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
self.watch_plugins()?;
let boot: Vec<Value> = serde_json::from_slice(
&tokio::fs::read(self.0.world.root.join("config/boot.json")).await?,
let boot: Vec<Value> = serde_yaml_ng::from_slice(
&tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?,
)?;
for service in boot {
self.start_service(
+1 -1
View File
@@ -27,7 +27,7 @@ impl World {
for dir in ["plugins", "state/core", "config"] {
std::fs::create_dir_all(path.join(dir))?;
}
std::fs::write(path.join("config/boot.json"), "[]\n")?;
std::fs::write(path.join("config/boot.yaml"), "[]\n")?;
Self::open(path)
}
/// Return the folder that contains a plugin component.