YAML configuration, init without a wizard, and openai-chat-completions
ci / check (push) Successful in 15m52s
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:
Generated
+27
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"sausage-plugin",
|
"sausage-plugin",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml_ng",
|
||||||
"wstd",
|
"wstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2058,6 +2059,12 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -2088,6 +2095,7 @@ dependencies = [
|
|||||||
"sausage-vfs",
|
"sausage-vfs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_yaml_ng",
|
||||||
"sha2",
|
"sha2",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2308,6 +2316,19 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "sha2"
|
name = "sha2"
|
||||||
version = "0.10.9"
|
version = "0.10.9"
|
||||||
@@ -2746,6 +2767,12 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ a plugin, or a change that the agent makes to itself.
|
|||||||
|
|
||||||
## Small choices
|
## 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
|
- **Plain POSIX files.** Programs like `tempfile`, git, and SQLite expect
|
||||||
normal files. Atomic writes use a temporary file and `rename`.
|
normal files. Atomic writes use a temporary file and `rename`.
|
||||||
- **Tests run under WASI.** `cargo test` on the host cannot find failures
|
- **Tests run under WASI.** `cargo test` on the host cannot find failures
|
||||||
|
|||||||
+12
-9
@@ -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`. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| import | An interface of another plugin that a plugin uses. |
|
||||||
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
|
| provider, consumer | The plugin that has an interface, and a plugin that imports it. |
|
||||||
| journal | The central log of the world. |
|
| journal | The central log of the world. |
|
||||||
@@ -36,11 +36,13 @@ Rust plugins use the SDK `sausage-plugin`. Its source is at
|
|||||||
state/<plugin>/ persistent state of a plugin, by convention
|
state/<plugin>/ persistent state of a plugin, by convention
|
||||||
state/core/journal.sqlite the journal
|
state/core/journal.sqlite the journal
|
||||||
config/ global configuration
|
config/ global configuration
|
||||||
boot.json the services
|
boot.yaml the services
|
||||||
agent.json API, base URL, model, and token of the agent
|
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.
|
- 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.
|
- 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
|
- 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.
|
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
|
## 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
|
```yaml
|
||||||
[
|
- method: secrets.serve
|
||||||
{"method": "secrets.serve", "params": {}},
|
params: {}
|
||||||
{"method": "www.start", "params": {"port": 8080}}
|
- method: www.start
|
||||||
]
|
params:
|
||||||
|
port: 8080
|
||||||
```
|
```
|
||||||
|
|
||||||
- A service is a detached call with the parent `core`. The core waits for
|
- A service is a detached call with the parent `core`. The core waits for
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ own code.
|
|||||||
|
|
||||||
## Start
|
## Start
|
||||||
|
|
||||||
You need [Nix](https://nixos.org/download) with flakes, and an API token for
|
You need [Nix](https://nixos.org/download) with flakes, and an LLM: the OpenAI
|
||||||
an LLM (OpenAI Responses or Anthropic Messages API). No clone needed:
|
Responses API, an OpenAI-compatible Chat Completions API (OpenRouter, Ollama,
|
||||||
|
and most other providers), or the Anthropic Messages API. No clone needed:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
nix run git+https://gitea.surma.technology/surma/sausage
|
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
|
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`),
|
`init` asks nothing. It writes `world/config/agent.yaml`, with a comment for
|
||||||
the base URL, the model, and the token. It reads `SAUSAGE_API`,
|
each field and an example for each API. Edit it before the first chat: choose
|
||||||
`SAUSAGE_BASE_URL`, `SAUSAGE_MODEL`, and `SAUSAGE_API_TOKEN` first. The
|
the API, the base URL, the model, and the key. Until then, a chat answers with
|
||||||
values go to `world/config/agent.json`. Both commands take another folder as
|
an error that names the missing field. Both commands take another folder as
|
||||||
an argument, for example `... -- run my-world`.
|
an argument, for example `... -- run my-world`.
|
||||||
|
|
||||||
Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/`
|
Compiled code is cached outside the world, in `$XDG_CACHE_HOME/sausage/`
|
||||||
@@ -83,8 +84,8 @@ plugins/<name>/plugin.wasm the current version of a plugin
|
|||||||
plugins/<name>/source/ its source
|
plugins/<name>/source/ its source
|
||||||
state/<plugin>/ the state of a plugin, by convention
|
state/<plugin>/ the state of a plugin, by convention
|
||||||
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
state/core/journal.sqlite the journal: stderr of all processes, and core events
|
||||||
config/boot.json the services that start with the world
|
config/boot.yaml the services that start with the world
|
||||||
config/agent.json the configuration of the agent
|
config/agent.yaml the configuration of the agent
|
||||||
```
|
```
|
||||||
|
|
||||||
Each process also sees `/proc` (the processes and the state of the core),
|
Each process also sees `/proc` (the processes and the state of the core),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ description = "The Sausage agent plugin."
|
|||||||
sausage-plugin = { version = "0.1", path = "../sdk" }
|
sausage-plugin = { version = "0.1", path = "../sdk" }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml_ng = "0.10"
|
||||||
|
|
||||||
[target.'cfg(target_os = "wasi")'.dependencies]
|
[target.'cfg(target_os = "wasi")'.dependencies]
|
||||||
wstd = "0.6"
|
wstd = "0.6"
|
||||||
|
|||||||
+210
-26
@@ -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
|
//! 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::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
#[cfg(target_os = "wasi")]
|
#[cfg(target_os = "wasi")]
|
||||||
use wstd::http::{Body, Client, Request};
|
use wstd::http::{Body, Client, Request};
|
||||||
|
|
||||||
/// Inference configuration from `/config/agent.json`.
|
/// Inference configuration from `/config/agent.yaml`.
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone, Default)]
|
||||||
|
#[serde(default)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
/// `openai-responses` or `anthropic-messages`.
|
/// `openai-responses`, `openai-chat-completions`, or `anthropic-messages`.
|
||||||
pub api: String,
|
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,
|
pub base_url: String,
|
||||||
/// The provider's model identifier.
|
/// The provider's model identifier.
|
||||||
pub model: String,
|
pub model: String,
|
||||||
/// The API credential sent in the provider's authentication header.
|
/// The API credential sent in the provider's authentication header.
|
||||||
pub token: String,
|
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).
|
/// Optional reasoning effort for OpenAI models only (wins over `thinking` there).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reasoning_effort: Option<String>,
|
pub reasoning_effort: Option<String>,
|
||||||
/// An optional cap on generated tokens.
|
/// An optional cap on generated tokens.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub max_tokens: Option<u32>,
|
pub max_tokens: Option<u32>,
|
||||||
/// 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
|
/// 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".
|
/// replaced by the nearest accepted one (logged), never by "off".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub thinking: Option<String>,
|
pub thinking: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, String> {
|
||||||
|
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<Self, String> {
|
||||||
|
let cfg = serde_yaml_ng::from_slice::<Option<Self>>(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.
|
/// Thinking levels, lowest first.
|
||||||
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
pub const LEVELS: [&str; 5] = ["low", "medium", "high", "xhigh", "max"];
|
||||||
|
|
||||||
@@ -63,6 +112,64 @@ fn fallback_effort(want: &str, error: &str, tried: &[String]) -> Option<String>
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::fallback_effort;
|
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]
|
#[test]
|
||||||
fn nearest_accepted_level() {
|
fn nearest_accepted_level() {
|
||||||
let anthropic =
|
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 {
|
pub struct Tool {
|
||||||
/// The function identifier exposed to the model.
|
/// The function identifier exposed to the model.
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
@@ -148,7 +255,7 @@ pub async fn complete(
|
|||||||
history: &[Entry],
|
history: &[Entry],
|
||||||
) -> Result<Vec<Entry>, String> {
|
) -> Result<Vec<Entry>, String> {
|
||||||
let want = match cfg.api.as_str() {
|
let want = match cfg.api.as_str() {
|
||||||
"openai-responses" => cfg
|
"openai-responses" | "openai-chat-completions" => cfg
|
||||||
.reasoning_effort
|
.reasoning_effort
|
||||||
.clone()
|
.clone()
|
||||||
.or_else(|| cfg.thinking.clone()),
|
.or_else(|| cfg.thinking.clone()),
|
||||||
@@ -160,6 +267,7 @@ pub async fn complete(
|
|||||||
loop {
|
loop {
|
||||||
let result = match cfg.api.as_str() {
|
let result = match cfg.api.as_str() {
|
||||||
"openai-responses" => openai(cfg, system, tools, history, effort.as_deref()).await,
|
"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-messages" => {
|
||||||
anthropic(
|
anthropic(
|
||||||
cfg,
|
cfg,
|
||||||
@@ -269,13 +377,8 @@ async fn openai(
|
|||||||
if let Some(effort) = effort {
|
if let Some(effort) = effort {
|
||||||
body["reasoning"] = json!({"effort": effort});
|
body["reasoning"] = json!({"effort": effort});
|
||||||
}
|
}
|
||||||
let url = format!("{}/v1/responses", cfg.base_url);
|
let url = format!("{}/responses", cfg.base_url.trim_end_matches('/'));
|
||||||
let resp = post(
|
let resp = post(&url, &cfg.bearer(), &body).await?;
|
||||||
&url,
|
|
||||||
&[("authorization", format!("Bearer {}", cfg.token))],
|
|
||||||
&body,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
for item in resp["output"].as_array().cloned().unwrap_or_default() {
|
||||||
match item["type"].as_str() {
|
match item["type"].as_str() {
|
||||||
@@ -389,16 +492,12 @@ async fn anthropic(
|
|||||||
body["output_config"] = json!({"effort": effort});
|
body["output_config"] = json!({"effort": effort});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let url = format!("{}/v1/messages", cfg.base_url);
|
let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/'));
|
||||||
let resp = post(
|
let mut headers = vec![("anthropic-version", "2023-06-01".to_string())];
|
||||||
&url,
|
if !cfg.token.is_empty() {
|
||||||
&[
|
headers.push(("x-api-key", cfg.token.clone()));
|
||||||
("x-api-key", cfg.token.clone()),
|
}
|
||||||
("anthropic-version", "2023-06-01".to_string()),
|
let resp = post(&url, &headers, &body).await?;
|
||||||
],
|
|
||||||
&body,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
for block in resp["content"].as_array().cloned().unwrap_or_default() {
|
||||||
match block["type"].as_str() {
|
match block["type"].as_str() {
|
||||||
@@ -423,3 +522,88 @@ async fn anthropic(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
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<Value> = 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<Entry> {
|
||||||
|
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<Vec<Entry>, 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))
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ impl Agent {
|
|||||||
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
|
async fn chat(&self, ctx: &Ctx, p: ChatParams) -> Result<String> {
|
||||||
let _guard = lock(HISTORY).await?;
|
let _guard = lock(HISTORY).await?;
|
||||||
std::fs::create_dir_all("/state/agent")?;
|
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)?;
|
let mut entries = load_history(HISTORY)?;
|
||||||
repair_tools(&mut entries)?;
|
repair_tools(&mut entries)?;
|
||||||
let current = entries.len();
|
let current = entries.len();
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ http-body-util = "0.1"
|
|||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
serde_yaml_ng = "0.10"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
toml_edit = "0.25"
|
toml_edit = "0.25"
|
||||||
wasmtime = "49"
|
wasmtime = "49"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -3,18 +3,14 @@ use anyhow::{Result, bail};
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use include_dir::{Dir, include_dir};
|
use include_dir::{Dir, include_dir};
|
||||||
use sausage::{Host, world::World};
|
use sausage::{Host, world::World};
|
||||||
use serde_json::json;
|
use std::path::{Path, PathBuf};
|
||||||
use std::{
|
|
||||||
io::Write,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
|
static AGENT: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/../agent/src");
|
||||||
mod test_runner;
|
mod test_runner;
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
#[arg(long, global = true, env = "SAUSAGE_HEADLESS", value_parser = headless)]
|
#[arg(long, global = true)]
|
||||||
headless: bool,
|
headless: bool,
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
cache_dir: Option<PathBuf>,
|
cache_dir: Option<PathBuf>,
|
||||||
@@ -60,33 +56,8 @@ impl Drop for TerminalMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setting(var: &str, question: &str, default: Option<&str>) -> Result<String> {
|
/// Creates a world with editable configuration and builds its embedded agent.
|
||||||
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.
|
|
||||||
async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
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() {
|
if dir.exists() {
|
||||||
bail!("world already exists: {}", dir.display());
|
bail!("world already exists: {}", dir.display());
|
||||||
}
|
}
|
||||||
@@ -100,10 +71,12 @@ async fn init(dir: &Path, cache: Option<&Path>) -> Result<()> {
|
|||||||
.tempdir_in(parent)?;
|
.tempdir_in(parent)?;
|
||||||
let world = World::create(&staging.path().join("world"))?;
|
let world = World::create(&staging.path().join("world"))?;
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
world.root.join("config/agent.json"),
|
world.root.join("config/agent.yaml"),
|
||||||
serde_json::to_vec_pretty(
|
include_str!("agent.yaml"),
|
||||||
&json!({"api":api,"base_url":base_url.trim_end_matches('/'),"model":model,"token":token}),
|
)?;
|
||||||
)?,
|
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 cache = cache.map(Path::to_path_buf).unwrap_or_else(|| {
|
||||||
let base = std::env::var_os("XDG_CACHE_HOME")
|
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?;
|
let output = builder.build(&source).await?;
|
||||||
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
|
std::fs::copy(output.wasm, world.plugin_dir("agent").join("plugin.wasm"))?;
|
||||||
std::fs::rename(&world.root, dir)?;
|
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());
|
println!("Start the world with: sausage run {}", dir.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -140,7 +117,7 @@ async fn main() -> Result<std::process::ExitCode> {
|
|||||||
Some(command) => command,
|
Some(command) => command,
|
||||||
None => {
|
None => {
|
||||||
let dir = PathBuf::from("world");
|
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?;
|
init(&dir, cli.cache_dir.as_deref()).await?;
|
||||||
}
|
}
|
||||||
Cmd::Run { dir }
|
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::Init { dir } => init(&dir, cli.cache_dir.as_deref()).await?,
|
||||||
Cmd::Run { dir } => {
|
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 =
|
let mut term =
|
||||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
|
||||||
let mut interrupt =
|
let mut interrupt =
|
||||||
@@ -170,7 +153,7 @@ async fn main() -> Result<std::process::ExitCode> {
|
|||||||
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
_=interrupt.recv()=>return Ok(std::process::ExitCode::SUCCESS),
|
||||||
};
|
};
|
||||||
let mut notifications = host.subscribe();
|
let mut notifications = host.subscribe();
|
||||||
let notification_task = cli.headless.then(|| {
|
let notification_task = headless.then(|| {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match notifications.recv().await {
|
match notifications.recv().await {
|
||||||
@@ -186,8 +169,8 @@ async fn main() -> Result<std::process::ExitCode> {
|
|||||||
let exit = tokio::select! {
|
let exit = tokio::select! {
|
||||||
_=host.exit_requested()=>true,
|
_=host.exit_requested()=>true,
|
||||||
_=term.recv()=>false,
|
_=term.recv()=>false,
|
||||||
_=interrupt.recv(), if cli.headless=>false,
|
_=interrupt.recv(), if headless=>false,
|
||||||
result = console, if !cli.headless => {
|
result = console, if !headless => {
|
||||||
console_result=result;
|
console_result=result;
|
||||||
false
|
false
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ impl Host {
|
|||||||
/// Starts discovery in the background, then waits for each boot service in order.
|
/// Starts discovery in the background, then waits for each boot service in order.
|
||||||
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
|
pub(crate) async fn boot(&self) -> anyhow::Result<()> {
|
||||||
self.watch_plugins()?;
|
self.watch_plugins()?;
|
||||||
let boot: Vec<Value> = serde_json::from_slice(
|
let boot: Vec<Value> = serde_yaml_ng::from_slice(
|
||||||
&tokio::fs::read(self.0.world.root.join("config/boot.json")).await?,
|
&tokio::fs::read(self.0.world.root.join("config/boot.yaml")).await?,
|
||||||
)?;
|
)?;
|
||||||
for service in boot {
|
for service in boot {
|
||||||
self.start_service(
|
self.start_service(
|
||||||
|
|||||||
+1
-1
@@ -27,7 +27,7 @@ impl World {
|
|||||||
for dir in ["plugins", "state/core", "config"] {
|
for dir in ["plugins", "state/core", "config"] {
|
||||||
std::fs::create_dir_all(path.join(dir))?;
|
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)
|
Self::open(path)
|
||||||
}
|
}
|
||||||
/// Return the folder that contains a plugin component.
|
/// Return the folder that contains a plugin component.
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ async fn review_shutdown_suppresses_hooks() {
|
|||||||
old["x-sausage-grace"] = json!(60);
|
old["x-sausage-grace"] = json!(60);
|
||||||
world.plugin("notes", old, document("serve"));
|
world.plugin("notes", old, document("serve"));
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
world.0.join("config/boot.json"),
|
world.0.join("config/boot.yaml"),
|
||||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -766,7 +766,7 @@ async fn restarts_service() {
|
|||||||
let world = TestWorld::new();
|
let world = TestWorld::new();
|
||||||
world.plugin("notes", document("serve"), document("serve"));
|
world.plugin("notes", document("serve"), document("serve"));
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
world.0.join("config/boot.json"),
|
world.0.join("config/boot.yaml"),
|
||||||
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
json!([{"method":"notes.serve","params":{}}]).to_string(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+84
-15
@@ -13,18 +13,22 @@ use tokio::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
/// The two HTTP contracts supported by the agent.
|
/// 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.
|
/// A captured request and the script's delayed HTTP response.
|
||||||
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
|
type Exchange = (String, Value, oneshot::Sender<(u16, Value)>);
|
||||||
/// The script controls each response, including concurrent requests.
|
/// The script controls each response, including concurrent requests.
|
||||||
struct Llm {
|
pub(super) struct Llm {
|
||||||
url: String,
|
pub(super) url: String,
|
||||||
api: &'static str,
|
api: &'static str,
|
||||||
requests: mpsc::UnboundedReceiver<Exchange>,
|
requests: mpsc::UnboundedReceiver<Exchange>,
|
||||||
task: tokio::task::JoinHandle<()>,
|
task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
impl Llm {
|
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 listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||||
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
|
let (tx, requests) = mpsc::unbounded_channel::<Exchange>();
|
||||||
@@ -37,6 +41,11 @@ impl Llm {
|
|||||||
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
|
service_fn(move |request: hyper::Request<hyper::body::Incoming>| {
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
async move {
|
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 path = request.uri().path().to_string();
|
||||||
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
let bytes = request.into_body().collect().await.unwrap().to_bytes();
|
||||||
let body = serde_json::from_slice(&bytes).unwrap();
|
let body = serde_json::from_slice(&bytes).unwrap();
|
||||||
@@ -66,7 +75,7 @@ impl Llm {
|
|||||||
task,
|
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) =
|
let (path, body, reply) =
|
||||||
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
|
tokio::time::timeout(Duration::from_secs(60), self.requests.recv())
|
||||||
.await
|
.await
|
||||||
@@ -76,6 +85,8 @@ impl Llm {
|
|||||||
path,
|
path,
|
||||||
if self.api == APIS[0] {
|
if self.api == APIS[0] {
|
||||||
"/v1/responses"
|
"/v1/responses"
|
||||||
|
} else if self.api == APIS[2] {
|
||||||
|
"/v1/chat/completions"
|
||||||
} else {
|
} else {
|
||||||
"/v1/messages"
|
"/v1/messages"
|
||||||
}
|
}
|
||||||
@@ -83,8 +94,11 @@ impl Llm {
|
|||||||
assert_eq!(body["model"], "scripted-test");
|
assert_eq!(body["model"], "scripted-test");
|
||||||
(body, reply)
|
(body, reply)
|
||||||
}
|
}
|
||||||
fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
|
pub(super) fn answer(&self, text: &str, calls: &[(&str, Value)]) -> Value {
|
||||||
if self.api == APIS[0] {
|
if self.api == APIS[2] {
|
||||||
|
let calls: Vec<Value> = 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 =
|
let mut output =
|
||||||
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
|
vec![json!({"type":"message","content":[{"type":"output_text","text":text}]})];
|
||||||
for (n, (name, args)) in calls.iter().enumerate() {
|
for (n, (name, args)) in calls.iter().enumerate() {
|
||||||
@@ -128,7 +142,7 @@ impl TestWorld {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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)
|
Self(path)
|
||||||
}
|
}
|
||||||
async fn start(&self) -> Host {
|
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()
|
body[if api == APIS[0] { "input" } else { "messages" }].clone()
|
||||||
}
|
}
|
||||||
fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
||||||
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"]
|
body["input"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -185,7 +207,54 @@ fn outputs(body: &Value, api: &str) -> Vec<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn final_answers_and_history_survive_restart() {
|
async fn final_answers_and_history_survive_restart() {
|
||||||
for api in APIS {
|
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.
|
/// Provider-private items are replayed only for their original API and model.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn private_items_are_scoped_to_the_model() {
|
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 mut server = Llm::new(api).await;
|
||||||
let world = TestWorld::new(&server);
|
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 =
|
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");
|
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();
|
std::fs::create_dir_all(world.0.join("state/agent")).unwrap();
|
||||||
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
let mut entries = vec![json!({"kind":"user","text":"old turn"})];
|
||||||
for (model, marker) in [
|
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.
|
/// Separate assistant messages retain a separator in the final answer.
|
||||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||||
async fn final_texts_have_a_separator() {
|
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 mut server = Llm::new(api).await;
|
||||||
let world = TestWorld::new(&server);
|
let world = TestWorld::new(&server);
|
||||||
let host = world.start().await;
|
let host = world.start().await;
|
||||||
|
|||||||
+42
-11
@@ -59,11 +59,12 @@ fn install_probe(world: &Path) {
|
|||||||
async fn init_builds_agent() {
|
async fn init_builds_agent() {
|
||||||
let temporary = tempfile::tempdir().unwrap();
|
let temporary = tempfile::tempdir().unwrap();
|
||||||
let world = temporary.path().join("world");
|
let world = temporary.path().join("world");
|
||||||
|
// Legacy configuration variables must not affect the templates.
|
||||||
let configured = || {
|
let configured = || {
|
||||||
let mut command = binary();
|
let mut command = binary();
|
||||||
command
|
command
|
||||||
.current_dir(temporary.path())
|
.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_BASE_URL", "http://127.0.0.1:1/")
|
||||||
.env("SAUSAGE_MODEL", "init-test")
|
.env("SAUSAGE_MODEL", "init-test")
|
||||||
.env("SAUSAGE_API_TOKEN", "fake-token")
|
.env("SAUSAGE_API_TOKEN", "fake-token")
|
||||||
@@ -72,11 +73,13 @@ async fn init_builds_agent() {
|
|||||||
};
|
};
|
||||||
let failed = configured()
|
let failed = configured()
|
||||||
.arg("init")
|
.arg("init")
|
||||||
|
.env("SAUSAGE_HEADLESS", "not-a-boolean")
|
||||||
.env("RUSTC", "/nonexistent-sausage-rustc")
|
.env("RUSTC", "/nonexistent-sausage-rustc")
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!failed.status.success());
|
assert!(!failed.status.success());
|
||||||
|
assert!(String::from_utf8_lossy(&failed.stderr).contains("nonexistent-sausage-rustc"));
|
||||||
assert!(!world.exists());
|
assert!(!world.exists());
|
||||||
let output = configured().output().await.unwrap();
|
let output = configured().output().await.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -88,15 +91,14 @@ async fn init_builds_agent() {
|
|||||||
assert!(
|
assert!(
|
||||||
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
String::from_utf8_lossy(&output.stdout).contains("first agent build takes a few minutes")
|
||||||
);
|
);
|
||||||
let config: Value =
|
let mut config: Value =
|
||||||
serde_json::from_slice(&std::fs::read(world.join("config/agent.json")).unwrap()).unwrap();
|
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!(
|
assert_eq!(
|
||||||
config,
|
serde_yaml_ng::from_slice::<Value>(&std::fs::read(world.join("config/boot.yaml")).unwrap())
|
||||||
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::<Value>(&std::fs::read(world.join("config/boot.json")).unwrap())
|
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
json!([])
|
json!([])
|
||||||
);
|
);
|
||||||
@@ -131,6 +133,35 @@ async fn init_builds_agent() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|method| method["name"] == "chat")
|
.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;
|
host.stop().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,7 +209,7 @@ async fn signals_work_during_boot() {
|
|||||||
] {
|
] {
|
||||||
let (_folder, world) = world();
|
let (_folder, world) = world();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
world.join("config/boot.json"),
|
world.join("config/boot.yaml"),
|
||||||
json!([
|
json!([
|
||||||
{"method":"core.lock","params":{"path":"boot"}},
|
{"method":"core.lock","params":{"path":"boot"}},
|
||||||
{"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();
|
let (_folder, world) = world();
|
||||||
install_probe(&world);
|
install_probe(&world);
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
world.join("config/boot.json"),
|
world.join("config/boot.yaml"),
|
||||||
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
json!([{"method":"probe.serve","params":{}}]).to_string(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -667,8 +667,8 @@ async fn reserved_world_roots_prevent_startup() {
|
|||||||
async fn boot_waits_for_readiness_in_order() {
|
async fn boot_waits_for_readiness_in_order() {
|
||||||
let w = TestWorld::new();
|
let w = TestWorld::new();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
w.0.join("config/boot.json"),
|
w.0.join("config/boot.yaml"),
|
||||||
serde_json::to_vec(&json!([
|
serde_yaml_ng::to_string(&json!([
|
||||||
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
|
{"method":"probe.write","params":{"path":"/state/boot","text":"ready"}},
|
||||||
{"method":"probe.read","params":{"path":"/state/boot"}}
|
{"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"}}
|
{"method":"probe.write","params":{"path":"/state/continued","text":"yes"}}
|
||||||
]);
|
]);
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
w.0.join("config/boot.json"),
|
w.0.join("config/boot.yaml"),
|
||||||
serde_json::to_vec(&services).unwrap(),
|
serde_json::to_vec(&services).unwrap(),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1423,7 +1423,7 @@ async fn failed_boot_services_are_logged_and_boot_continues() {
|
|||||||
async fn boot_services_are_detached() {
|
async fn boot_services_are_detached() {
|
||||||
let w = TestWorld::new();
|
let w = TestWorld::new();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
w.0.join("config/boot.json"),
|
w.0.join("config/boot.yaml"),
|
||||||
br#"[{"method":"probe.serve","params":{}}]"#,
|
br#"[{"method":"probe.serve","params":{}}]"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1855,7 +1855,7 @@ async fn nested_parent_environment() {
|
|||||||
async fn service_parent_environment() {
|
async fn service_parent_environment() {
|
||||||
let w = TestWorld::new();
|
let w = TestWorld::new();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
w.0.join("config/boot.json"),
|
w.0.join("config/boot.yaml"),
|
||||||
br#"[{"method":"probe.forgotten","params":{}}]"#,
|
br#"[{"method":"probe.forgotten","params":{}}]"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1936,7 +1936,7 @@ async fn detached_processes_need_explicit_termination() {
|
|||||||
async fn core_exit_sets_executable_status_75() {
|
async fn core_exit_sets_executable_status_75() {
|
||||||
let w = TestWorld::new();
|
let w = TestWorld::new();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
w.0.join("config/boot.json"),
|
w.0.join("config/boot.yaml"),
|
||||||
br#"[{"method":"core.exit","params":{}}]"#,
|
br#"[{"method":"core.exit","params":{}}]"#,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
emptyPath="$root/empty-bin"
|
emptyPath="$root/empty-bin"
|
||||||
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
|
mkdir -p "$emptyPath" "$root/home" "$root/cargo-home"
|
||||||
mkdir -p "$world/plugins" "$world/state" "$world/config"
|
mkdir -p "$world/plugins" "$world/state" "$world/config"
|
||||||
echo "[]" > "$world/config/boot.json"
|
echo "[]" > "$world/config/boot.yaml"
|
||||||
source="$world/plugins/probe/source"
|
source="$world/plugins/probe/source"
|
||||||
mkdir -p "$source/src" "$source/tests"
|
mkdir -p "$source/src" "$source/tests"
|
||||||
cat > "$source/Cargo.toml" <<'TOML'
|
cat > "$source/Cargo.toml" <<'TOML'
|
||||||
|
|||||||
+1
-1
@@ -199,7 +199,7 @@
|
|||||||
//! # A service with a fixed endpoint
|
//! # A service with a fixed endpoint
|
||||||
//!
|
//!
|
||||||
//! A service can publish a fixed path in `/run`. The core clears `/run` when the host starts.
|
//! 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`.
|
//! Clients can then connect to `/run/secrets`.
|
||||||
//!
|
//!
|
||||||
//! ```no_run
|
//! ```no_run
|
||||||
|
|||||||
Reference in New Issue
Block a user