From 0df1d6b5b358f9858960ed159fd780cd402f42a7 Mon Sep 17 00:00:00 2001 From: emmettlu Date: Mon, 3 Aug 2026 09:47:59 +0800 Subject: [PATCH] feat: add readline mode and TOML configuration support --- bitbi/.gitkeep | 0 emmett/.dockerignore | 8 + emmett/.gitignore | 2 + emmett/Cargo.toml | 2 + emmett/Dockerfile.runtime | 8 + emmett/README.md | 42 +++ emmett/README_CN.md | 38 +++ emmett/agentos.example.toml.template | 11 + emmett/crates/agentos-cli/Cargo.toml | 1 + emmett/crates/agentos-cli/src/main.rs | 81 +++++- emmett/crates/agentos-runtime/Cargo.toml | 1 + emmett/crates/agentos-runtime/src/lib.rs | 352 +++++++++++++++++++---- emmett/run.sh | 68 +++++ 13 files changed, 557 insertions(+), 57 deletions(-) create mode 100644 bitbi/.gitkeep create mode 100644 emmett/.dockerignore create mode 100644 emmett/.gitignore create mode 100644 emmett/Dockerfile.runtime create mode 100644 emmett/agentos.example.toml.template create mode 100755 emmett/run.sh diff --git a/bitbi/.gitkeep b/bitbi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/emmett/.dockerignore b/emmett/.dockerignore new file mode 100644 index 0000000..7cd9de5 --- /dev/null +++ b/emmett/.dockerignore @@ -0,0 +1,8 @@ +* +!Dockerfile.runtime +!target/ +target/* +!target/debug/ +target/debug/* +!target/debug/agentos +!target/debug/agentos-npu-bridge diff --git a/emmett/.gitignore b/emmett/.gitignore new file mode 100644 index 0000000..e66d89b --- /dev/null +++ b/emmett/.gitignore @@ -0,0 +1,2 @@ +/agentos.toml +/agentos.example.toml diff --git a/emmett/Cargo.toml b/emmett/Cargo.toml index 41e1851..d818b3a 100644 --- a/emmett/Cargo.toml +++ b/emmett/Cargo.toml @@ -46,6 +46,7 @@ rustix = { "thread", ] } +rustyline = "18" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0" @@ -70,6 +71,7 @@ tokio = { "time", ] } +toml = "1" url = "2" uuid = { version = "1", features = ["serde", "v4"] } diff --git a/emmett/Dockerfile.runtime b/emmett/Dockerfile.runtime new file mode 100644 index 0000000..1e37229 --- /dev/null +++ b/emmett/Dockerfile.runtime @@ -0,0 +1,8 @@ +FROM archlinux:base + +# This image contains only host-built runtime binaries. +COPY target/debug/agentos /usr/local/bin/agentos +COPY target/debug/agentos-npu-bridge /usr/local/bin/agentos-npu-bridge + +USER 65534:65534 +ENTRYPOINT ["/usr/local/bin/agentos"] diff --git a/emmett/README.md b/emmett/README.md index 4da555d..347a574 100644 --- a/emmett/README.md +++ b/emmett/README.md @@ -160,6 +160,48 @@ cargo run -p agentos-cli --bin agentos -- \ `OPENAI_API_KEY` is accepted as a fallback. A non-empty credential is currently required by the remote adapter. Streaming is not implemented. +### TOML configuration and readline mode + +`--config` loads a TOML file before environment and CLI overrides are applied. +Copy the template to the ignored test configuration, restrict its permissions, +and fill in the API key: + +```bash +cp agentos.example.toml.template agentos.example.toml +chmod 600 agentos.example.toml +``` + +Run an interactive session on the host with: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --config agentos.example.toml agent readline +``` + +The readline session supports in-process history, `/help`, `/exit`, `/quit`, +Ctrl-C, and Ctrl-D. Each entered line starts an independent bounded agent run; +the runtime and durable audit state remain open for the session. + +To compile on the physical host and place only the binaries in a runtime +container: + +```bash +cargo build -p agentos-cli --bins +docker build -f Dockerfile.runtime -t agentos-cli:local . +./run.sh +``` + +`run.sh` verifies that the config is private, the runtime image exists, and the +state directory is on Btrfs. Override its defaults with `AGENTOS_CONFIG`, +`AGENTOS_CONTAINER_IMAGE`, or `AGENTOS_CONTAINER_STATE_DIR`. + +The runtime container shares the host kernel but contains no source tree or +Rust toolchain. The dedicated `/state` bind mount preserves the host Btrfs +filesystem type, so worldline uses real Btrfs subvolumes and snapshots even +though the container root filesystem uses overlayfs. Network access remains +enabled for remote inference. Do not commit `agentos.example.toml`; it is +ignored because it can contain credentials. + ## Local model transport A persistent model on the same Linux host must expose a filesystem Unix domain diff --git a/emmett/README_CN.md b/emmett/README_CN.md index bd67f4c..4ce559c 100644 --- a/emmett/README_CN.md +++ b/emmett/README_CN.md @@ -147,6 +147,44 @@ cargo run -p agentos-cli --bin agentos -- \ 也可以用 `OPENAI_API_KEY` 作为 fallback。远程 adapter 当前要求非空 credential。暂不支持 streaming。 +### TOML 配置与 readline 模式 + +`--config` 先加载 TOML 文件, 然后再应用环境变量和 CLI 参数覆盖。把模板复制 +为已被忽略的测试配置, 限制文件权限并手动填写 API key: + +```bash +cp agentos.example.toml.template agentos.example.toml +chmod 600 agentos.example.toml +``` + +在宿主机启动交互会话: + +```bash +cargo run -p agentos-cli --bin agentos -- \ + --config agentos.example.toml agent readline +``` + +readline 会话支持进程内历史、`/help`、`/exit`、`/quit`、Ctrl-C 和 Ctrl-D。 +每行输入会启动一次独立且有预算限制的 agent run, 但 runtime 和持久审计状态会 +在整个会话中保持打开。 + +如果要在物理机编译, 并且只把二进制放入运行容器: + +```bash +cargo build -p agentos-cli --bins +docker build -f Dockerfile.runtime -t agentos-cli:local . +./run.sh +``` + +`run.sh` 会检查配置文件权限、运行镜像是否存在, 以及状态目录是否位于 Btrfs。 +可以用 `AGENTOS_CONFIG`、`AGENTOS_CONTAINER_IMAGE` 或 +`AGENTOS_CONTAINER_STATE_DIR` 覆盖默认值。 + +运行容器共享宿主机内核, 但不包含源码或 Rust 工具链。专用 `/state` bind mount +会保留宿主 Btrfs 文件系统类型, 因此即使容器根文件系统是 overlayfs, worldline +仍会使用真实 Btrfs subvolume 和 snapshot。远程推理需要保留网络访问。不要提交 +`agentos.example.toml`; 该文件可能包含 credential, 已被 Git 忽略。 + ## 本机模型传输 同一台 Linux 主机上的常驻模型必须暴露 filesystem Unix domain socket。不要 diff --git a/emmett/agentos.example.toml.template b/emmett/agentos.example.toml.template new file mode 100644 index 0000000..464797a --- /dev/null +++ b/emmett/agentos.example.toml.template @@ -0,0 +1,11 @@ +state_dir = "/state" +backend = "openai_compatible" +agent_id = "interactive-agent" +model_timeout_seconds = 60 + +[openai_compatible] +api_key = "" +base_url = "https://hualai-claw.hualaikeji.com/v1" +model = "kimi-k3" +api = "chat_completions" +max_retries = 2 diff --git a/emmett/crates/agentos-cli/Cargo.toml b/emmett/crates/agentos-cli/Cargo.toml index 9b7efc4..1eea43d 100644 --- a/emmett/crates/agentos-cli/Cargo.toml +++ b/emmett/crates/agentos-cli/Cargo.toml @@ -26,6 +26,7 @@ agentos-tools.workspace = true agentos-worldline.workspace = true anyhow.workspace = true clap.workspace = true +rustyline.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/emmett/crates/agentos-cli/src/main.rs b/emmett/crates/agentos-cli/src/main.rs index 6c037c8..a88658d 100644 --- a/emmett/crates/agentos-cli/src/main.rs +++ b/emmett/crates/agentos-cli/src/main.rs @@ -2,8 +2,9 @@ use agentos_core::{CommitId, WorldlineId}; use agentos_kernel::ProcessCredentials; use agentos_runtime::{BackendSelection, Runtime, RuntimeConfig, doctor}; use agentos_worldline::WorldlineStore; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; +use rustyline::{DefaultEditor, error::ReadlineError}; use serde_json::json; use std::path::PathBuf; @@ -13,6 +14,8 @@ struct Cli { #[arg(long, global = true)] state_dir: Option, #[arg(long, global = true)] + config: Option, + #[arg(long, global = true)] backend: Option, #[arg(long, global = true)] fake_model: bool, @@ -42,6 +45,7 @@ enum Command { #[derive(Debug, Subcommand)] enum AgentCommand { Once { prompt: String }, + Readline, } #[derive(Debug, Subcommand)] @@ -79,7 +83,7 @@ async fn main() -> Result<()> { if (cli.agent_once.is_some() || cli.doctor) && cli.command.is_some() { bail!("legacy flags --doctor/--agent-once cannot be combined with a subcommand"); } - let mut config = RuntimeConfig::from_env()?; + let mut config = RuntimeConfig::load(cli.config.as_deref())?; if let Some(state_dir) = &cli.state_dir { config.state_dir.clone_from(state_dir); } @@ -102,6 +106,9 @@ async fn main() -> Result<()> { Some(Command::Agent { command: AgentCommand::Once { prompt }, }) => run_agent(config, &prompt, cli.json).await, + Some(Command::Agent { + command: AgentCommand::Readline, + }) => run_readline(config, cli.json).await, Some(Command::Worldline { command }) => run_worldline(&config, command, cli.json), Some(Command::Doctor) => unreachable!(), None => bail!("select --doctor, --agent-once, or a subcommand"), @@ -111,6 +118,51 @@ async fn main() -> Result<()> { async fn run_agent(config: RuntimeConfig, prompt: &str, json_output: bool) -> Result<()> { let runtime = Runtime::initialize(config)?; let result = runtime.run_once(prompt).await?; + print_agent_result(&result, json_output)?; + Ok(()) +} + +async fn run_readline(config: RuntimeConfig, json_output: bool) -> Result<()> { + let runtime = Runtime::initialize(config)?; + let mut editor = DefaultEditor::new().context("initialize readline editor")?; + eprintln!("AgentOS readline mode. Use /help for commands."); + loop { + let line = match editor.readline("agentos> ") { + Ok(line) => line, + Err(ReadlineError::Interrupted) => { + eprintln!("^C"); + continue; + } + Err(ReadlineError::Eof) => { + eprintln!(); + break; + } + Err(error) => return Err(error).context("read interactive input"), + }; + let input = line.trim(); + if input.is_empty() { + continue; + } + match input { + "/exit" | "/quit" => break, + "/help" => { + eprintln!("/help show this help\n/exit exit AgentOS\n/quit exit AgentOS"); + continue; + } + _ => {} + } + let _ = editor + .add_history_entry(input) + .context("add readline history entry")?; + match runtime.run_once(input).await { + Ok(result) => print_agent_result(&result, json_output)?, + Err(error) => eprintln!("error: {error}"), + } + } + Ok(()) +} + +fn print_agent_result(result: &agentos_agent::AgentRunResult, json_output: bool) -> Result<()> { if json_output { println!("{}", serde_json::to_string_pretty(&result)?); } else { @@ -199,3 +251,28 @@ fn print_value(value: &serde_json::Value, pretty: bool) { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_readline_with_config() { + let cli = Cli::try_parse_from([ + "agentos", + "--config", + "/config/agentos.toml", + "agent", + "readline", + ]) + .expect("valid readline command"); + + assert_eq!(cli.config, Some(PathBuf::from("/config/agentos.toml"))); + assert!(matches!( + cli.command, + Some(Command::Agent { + command: AgentCommand::Readline + }) + )); + } +} diff --git a/emmett/crates/agentos-runtime/Cargo.toml b/emmett/crates/agentos-runtime/Cargo.toml index a699d9e..f2788ec 100644 --- a/emmett/crates/agentos-runtime/Cargo.toml +++ b/emmett/crates/agentos-runtime/Cargo.toml @@ -18,6 +18,7 @@ agentos-worldline.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +toml.workspace = true [lints] workspace = true diff --git a/emmett/crates/agentos-runtime/src/lib.rs b/emmett/crates/agentos-runtime/src/lib.rs index 9b6aad6..bd51730 100644 --- a/emmett/crates/agentos-runtime/src/lib.rs +++ b/emmett/crates/agentos-runtime/src/lib.rs @@ -77,71 +77,258 @@ pub struct RuntimeConfig { pub calrt_library: Option, } +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct RuntimeConfigFile { + state_dir: Option, + backend: Option, + agent_id: Option, + owner_uid: Option, + agent_uid: Option, + agent_gid: Option, + model_timeout_seconds: Option, + openai_compatible: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct OpenAiCompatibleConfigFile { + api_key: Option, + base_url: Option, + model: Option, + api: Option, + max_retries: Option, +} + impl RuntimeConfig { + pub fn load(config_path: Option<&Path>) -> Result { + let mut config = Self::defaults()?; + if let Some(path) = config_path { + let source = + std::fs::read_to_string(path).map_err(|source| RuntimeError::ConfigFileIo { + path: path.to_path_buf(), + source, + })?; + let file = toml::from_str(&source) + .map_err(|_| RuntimeError::InvalidConfigFile(path.to_path_buf()))?; + config.apply_file(file)?; + } + config.apply_env()?; + Ok(config) + } + pub fn from_env() -> Result { + Self::load(None) + } + + fn defaults() -> Result { let credentials = ProcessCredentials::current(); - let state_dir = - std::env::var_os("AGENTOS_STATE_DIR").map_or_else(default_state_dir, PathBuf::from); - let backend = std::env::var("AGENTOS_MODEL_BACKEND") - .unwrap_or_else(|_| "auto".into()) - .trim() - .to_ascii_lowercase() - .parse()?; - let openai_compatible_api = match std::env::var("AGENTOS_OPENAI_COMPATIBLE_API") - .unwrap_or_else(|_| "chat_completions".into()) - .as_str() - { - "chat_completions" => OpenAiCompatibleApi::ChatCompletions, - "responses" => OpenAiCompatibleApi::Responses, - value => return Err(RuntimeError::InvalidApiMode(value.into())), - }; - let agent_id = AgentId::parse( - std::env::var("AGENTOS_AGENT_ID").unwrap_or_else(|_| "default-agent".into()), - )?; Ok(Self { - state_dir, - backend, - agent_id, - owner_uid: optional_u32("AGENTOS_OWNER_UID")?.or(Some(credentials.real_uid)), - agent_uid: optional_u32("AGENTOS_AGENT_UID")?.or(Some(credentials.effective_uid)), - agent_gid: optional_u32("AGENTOS_AGENT_GID")?.or(Some(credentials.effective_gid)), - openai_compatible_api_key: std::env::var("AGENTOS_OPENAI_COMPATIBLE_API_KEY") + state_dir: default_state_dir(), + backend: BackendSelection::Auto, + agent_id: AgentId::parse("default-agent")?, + owner_uid: Some(credentials.real_uid), + agent_uid: Some(credentials.effective_uid), + agent_gid: Some(credentials.effective_gid), + openai_compatible_api_key: None, + openai_compatible_base_url: "https://api.openai.com/v1".into(), + openai_compatible_model: String::new(), + openai_compatible_api: OpenAiCompatibleApi::ChatCompletions, + openai_compatible_max_retries: 2, + npu_bridge_command: Vec::new(), + npu_server_socket: PathBuf::from(DEFAULT_NPU_SERVER_SOCKET), + npu_candle_calbin: None, + npu_candle_tokenizer: None, + npu_candle_device_index: 0, + subprocess_command: Vec::new(), + model_timeout: Duration::from_mins(4), + budget: AgentBudget { + max_iterations: 8, + max_tool_calls: 16, + max_elapsed: Duration::from_mins(2), + max_output_tokens_per_turn: 2048, + max_total_tokens: 64 * 1024, + max_safe_workers: 4, + }, + service_allowlist: BTreeSet::new(), + tool_output_limit_bytes: 64 * 1024, + tool_argument_limit_bytes: 64 * 1024, + calrt_library: None, + }) + } + + fn apply_file(&mut self, file: RuntimeConfigFile) -> Result<(), RuntimeError> { + if let Some(value) = file.state_dir { + self.state_dir = value; + } + if let Some(value) = file.backend { + self.backend = value; + } + if let Some(value) = file.agent_id { + self.agent_id = AgentId::parse(value)?; + } + if let Some(value) = file.owner_uid { + self.owner_uid = Some(value); + } + if let Some(value) = file.agent_uid { + self.agent_uid = Some(value); + } + if let Some(value) = file.agent_gid { + self.agent_gid = Some(value); + } + if let Some(value) = file.model_timeout_seconds { + self.model_timeout = Duration::from_secs(value); + } + if let Some(openai) = file.openai_compatible { + if let Some(value) = openai.api_key { + self.openai_compatible_api_key = (!value.is_empty()).then_some(value); + } + if let Some(value) = openai.base_url { + self.openai_compatible_base_url = value; + } + if let Some(value) = openai.model { + self.openai_compatible_model = value; + } + if let Some(value) = openai.api { + self.openai_compatible_api = value; + } + if let Some(value) = openai.max_retries { + self.openai_compatible_max_retries = value; + } + } + Ok(()) + } + + fn apply_env(&mut self) -> Result<(), RuntimeError> { + if let Some(value) = std::env::var_os("AGENTOS_STATE_DIR") { + self.state_dir = PathBuf::from(value); + } + if let Ok(value) = std::env::var("AGENTOS_MODEL_BACKEND") { + self.backend = value.trim().to_ascii_lowercase().parse()?; + } + if let Ok(value) = std::env::var("AGENTOS_AGENT_ID") { + self.agent_id = AgentId::parse(value)?; + } + self.apply_identity_env()?; + self.apply_openai_env()?; + self.apply_model_env()?; + self.apply_budget_env()?; + self.apply_tool_env()?; + Ok(()) + } + + fn apply_identity_env(&mut self) -> Result<(), RuntimeError> { + if std::env::var_os("AGENTOS_OWNER_UID").is_some() { + self.owner_uid = optional_u32("AGENTOS_OWNER_UID")?; + } + if std::env::var_os("AGENTOS_AGENT_UID").is_some() { + self.agent_uid = optional_u32("AGENTOS_AGENT_UID")?; + } + if std::env::var_os("AGENTOS_AGENT_GID").is_some() { + self.agent_gid = optional_u32("AGENTOS_AGENT_GID")?; + } + Ok(()) + } + + fn apply_openai_env(&mut self) -> Result<(), RuntimeError> { + if std::env::var_os("AGENTOS_OPENAI_COMPATIBLE_API_KEY").is_some() + || std::env::var_os("OPENAI_API_KEY").is_some() + { + self.openai_compatible_api_key = std::env::var("AGENTOS_OPENAI_COMPATIBLE_API_KEY") .ok() .or_else(|| std::env::var("OPENAI_API_KEY").ok()) - .filter(|value| !value.is_empty()), - openai_compatible_base_url: std::env::var("AGENTOS_OPENAI_COMPATIBLE_BASE_URL") - .unwrap_or_else(|_| "https://api.openai.com/v1".into()), - openai_compatible_model: std::env::var("AGENTOS_OPENAI_COMPATIBLE_MODEL") - .unwrap_or_default(), - openai_compatible_api, - openai_compatible_max_retries: env_usize("AGENTOS_OPENAI_COMPATIBLE_MAX_RETRIES", 2)?, - npu_bridge_command: command_env("AGENTOS_NPU_BRIDGE_COMMAND"), - npu_server_socket: std::env::var_os("AGENTOS_NPU_SERVER_SOCKET") - .map_or_else(|| PathBuf::from(DEFAULT_NPU_SERVER_SOCKET), PathBuf::from), - npu_candle_calbin: std::env::var_os("AGENTOS_NPU_CALBIN").map(PathBuf::from), - npu_candle_tokenizer: std::env::var_os("AGENTOS_NPU_TOKENIZER").map(PathBuf::from), - npu_candle_device_index: env_u8("AGENTOS_NPU_DEVICE_INDEX", 0)?, - subprocess_command: command_env("AGENTOS_MODEL_COMMAND"), - model_timeout: Duration::from_secs(env_u64("AGENTOS_MODEL_TIMEOUT_S", 240)?), - budget: AgentBudget { - max_iterations: env_usize("AGENTOS_AGENT_MAX_ITERATIONS", 8)?, - max_tool_calls: env_usize("AGENTOS_AGENT_MAX_TOOL_CALLS", 16)?, - max_elapsed: Duration::from_secs(env_u64("AGENTOS_AGENT_MAX_ELAPSED_S", 120)?), - max_output_tokens_per_turn: env_u32("AGENTOS_AGENT_MAX_OUTPUT_TOKENS", 2048)?, - max_total_tokens: env_u64("AGENTOS_AGENT_MAX_TOTAL_TOKENS", 64 * 1024)?, - max_safe_workers: env_usize("AGENTOS_AGENT_MAX_SAFE_WORKERS", 4)?, - }, - service_allowlist: std::env::var("AGENTOS_SERVICE_INSPECT_ALLOWLIST") - .unwrap_or_default() + .filter(|value| !value.is_empty()); + } + if let Ok(value) = std::env::var("AGENTOS_OPENAI_COMPATIBLE_BASE_URL") { + self.openai_compatible_base_url = value; + } + if let Ok(value) = std::env::var("AGENTOS_OPENAI_COMPATIBLE_MODEL") { + self.openai_compatible_model = value; + } + if let Ok(value) = std::env::var("AGENTOS_OPENAI_COMPATIBLE_API") { + self.openai_compatible_api = match value.as_str() { + "chat_completions" => OpenAiCompatibleApi::ChatCompletions, + "responses" => OpenAiCompatibleApi::Responses, + _ => return Err(RuntimeError::InvalidApiMode(value)), + }; + } + self.openai_compatible_max_retries = env_usize( + "AGENTOS_OPENAI_COMPATIBLE_MAX_RETRIES", + self.openai_compatible_max_retries, + )?; + Ok(()) + } + + fn apply_model_env(&mut self) -> Result<(), RuntimeError> { + if std::env::var_os("AGENTOS_NPU_BRIDGE_COMMAND").is_some() { + self.npu_bridge_command = command_env("AGENTOS_NPU_BRIDGE_COMMAND"); + } + if let Some(value) = std::env::var_os("AGENTOS_NPU_SERVER_SOCKET") { + self.npu_server_socket = PathBuf::from(value); + } + if let Some(value) = std::env::var_os("AGENTOS_NPU_CALBIN") { + self.npu_candle_calbin = Some(PathBuf::from(value)); + } + if let Some(value) = std::env::var_os("AGENTOS_NPU_TOKENIZER") { + self.npu_candle_tokenizer = Some(PathBuf::from(value)); + } + self.npu_candle_device_index = + env_u8("AGENTOS_NPU_DEVICE_INDEX", self.npu_candle_device_index)?; + if std::env::var_os("AGENTOS_MODEL_COMMAND").is_some() { + self.subprocess_command = command_env("AGENTOS_MODEL_COMMAND"); + } + self.model_timeout = Duration::from_secs(env_u64( + "AGENTOS_MODEL_TIMEOUT_S", + self.model_timeout.as_secs(), + )?); + Ok(()) + } + + fn apply_budget_env(&mut self) -> Result<(), RuntimeError> { + self.budget.max_iterations = + env_usize("AGENTOS_AGENT_MAX_ITERATIONS", self.budget.max_iterations)?; + self.budget.max_tool_calls = + env_usize("AGENTOS_AGENT_MAX_TOOL_CALLS", self.budget.max_tool_calls)?; + self.budget.max_elapsed = Duration::from_secs(env_u64( + "AGENTOS_AGENT_MAX_ELAPSED_S", + self.budget.max_elapsed.as_secs(), + )?); + self.budget.max_output_tokens_per_turn = env_u32( + "AGENTOS_AGENT_MAX_OUTPUT_TOKENS", + self.budget.max_output_tokens_per_turn, + )?; + self.budget.max_total_tokens = env_u64( + "AGENTOS_AGENT_MAX_TOTAL_TOKENS", + self.budget.max_total_tokens, + )?; + self.budget.max_safe_workers = env_usize( + "AGENTOS_AGENT_MAX_SAFE_WORKERS", + self.budget.max_safe_workers, + )?; + Ok(()) + } + + fn apply_tool_env(&mut self) -> Result<(), RuntimeError> { + if let Ok(value) = std::env::var("AGENTOS_SERVICE_INSPECT_ALLOWLIST") { + self.service_allowlist = value .split(',') .map(str::trim) .filter(|unit| !unit.is_empty()) .map(str::to_owned) - .collect(), - tool_output_limit_bytes: env_usize("AGENTOS_TOOL_OUTPUT_LIMIT_BYTES", 64 * 1024)?, - tool_argument_limit_bytes: env_usize("AGENTOS_TOOL_ARGUMENT_LIMIT_BYTES", 64 * 1024)?, - calrt_library: std::env::var_os("AGENTOS_CALRT_LIBRARY").map(PathBuf::from), - }) + .collect(); + } + self.tool_output_limit_bytes = env_usize( + "AGENTOS_TOOL_OUTPUT_LIMIT_BYTES", + self.tool_output_limit_bytes, + )?; + self.tool_argument_limit_bytes = env_usize( + "AGENTOS_TOOL_ARGUMENT_LIMIT_BYTES", + self.tool_argument_limit_bytes, + )?; + if let Some(value) = std::env::var_os("AGENTOS_CALRT_LIBRARY") { + self.calrt_library = Some(PathBuf::from(value)); + } + Ok(()) } pub fn database_path(&self) -> PathBuf { @@ -527,6 +714,13 @@ fn write_probe(path: &Path) -> bool { pub enum RuntimeError { #[error("filesystem operation failed: {0}")] Io(#[from] std::io::Error), + #[error("failed to read config file {path}: {source}")] + ConfigFileIo { + path: PathBuf, + source: std::io::Error, + }, + #[error("invalid TOML configuration file: {0}")] + InvalidConfigFile(PathBuf), #[error("identity is invalid: {0}")] Identity(#[from] agentos_core::IdentityError), #[error("memory store failed: {0}")] @@ -561,3 +755,51 @@ pub enum RuntimeError { actual_gid: u32, }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toml_config_applies_openai_compatible_settings() { + let file: RuntimeConfigFile = toml::from_str( + r#" + state_dir = "/state" + backend = "openai_compatible" + agent_id = "interactive-agent" + model_timeout_seconds = 60 + + [openai_compatible] + api_key = "test-secret" + base_url = "https://example.com/v1" + model = "test-model" + api = "responses" + max_retries = 1 + "#, + ) + .expect("valid TOML configuration"); + let mut config = RuntimeConfig::defaults().expect("default configuration"); + + config.apply_file(file).expect("apply TOML configuration"); + + assert_eq!(config.state_dir, PathBuf::from("/state")); + assert_eq!(config.backend, BackendSelection::OpenaiCompatible); + assert_eq!(config.agent_id.as_str(), "interactive-agent"); + assert_eq!(config.model_timeout, Duration::from_mins(1)); + assert_eq!( + config.openai_compatible_api_key.as_deref(), + Some("test-secret") + ); + assert_eq!(config.openai_compatible_base_url, "https://example.com/v1"); + assert_eq!(config.openai_compatible_model, "test-model"); + assert_eq!(config.openai_compatible_api, OpenAiCompatibleApi::Responses); + assert_eq!(config.openai_compatible_max_retries, 1); + } + + #[test] + fn toml_config_rejects_unknown_fields() { + let result = toml::from_str::("unknown = true"); + + assert!(result.is_err()); + } +} diff --git a/emmett/run.sh b/emmett/run.sh new file mode 100755 index 0000000..d08ff1b --- /dev/null +++ b/emmett/run.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +config_path=${AGENTOS_CONFIG:-"$script_dir/agentos.example.toml"} +image=${AGENTOS_CONTAINER_IMAGE:-agentos-cli:local} +state_dir=${AGENTOS_CONTAINER_STATE_DIR:-"${XDG_DATA_HOME:-$HOME/.local/share}/agentos-container"} + +if [[ $(id -u) == 0 || $(id -g) == 0 ]]; then + printf 'error: AgentOS must run as a non-root UID and GID\n' >&2 + exit 1 +fi + +if [[ ! -t 0 || ! -t 1 ]]; then + printf 'error: run.sh requires an interactive terminal\n' >&2 + exit 1 +fi + +if [[ ! -f $config_path ]]; then + printf 'error: config file not found: %s\n' "$config_path" >&2 + printf 'copy agentos.example.toml.template to agentos.example.toml and fill in the API key\n' >&2 + exit 1 +fi + +config_dir=$(cd -- "$(dirname -- "$config_path")" && pwd -P) +config_path="$config_dir/$(basename -- "$config_path")" +if find "$config_path" -perm /077 -print -quit | grep -q .; then + printf 'error: config file permissions are too broad; run: chmod 600 %q\n' \ + "$config_path" >&2 + exit 1 +fi + +if ! docker image inspect "$image" >/dev/null 2>&1; then + printf 'error: Docker image not found: %s\n' "$image" >&2 + printf 'build it with:\n' >&2 + printf ' cargo build -p agentos-cli --bins\n' >&2 + printf ' docker build -f Dockerfile.runtime -t %q .\n' "$image" >&2 + exit 1 +fi + +mkdir -p -- "$state_dir" +chmod 700 -- "$state_dir" +state_dir=$(cd -- "$state_dir" && pwd -P) +state_fstype=$(findmnt -n -o FSTYPE -T "$state_dir") +if [[ $state_fstype != btrfs ]]; then + printf 'error: AgentOS state directory must be on Btrfs, got %s: %s\n' \ + "$state_fstype" "$state_dir" >&2 + exit 1 +fi + +host_uid=$(id -u) +host_gid=$(id -g) +printf 'Starting %s with Btrfs state at %s\n' "$image" "$state_dir" >&2 + +exec docker run --rm -it \ + --read-only \ + --cap-drop ALL \ + --security-opt no-new-privileges \ + --pids-limit 256 \ + --memory 1g \ + --cpus 2 \ + --user "$host_uid:$host_gid" \ + --env HOME=/tmp \ + --mount "type=bind,src=$config_path,dst=/agentos.toml,readonly" \ + --mount "type=bind,src=$state_dir,dst=/state" \ + --tmpfs "/tmp:rw,nosuid,nodev,noexec,size=16m,uid=$host_uid,gid=$host_gid,mode=1777" \ + "$image" --config /agentos.toml agent readline