diff --git a/Cargo.lock b/Cargo.lock index da4b1e7..6277921 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,7 @@ dependencies = [ "sausage-plugin", "serde", "serde_json", + "serde_yaml_ng", "wstd", ] @@ -2058,6 +2059,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -2088,6 +2095,7 @@ dependencies = [ "sausage-vfs", "serde", "serde_json", + "serde_yaml_ng", "sha2", "tempfile", "tokio", @@ -2308,6 +2316,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2746,6 +2767,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/DECISIONS.md b/DECISIONS.md index f0310ab..09194b8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -61,6 +61,9 @@ a plugin, or a change that the agent makes to itself. ## Small choices +- **YAML for configuration, JSON for data.** A person edits configuration, so + it needs comments. `init` writes commented templates instead of asking + questions, so there is one way to configure a world. - **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect normal files. Atomic writes use a temporary file and `rename`. - **Tests run under WASI.** `cargo test` on the host cannot find failures diff --git a/PROTOCOL.md b/PROTOCOL.md index 42323e0..fad66bd 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -19,7 +19,7 @@ Rust plugins use the SDK `sausage-plugin`. Its source is at | call | A JSON-RPC request. `notes.add` calls the method `add` of the plugin `notes`. | | interface | A set of methods. A plugin has one main interface. It can also declare endpoint interfaces. | | endpoint | An interface that a running process serves on a TCP port. An endpoint file tells others where it is. | -| service | A call in `/config/boot.json`. The core starts it at boot and again after an upgrade of its plugin. | +| service | A call in `/config/boot.yaml`. The core starts it at boot and again after an upgrade of its plugin. | | import | An interface of another plugin that a plugin uses. | | provider, consumer | The plugin that has an interface, and a plugin that imports it. | | journal | The central log of the world. | @@ -36,11 +36,13 @@ Rust plugins use the SDK `sausage-plugin`. Its source is at state// persistent state of a plugin, by convention state/core/journal.sqlite the journal config/ global configuration - boot.json the services - agent.json API, base URL, model, and token of the agent + boot.yaml the services + agent.yaml the API, base URL, model, and key of the agent ``` - Every process can read and write all of it. There are no permissions. +- Configuration files are YAML, with comments. Data and interface files are + JSON. - Plugin names use `a-z`, `0-9`, `-`, and `_`. The name `core` is reserved. - The world folder must not contain `proc`, `run`, or `self`. These names are mount points (section 4). The host does not start a world that has them. @@ -287,13 +289,14 @@ for example the control interface of a web server. ## 8. Boot and services -At start, the core starts each service in `/config/boot.json`, in order: +At start, the core starts each service in `/config/boot.yaml`, in order: -```json -[ - {"method": "secrets.serve", "params": {}}, - {"method": "www.start", "params": {"port": 8080}} -] +```yaml +- method: secrets.serve + params: {} +- method: www.start + params: + port: 8080 ``` - A service is a detached call with the parent `core`. The core waits for diff --git a/README.md b/README.md index b24b81c..852ac42 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ own code. ## Start -You need [Nix](https://nixos.org/download) with flakes, and an API token for -an LLM (OpenAI Responses or Anthropic Messages API). No clone needed: +You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI +Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama, +and most other providers), or the Anthropic Messages API. No clone needed: ```sh nix run git+https://gitea.surma.technology/surma/sausage @@ -27,10 +28,10 @@ nix run git+https://gitea.surma.technology/surma/sausage -- init # creates ./w nix run git+https://gitea.surma.technology/surma/sausage -- run # starts the world and a console ``` -`init` asks for the API shape (`openai-responses` or `anthropic-messages`), -the base URL, the model, and the token. It reads `SAUSAGE_API`, -`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The -values go to `world/config/agent.json`. Both commands take another folder as +`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for +each field and an example for each API. Edit it before the first chat: choose +the API, the base URL, the model, and the key. Until then, a chat answers with +an error that names the missing field. Both commands take another folder as an argument, for example `... -- run my-world`. Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/` @@ -83,8 +84,8 @@ plugins//plugin.wasm the current version of a plugin plugins//source/ its source state// the state of a plugin, by convention state/core/journal.sqlite the journal: stderr of all processes, and core events -config/boot.json the services that start with the world -config/agent.json the configuration of the agent +config/boot.yaml the services that start with the world +config/agent.yaml the configuration of the agent ``` Each process also sees `/proc` (the processes and the state of the core), diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 3f65ca2..18dbea0 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -8,6 +8,7 @@ description = "The Sausage agent plugin." sausage-plugin = { version = "0.1", path = "../sdk" } serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml_ng = "0.10" [target.'cfg(target_os = "wasi")'.dependencies] wstd = "0.6" diff --git a/agent/src/llm.rs b/agent/src/llm.rs index 00ee41b..019fb21 100644 --- a/agent/src/llm.rs +++ b/agent/src/llm.rs @@ -1,37 +1,86 @@ -//! Inference through two API shapes: OpenAI Responses and Anthropic Messages. +//! Inference through OpenAI Responses, Chat Completions, and Anthropic Messages. //! //! The conversation is stored in a neutral format, so the agent can switch -//! between the two shapes. +//! between the three shapes. use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; #[cfg(target_os = "wasi")] use wstd::http::{Body, Client, Request}; -/// Inference configuration from `/config/agent.json`. -#[derive(Deserialize, Clone)] +/// Inference configuration from `/config/agent.yaml`. +#[derive(Deserialize, Clone, Default)] +#[serde(default)] pub struct Config { - /// `openai-responses` or `anthropic-messages`. + /// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`. pub api: String, - /// The API origin, without its versioned endpoint path. + /// The SDK base URL: versioned for OpenAI, unversioned for Anthropic. pub base_url: String, /// The provider's model identifier. pub model: String, /// The API credential sent in the provider's authentication header. pub token: String, + /// Permit an empty token and omit authentication headers. + pub allow_no_token: bool, /// Optional reasoning effort for OpenAI models only (wins over `thinking` there). #[serde(default)] pub reasoning_effort: Option, /// An optional cap on generated tokens. #[serde(default)] pub max_tokens: Option, - /// Thinking effort for both APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none. + /// Thinking effort for all APIs: "low", "medium", "high", "xhigh", "max" (`LEVELS`), or "off"/unset for none. /// Anthropic: adaptive thinking + output_config.effort; OpenAI: reasoning.effort. A level the model rejects is /// replaced by the nearest accepted one (logged), never by "off". #[serde(default)] pub thinking: Option, } +impl Config { + pub fn load() -> Result { + let bytes = std::fs::read("/config/agent.yaml") + .map_err(|e| format!("/config/agent.yaml: missing configuration file: {e}"))?; + Self::parse(&bytes) + } + + fn parse(bytes: &[u8]) -> Result { + let cfg = serde_yaml_ng::from_slice::>(bytes) + .map_err(|e| format!("/config/agent.yaml: {e}"))? + .unwrap_or_default(); + let invalid = if ![ + "openai-responses", + "openai-chat-completions", + "anthropic-messages", + ] + .contains(&cfg.api.as_str()) + { + Some("api (missing or unknown API shape)") + } else if cfg.base_url.trim().is_empty() { + Some("base_url") + } else if cfg.model.trim().is_empty() { + Some("model") + } else if (cfg.token.trim().is_empty() && !cfg.allow_no_token) + || cfg.token == "CHANGE_ME" + || cfg.token.starts_with("YOUR_") + { + Some("token (set a key, or set token: \"\" and allow_no_token: true)") + } else { + None + }; + if let Some(field) = invalid { + return Err(format!("/config/agent.yaml: configure field {field}")); + } + Ok(cfg) + } + + fn bearer(&self) -> Vec<(&'static str, String)> { + if self.token.is_empty() { + vec![] + } else { + vec![("authorization", format!("Bearer {}", self.token))] + } + } +} + /// Thinking levels, lowest first. pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"]; @@ -63,6 +112,64 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option mod tests { use super::fallback_effort; + #[test] + fn yaml_configuration_and_credentials() { + use super::Config; + for text in [ + "", + "api: unknown", + "api: openai-responses\nbase_url: url\nmodel: model\ntoken: CHANGE_ME", + "api: openai-responses\nbase_url: url\nmodel: model\ntoken: ''", + ] { + assert!( + Config::parse(text.as_bytes()) + .err() + .unwrap() + .contains("/config/agent.yaml") + ); + } + let cfg = Config::parse(b"api: openai-chat-completions\nbase_url: http://localhost/v1\nmodel: local\ntoken: ''\nallow_no_token: true").unwrap(); + assert!(cfg.bearer().is_empty()); + } + + #[test] + fn chat_request_and_response_mapping() { + use super::*; + let cfg = Config { + model: "test".into(), + max_tokens: Some(100), + ..Config::default() + }; + let response = json!({"choices":[{"message":{"content":"interim","tool_calls":[{"id":"one","function":{"name":"read_file","arguments":"{}"}},{"id":"two","function":{"name":"read_file","arguments":"{}"}}]}}]}); + let mut history = chat_entries(&response); + assert_eq!(history.len(), 3); + history.push(Entry::ToolResult { + id: "one".into(), + output: "error".into(), + }); + let tools = [Tool { + name: "read_file", + description: "read", + parameters: json!({"type":"object"}), + }]; + let body = chat_body(&cfg, "system", &tools, &history, Some("high")); + assert_eq!(body["messages"][1]["content"], "interim"); + assert_eq!( + body["messages"][1]["tool_calls"].as_array().unwrap().len(), + 2 + ); + assert_eq!( + body["messages"][2], + json!({"role":"tool","tool_call_id":"one","content":"error"}) + ); + assert_eq!(body["tools"][0]["function"]["name"], "read_file"); + assert_eq!(body["reasoning_effort"], "high"); + assert_eq!(body["max_completion_tokens"], 100); + assert!( + matches!(&chat_entries(&json!({"choices":[{"message":{"content":"final"}}]}))[0], Entry::Assistant {text,..} if text == "final") + ); + } + #[test] fn nearest_accepted_level() { let anthropic = @@ -130,7 +237,7 @@ pub enum Entry { }, } -/// A tool contract shared by both API shapes. +/// A tool contract shared by all API shapes. pub struct Tool { /// The function identifier exposed to the model. pub name: &'static str, @@ -148,7 +255,7 @@ pub async fn complete( history: &[Entry], ) -> Result, String> { let want = match cfg.api.as_str() { - "openai-responses" => cfg + "openai-responses" | "openai-chat-completions" => cfg .reasoning_effort .clone() .or_else(|| cfg.thinking.clone()), @@ -160,6 +267,7 @@ pub async fn complete( loop { let result = match cfg.api.as_str() { "openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await, + "openai-chat-completions" => chat(cfg, system, tools, history, effort.as_deref()).await, "anthropic-messages" => { anthropic( cfg, @@ -269,13 +377,8 @@ async fn openai( if let Some(effort) = effort { body["reasoning"] = json!({"effort": effort}); } - let url = format!("{}/v1/responses", cfg.base_url); - let resp = post( - &url, - &[("authorization", format!("Bearer {}", cfg.token))], - &body, - ) - .await?; + let url = format!("{}/responses", cfg.base_url.trim_end_matches('/')); + let resp = post(&url, &cfg.bearer(), &body).await?; let mut out = Vec::new(); for item in resp["output"].as_array().cloned().unwrap_or_default() { match item["type"].as_str() { @@ -389,16 +492,12 @@ async fn anthropic( body["output_config"] = json!({"effort": effort}); } } - let url = format!("{}/v1/messages", cfg.base_url); - let resp = post( - &url, - &[ - ("x-api-key", cfg.token.clone()), - ("anthropic-version", "2023-06-01".to_string()), - ], - &body, - ) - .await?; + let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); + let mut headers = vec![("anthropic-version", "2023-06-01".to_string())]; + if !cfg.token.is_empty() { + headers.push(("x-api-key", cfg.token.clone())); + } + let resp = post(&url, &headers, &body).await?; let mut out = Vec::new(); for block in resp["content"].as_array().cloned().unwrap_or_default() { match block["type"].as_str() { @@ -423,3 +522,88 @@ async fn anthropic( } Ok(out) } + +/// Translate neutral entries into the Chat Completions message format. +fn chat_body( + cfg: &Config, + system: &str, + tools: &[Tool], + history: &[Entry], + effort: Option<&str>, +) -> Value { + let mut messages = vec![json!({"role":"system", "content":system})]; + for entry in history { + match entry { + Entry::User { text } => messages.push(json!({"role":"user","content":text})), + Entry::Assistant { text, .. } => { + messages.push(json!({"role":"assistant","content":text})) + } + Entry::ToolCall { + id, + name, + arguments, + } => { + if messages.last().unwrap()["role"] != "assistant" { + messages.push(json!({"role":"assistant","content":null})); + } + let last = messages.last_mut().unwrap(); + if !last["tool_calls"].is_array() { + last["tool_calls"] = json!([]); + } + last["tool_calls"].as_array_mut().unwrap().push(json!({"id":id,"type":"function","function":{"name":name,"arguments":arguments}})); + } + Entry::ToolResult { id, output } => { + messages.push(json!({"role":"tool","tool_call_id":id,"content":output})) + } + Entry::Opaque { .. } => {} + } + } + let tools: Vec = tools.iter().map(|t| json!({"type":"function","function":{"name":t.name,"description":t.description,"parameters":t.parameters}})).collect(); + let mut body = json!({"model":cfg.model,"messages":messages,"tools":tools}); + if let Some(max) = cfg.max_tokens { + body["max_completion_tokens"] = json!(max); + } + if let Some(effort) = effort { + body["reasoning_effort"] = json!(effort); + } + body +} + +/// Map text and tool calls back into the shared history. +fn chat_entries(response: &Value) -> Vec { + let message = &response["choices"][0]["message"]; + let mut entries = Vec::new(); + if let Some(text) = message["content"].as_str().filter(|s| !s.is_empty()) { + entries.push(Entry::Assistant { + text: text.into(), + phase: None, + }); + } + for call in message["tool_calls"].as_array().into_iter().flatten() { + entries.push(Entry::ToolCall { + id: call["id"].as_str().unwrap_or_default().into(), + name: call["function"]["name"].as_str().unwrap_or_default().into(), + arguments: call["function"]["arguments"] + .as_str() + .unwrap_or("{}") + .into(), + }); + } + entries +} + +async fn chat( + cfg: &Config, + system: &str, + tools: &[Tool], + history: &[Entry], + effort: Option<&str>, +) -> Result, String> { + let response = post( + &format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')), + &cfg.bearer(), + &chat_body(cfg, system, tools, history, effort), + ) + .await?; + Ok(chat_entries(&response)) +} diff --git a/agent/src/main.rs b/agent/src/main.rs index a5b38d2..933406b 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -32,7 +32,7 @@ impl Agent { async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result { let _guard = lock(HISTORY).await?; std::fs::create_dir_all("/state/agent")?; - let cfg: Config = serde_json::from_slice(&std::fs::read("/config/agent.json")?)?; + let cfg = Config::load().map_err(|error| Error::new(-32603, error))?; let mut entries = load_history(HISTORY)?; repair_tools(&mut entries)?; let current = entries.len(); diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 317da6c..58adf5a 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -17,6 +17,7 @@ http-body-util = "0.1" include_dir = "0.7" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml_ng = "0.10" tokio = { version = "1", features = ["full"] } toml_edit = "0.25" wasmtime = "49" diff --git a/cli/src/agent.yaml b/cli/src/agent.yaml new file mode 100644 index 0000000..07d4c99 --- /dev/null +++ b/cli/src/agent.yaml @@ -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 diff --git a/cli/src/boot.yaml b/cli/src/boot.yaml new file mode 100644 index 0000000..a70e864 --- /dev/null +++ b/cli/src/boot.yaml @@ -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 +[] diff --git a/cli/src/main.rs b/cli/src/main.rs index af1caa8..9114bbd 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -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, @@ -60,33 +56,8 @@ impl Drop for TerminalMode { } } -fn setting(var: &str, question: &str, default: Option<&str>) -> Result { - 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 { 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 { } 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 { _=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 { 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 }, diff --git a/cli/src/services.rs b/cli/src/services.rs index e156818..f9e6bc2 100644 --- a/cli/src/services.rs +++ b/cli/src/services.rs @@ -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 = serde_json::from_slice( - &tokio::fs::read(self.0.world.root.join("config/boot.json")).await?, + let boot: Vec = serde_yaml_ng::from_slice( + &tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?, )?; for service in boot { self.start_service( diff --git a/cli/src/world.rs b/cli/src/world.rs index 992eea4..6c707f8 100644 --- a/cli/src/world.rs +++ b/cli/src/world.rs @@ -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. diff --git a/cli/tests/host/activation.rs b/cli/tests/host/activation.rs index bea6664..3be2f61 100644 --- a/cli/tests/host/activation.rs +++ b/cli/tests/host/activation.rs @@ -232,7 +232,7 @@ async fn review_shutdown_suppresses_hooks() { old["x-sausage-grace"] = json!(60); world.plugin("notes", old, document("serve")); std::fs::write( - world.0.join("config/boot.json"), + world.0.join("config/boot.yaml"), json!([{"method":"notes.serve","params":{}}]).to_string(), ) .unwrap(); @@ -766,7 +766,7 @@ async fn restarts_service() { let world = TestWorld::new(); world.plugin("notes", document("serve"), document("serve")); std::fs::write( - world.0.join("config/boot.json"), + world.0.join("config/boot.yaml"), json!([{"method":"notes.serve","params":{}}]).to_string(), ) .unwrap(); diff --git a/cli/tests/host/agent.rs b/cli/tests/host/agent.rs index 95979ba..35f80f2 100644 --- a/cli/tests/host/agent.rs +++ b/cli/tests/host/agent.rs @@ -13,18 +13,22 @@ use tokio::{ }; /// The two HTTP contracts supported by the agent. -const APIS: [&str; 2] = ["openai-responses", "anthropic-messages"]; +const APIS: [&str; 3] = [ + "openai-responses", + "anthropic-messages", + "openai-chat-completions", +]; /// A captured request and the script's delayed HTTP response. type Exchange = (String, Value, oneshot::Sender<(u16, Value)>); /// The script controls each response, including concurrent requests. -struct Llm { - url: String, +pub(super) struct Llm { + pub(super) url: String, api: &'static str, requests: mpsc::UnboundedReceiver, task: tokio::task::JoinHandle<()>, } impl Llm { - async fn new(api: &'static str) -> Self { + pub(super) async fn new(api: &'static str) -> Self { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let (tx, requests) = mpsc::unbounded_channel::(); @@ -37,6 +41,11 @@ impl Llm { service_fn(move |request: hyper::Request| { let tx = tx.clone(); async move { + assert_eq!(request.method(), "POST"); + // The local Chat Completions fixture explicitly disables authentication. + if api == APIS[2] { + assert!(!request.headers().contains_key("authorization")); + } let path = request.uri().path().to_string(); let bytes = request.into_body().collect().await.unwrap().to_bytes(); let body = serde_json::from_slice(&bytes).unwrap(); @@ -66,7 +75,7 @@ impl Llm { task, } } - async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) { + pub(super) async fn next(&mut self) -> (Value, oneshot::Sender<(u16, Value)>) { let (path, body, reply) = tokio::time::timeout(Duration::from_secs(60), self.requests.recv()) .await @@ -76,6 +85,8 @@ impl Llm { path, if self.api == APIS[0] { "/v1/responses" + } else if self.api == APIS[2] { + "/v1/chat/completions" } else { "/v1/messages" } @@ -83,8 +94,11 @@ impl Llm { assert_eq!(body["model"], "scripted-test"); (body, reply) } - fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value { - if self.api == APIS[0] { + pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value { + if self.api == APIS[2] { + let calls: Vec = calls.iter().enumerate().map(|(n,(name,args))| json!({"id":format!("tool-{n}"),"type":"function","function":{"name":name,"arguments":args.to_string()}})).collect(); + json!({"choices":[{"message":{"role":"assistant","content":text,"tool_calls":calls}}]}) + } else if self.api == APIS[0] { let mut output = vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})]; for (n, (name, args)) in calls.iter().enumerate() { @@ -128,7 +142,7 @@ impl TestWorld { ) .unwrap(); } - std::fs::write(path.join("config/agent.json"),json!({"api":server.api,"base_url":server.url,"model":"scripted-test","token":"fake-token"}).to_string()).unwrap(); + std::fs::write(path.join("config/agent.yaml"), serde_yaml_ng::to_string(&json!({"api":server.api,"base_url":if server.api == APIS[1] {server.url.clone()} else {format!("{}/v1",server.url)},"model":"scripted-test","token":if server.api == APIS[2] {""} else {"fake-token"},"allow_no_token":server.api == APIS[2]})).unwrap()).unwrap(); Self(path) } async fn start(&self) -> Host { @@ -165,7 +179,15 @@ fn input(body: &Value, api: &str) -> Value { body[if api == APIS[0] { "input" } else { "messages" }].clone() } fn outputs(body: &Value, api: &str) -> Vec { - if api == APIS[0] { + if api == APIS[2] { + body["messages"] + .as_array() + .unwrap() + .iter() + .filter(|e| e["role"] == "tool") + .map(|e| serde_json::from_str(e["content"].as_str().unwrap()).unwrap()) + .collect() + } else if api == APIS[0] { body["input"] .as_array() .unwrap() @@ -185,7 +207,54 @@ fn outputs(body: &Value, api: &str) -> Vec { } } -/// Both API shapes return final text and retain the complete history after a core restart. +/// Invalid configuration names the file and the field before any inference request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unconfigured_chat_names_the_configuration() { + let server = Llm::new(APIS[0]).await; + let world = TestWorld::new(&server); + let path = world.0.join("config/agent.yaml"); + std::fs::remove_file(&path).unwrap(); + let host = world.start().await; + let error = host + .call("agent.chat", json!({"text":"hello"})) + .await + .unwrap_err(); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("/config/agent.yaml") + ); + for (config, field) in [ + ("", "api"), + ("api: unknown", "api"), + ("api: openai-responses", "base_url"), + ( + "api: openai-responses\nbase_url: http://localhost/v1", + "model", + ), + ( + "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: ''", + "token", + ), + ( + "api: openai-responses\nbase_url: http://localhost/v1\nmodel: test\ntoken: CHANGE_ME", + "token", + ), + ] { + std::fs::write(&path, config).unwrap(); + let error = host + .call("agent.chat", json!({"text":"hello"})) + .await + .unwrap_err(); + let message = error["message"].as_str().unwrap(); + assert!(message.contains("/config/agent.yaml"), "{message}"); + assert!(message.contains(field), "{message}"); + } + host.stop().await; +} + +/// All API shapes return final text and retain the complete history after a core restart. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn final_answers_and_history_survive_restart() { for api in APIS { @@ -435,14 +504,14 @@ async fn oversized_current_turn_preserves_the_previous_cursor() { /// Provider-private items are replayed only for their original API and model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn private_items_are_scoped_to_the_model() { - for api in APIS { + for api in [APIS[0], APIS[1]] { let mut server = Llm::new(api).await; let world = TestWorld::new(&server); - let config_path = world.0.join("config/agent.json"); + let config_path = world.0.join("config/agent.yaml"); let mut config: Value = - serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + serde_yaml_ng::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); config["thinking"] = json!("high"); - std::fs::write(config_path, config.to_string()).unwrap(); + std::fs::write(config_path, serde_yaml_ng::to_string(&config).unwrap()).unwrap(); std::fs::create_dir_all(world.0.join("state/agent")).unwrap(); let mut entries = vec![json!({"kind":"user","text":"old turn"})]; for (model, marker) in [ @@ -480,7 +549,7 @@ async fn private_items_are_scoped_to_the_model() { /// Separate assistant messages retain a separator in the final answer. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn final_texts_have_a_separator() { - for api in APIS { + for api in [APIS[0], APIS[1]] { let mut server = Llm::new(api).await; let world = TestWorld::new(&server); let host = world.start().await; diff --git a/cli/tests/host/cli.rs b/cli/tests/host/cli.rs index f51c0d7..ccdc4fe 100644 --- a/cli/tests/host/cli.rs +++ b/cli/tests/host/cli.rs @@ -59,11 +59,12 @@ fn install_probe(world: &Path) { async fn init_builds_agent() { let temporary = tempfile::tempdir().unwrap(); let world = temporary.path().join("world"); + // Legacy configuration variables must not affect the templates. let configured = || { let mut command = binary(); command .current_dir(temporary.path()) - .env("SAUSAGE_API", "anthropic-messages") + .env("SAUSAGE_API", "not-an-api") .env("SAUSAGE_BASE_URL", "http://127.0.0.1:1/") .env("SAUSAGE_MODEL", "init-test") .env("SAUSAGE_API_TOKEN", "fake-token") @@ -72,11 +73,13 @@ async fn init_builds_agent() { }; let failed = configured() .arg("init") + .env("SAUSAGE_HEADLESS", "not-a-boolean") .env("RUSTC", "/nonexistent-sausage-rustc") .output() .await .unwrap(); assert!(!failed.status.success()); + assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc")); assert!(!world.exists()); let output = configured().output().await.unwrap(); assert!( @@ -88,15 +91,14 @@ async fn init_builds_agent() { assert!( String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes") ); - let config: Value = - serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap(); + let mut config: Value = + serde_yaml_ng::from_slice(&std::fs::read(world.join("config/agent.yaml")).unwrap()) + .unwrap(); + assert_eq!(config["token"], "CHANGE_ME"); + assert_eq!(config["api"], "openai-responses"); + assert!(String::from_utf8_lossy(&output.stdout).contains("config/agent.yaml")); assert_eq!( - config, - json!({"api":"anthropic-messages","base_url":"http://127.0.0.1:1","model":"init-test","token":"fake-token"}) - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("sausage run")); - assert_eq!( - serde_json::from_slice::(&std::fs::read(world.join("config/boot.json")).unwrap()) + serde_yaml_ng::from_slice::(&std::fs::read(world.join("config/boot.yaml")).unwrap()) .unwrap(), json!([]) ); @@ -131,6 +133,35 @@ async fn init_builds_agent() { .iter() .any(|method| method["name"] == "chat") ); + let error = host + .call("agent.chat", json!({"text":"hello"})) + .await + .unwrap_err(); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("/config/agent.yaml") + ); + assert!(error["message"].as_str().unwrap().contains("token")); + let mut server = super::agent::Llm::new("anthropic-messages").await; + config["api"] = json!("anthropic-messages"); + config["base_url"] = json!(server.url); + config["model"] = json!("scripted-test"); + config["token"] = json!("fake-token"); + std::fs::write( + world.join("config/agent.yaml"), + serde_yaml_ng::to_string(&config).unwrap(), + ) + .unwrap(); + let caller = host.clone(); + let turn = + tokio::spawn(async move { caller.call("agent.chat", json!({"text":"hello"})).await }); + let (_, reply) = server.next().await; + reply + .send((200, server.answer("configured agent", &[]))) + .unwrap(); + assert_eq!(turn.await.unwrap().unwrap(), "configured agent"); host.stop().await; } @@ -178,7 +209,7 @@ async fn signals_work_during_boot() { ] { let (_folder, world) = world(); std::fs::write( - world.join("config/boot.json"), + world.join("config/boot.yaml"), json!([ {"method":"core.lock","params":{"path":"boot"}}, {"method":"core.lock","params":{"path":"boot"}} @@ -251,7 +282,7 @@ async fn console_error_stops_host() { let (_folder, world) = world(); install_probe(&world); std::fs::write( - world.join("config/boot.json"), + world.join("config/boot.yaml"), json!([{"method":"probe.serve","params":{}}]).to_string(), ) .unwrap(); diff --git a/cli/tests/host/core.rs b/cli/tests/host/core.rs index 9e25a7e..119bda9 100644 --- a/cli/tests/host/core.rs +++ b/cli/tests/host/core.rs @@ -667,8 +667,8 @@ async fn reserved_world_roots_prevent_startup() { async fn boot_waits_for_readiness_in_order() { let w = TestWorld::new(); std::fs::write( - w.0.join("config/boot.json"), - serde_json::to_vec(&json!([ + w.0.join("config/boot.yaml"), + serde_yaml_ng::to_string(&json!([ {"method":"probe.write","params":{"path":"/state/boot","text":"ready"}}, {"method":"probe.read","params":{"path":"/state/boot"}} ])) @@ -1399,7 +1399,7 @@ async fn failed_boot_services_are_logged_and_boot_continues() { {"method":"probe.write","params":{"path":"/state/continued","text":"yes"}} ]); std::fs::write( - w.0.join("config/boot.json"), + w.0.join("config/boot.yaml"), serde_json::to_vec(&services).unwrap(), ) .unwrap(); @@ -1423,7 +1423,7 @@ async fn failed_boot_services_are_logged_and_boot_continues() { async fn boot_services_are_detached() { let w = TestWorld::new(); std::fs::write( - w.0.join("config/boot.json"), + w.0.join("config/boot.yaml"), br#"[{"method":"probe.serve","params":{}}]"#, ) .unwrap(); @@ -1855,7 +1855,7 @@ async fn nested_parent_environment() { async fn service_parent_environment() { let w = TestWorld::new(); std::fs::write( - w.0.join("config/boot.json"), + w.0.join("config/boot.yaml"), br#"[{"method":"probe.forgotten","params":{}}]"#, ) .unwrap(); @@ -1936,7 +1936,7 @@ async fn detached_processes_need_explicit_termination() { async fn core_exit_sets_executable_status_75() { let w = TestWorld::new(); std::fs::write( - w.0.join("config/boot.json"), + w.0.join("config/boot.yaml"), br#"[{"method":"core.exit","params":{}}]"#, ) .unwrap(); diff --git a/flake.nix b/flake.nix index 7819a1e..c01863d 100644 --- a/flake.nix +++ b/flake.nix @@ -126,7 +126,7 @@ emptyPath="$root/empty-bin" mkdir -p "$emptyPath" "$root/home" "$root/cargo-home" mkdir -p "$world/plugins" "$world/state" "$world/config" - echo "[]" > "$world/config/boot.json" + echo "[]" > "$world/config/boot.yaml" source="$world/plugins/probe/source" mkdir -p "$source/src" "$source/tests" cat > "$source/Cargo.toml" <<'TOML' diff --git a/sdk/src/lib.rs b/sdk/src/lib.rs index ba2a5b7..3924382 100644 --- a/sdk/src/lib.rs +++ b/sdk/src/lib.rs @@ -199,7 +199,7 @@ //! # A service with a fixed endpoint //! //! A service can publish a fixed path in `/run`. The core clears `/run` when the host starts. -//! Configure this service in `/config/boot.json` with `{"method":"secrets.serve","params":{}}`. +//! Configure this service in `/config/boot.yaml` with the entry `- method: secrets.serve`. //! Clients can then connect to `/run/secrets`. //! //! ```no_run