From 2d42aeb68e374a6fb27070c6ac570a869cae81f3 Mon Sep 17 00:00:00 2001 From: 9bany Date: Sun, 19 Apr 2026 11:02:05 +0700 Subject: [PATCH] artifact: build and run --- Cargo.lock | 2 + crates/rune-artifact/README.md | 1 + crates/rune-artifact/src/lib.rs | 8 +- crates/rune-artifact/src/pack.rs | 28 ++- crates/rune-cmd/Cargo.toml | 2 + crates/rune-cmd/cli.rs | 43 ++--- crates/rune-cmd/commands/agent.rs | 22 --- crates/rune-cmd/commands/artifact.rs | 179 +++++++++--------- crates/rune-cmd/commands/chat.rs | 176 ++++++++++++++++- crates/rune-cmd/commands/rm.rs | 49 +---- crates/rune-cmd/commands/run.rs | 20 +- crates/rune-cmd/commands/stop_agent.rs | 30 +-- crates/rune-gateway/src/routes/a2a.rs | 24 +++ .../rune-runtime/src/engine/llm/anthropic.rs | 13 +- .../src/engine/llm/claude_code.rs | 8 +- crates/rune-runtime/src/engine/llm/copilot.rs | 8 +- crates/rune-runtime/src/engine/llm/gemini.rs | 4 +- crates/rune-runtime/src/engine/llm/mod.rs | 17 +- crates/rune-runtime/src/engine/llm/openai.rs | 15 +- crates/rune-runtime/src/engine/loader.rs | 14 +- crates/rune-runtime/src/engine/mod.rs | 2 +- crates/rune-runtime/src/engine/planner.rs | 30 ++- crates/rune-runtime/src/lib.rs | 6 +- crates/rune-spec/src/agent.rs | 29 ++- crates/rune-spec/src/tool.rs | 157 +++++++++++---- examples/agent-python/Runefile | 21 ++ examples/agent-python/tools/sum.py | 15 ++ 27 files changed, 631 insertions(+), 292 deletions(-) create mode 100644 examples/agent-python/Runefile create mode 100644 examples/agent-python/tools/sum.py diff --git a/Cargo.lock b/Cargo.lock index a4d08eb..0d6232e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3101,6 +3101,7 @@ dependencies = [ "chrono", "clap", "dirs", + "futures-util", "metrics-exporter-prometheus", "nix", "openraft", @@ -3123,6 +3124,7 @@ dependencies = [ "sqlx", "tempfile", "tokio", + "tokio-util", "tonic", "tracing", "tracing-opentelemetry", diff --git a/crates/rune-artifact/README.md b/crates/rune-artifact/README.md index 6519aee..ea6e26f 100644 --- a/crates/rune-artifact/README.md +++ b/crates/rune-artifact/README.md @@ -20,6 +20,7 @@ The tree is rooted at `agent/`: - `agent/manifest.json` — `format: rune-artifact-v1`, optional `initiative: open-agent`, `tag` (from `rune artifact build --tag`, default `latest`), agent name, model, RFC3339 `created_at`, and a sorted `files` list (paths relative to `agent/`, excluding `manifest.json`). - `agent/Runefile`. +- `agent/tools/**`, `agent/skills/**`, `agent/schemas/**` — included when present under the source agent directory (so scripts, WASM modules, skill markdown, and JSON schemas ship with the bundle). ## Loading with `AgentPackage` diff --git a/crates/rune-artifact/src/lib.rs b/crates/rune-artifact/src/lib.rs index b3ac376..5852758 100644 --- a/crates/rune-artifact/src/lib.rs +++ b/crates/rune-artifact/src/lib.rs @@ -1,13 +1,17 @@ //! Portable **rune-artifact** bundles: a versioned `manifest.json` plus packable agent files under //! an `agent/` tree (same layout [`rune_spec::AgentPackage::load`] expects). //! +//! Packed paths (relative to `agent/`) include `Runefile` and files under `tools/`, `skills/`, and +//! `schemas/` so process tools, local skills, and JSON schemas referenced from tool YAML are +//! shipped with the bundle. +//! //! **Open Agent Initiative (OAI)** is the project’s name for this portable agent bundle format. //! It is **not** an [Open Container Initiative](https://opencontainers.org/) (OCI) **container** //! image; the payload is a deterministic bundle for agents, not a container runtime image. //! //! **Remote skills:** refs listed in `Runefile` but not present under `skills/` are **not** -//! embedded (MVP packs on-disk files only). After load, `missing_skills` is populated the same as -//! loading from a dev tree. +//! embedded (only on-disk files under `skills/` are packed). After load, `missing_skills` is +//! populated the same as loading from a dev tree. //! //! Typical flow: [`materialize_agent_bundle`](crate::materialize_agent_bundle) → ship directory or //! [`export_bundle_to_tar_gz_file`](crate::export_bundle_to_tar_gz_file) for a `.tar.gz` → diff --git a/crates/rune-artifact/src/pack.rs b/crates/rune-artifact/src/pack.rs index 10c7351..1498ddd 100644 --- a/crates/rune-artifact/src/pack.rs +++ b/crates/rune-artifact/src/pack.rs @@ -49,8 +49,16 @@ struct PreparedBundle { files: Vec<(String, Vec)>, } +/// Pack `Runefile` plus files under `tools/`, `skills/`, and `schemas/` (relative to agent root). fn allowed_relative_path(rel: &str) -> bool { - rel == "Runefile" + if rel.contains("..") || rel.starts_with('/') { + return false; + } + if rel == "Runefile" { + return true; + } + const PREFIXES: &[&str] = &["tools/", "skills/", "schemas/"]; + PREFIXES.iter().any(|p| rel.starts_with(*p)) } /// Collect (full_path, unix-style relative path) for every file that belongs in the artifact. @@ -329,9 +337,17 @@ models: assert!(!allowed_relative_path("README.md")); assert!(!allowed_relative_path(".gitignore")); assert!(!allowed_relative_path("workflow.yaml")); - assert!(!allowed_relative_path("tools/search.yaml")); - assert!(!allowed_relative_path("skills/x.md")); assert!(!allowed_relative_path("src/main.rs")); + assert!(!allowed_relative_path("../Runefile")); + assert!(!allowed_relative_path("tools/../Runefile")); + } + + #[test] + fn allows_tools_skills_schemas_trees() { + assert!(allowed_relative_path("tools/search.yaml")); + assert!(allowed_relative_path("tools/sum.py")); + assert!(allowed_relative_path("skills/foo/SKILL.md")); + assert!(allowed_relative_path("schemas/in.json")); } // --- collect_packable_files --- @@ -348,11 +364,14 @@ models: let dir = tempfile::tempdir().unwrap(); write_minimal_agent(dir.path()); std::fs::write(dir.path().join("README.md"), "# readme").unwrap(); + std::fs::create_dir_all(dir.path().join("tools")).unwrap(); + std::fs::write(dir.path().join("tools/sum.py"), b"print(1)").unwrap(); let files = collect_packable_files(dir.path()).unwrap(); let rels: Vec<&str> = files.iter().map(|(_, r)| r.as_str()).collect(); assert!(rels.contains(&"Runefile")); + assert!(rels.contains(&"tools/sum.py")); assert!(!rels.contains(&"README.md")); } @@ -379,12 +398,15 @@ models: write_minimal_agent(dir.path()); std::fs::write(dir.path().join("README.md"), "# readme").unwrap(); std::fs::write(dir.path().join(".gitignore"), "target/\n").unwrap(); + std::fs::create_dir_all(dir.path().join("tools")).unwrap(); + std::fs::write(dir.path().join("tools/sum.py"), b"# tool").unwrap(); let mut buf = Vec::new(); pack_agent_dir(dir.path(), &mut buf, PackOptions::default()).unwrap(); let paths = tar_entry_paths(&buf); assert!(paths.iter().any(|p| p == "agent/Runefile")); + assert!(paths.iter().any(|p| p == "agent/tools/sum.py")); assert!(!paths.iter().any(|p| p.contains("README.md"))); assert!(!paths.iter().any(|p| p.contains(".gitignore"))); } diff --git a/crates/rune-cmd/Cargo.toml b/crates/rune-cmd/Cargo.toml index c37d961..ba8405b 100644 --- a/crates/rune-cmd/Cargo.toml +++ b/crates/rune-cmd/Cargo.toml @@ -33,6 +33,8 @@ tracing-opentelemetry = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } reqwest = { workspace = true } +futures-util = { workspace = true } +tokio-util = { workspace = true, features = ["io"] } tempfile = { workspace = true } nix = { workspace = true } chrono = { workspace = true } diff --git a/crates/rune-cmd/cli.rs b/crates/rune-cmd/cli.rs index 5e3c30c..490d9cf 100644 --- a/crates/rune-cmd/cli.rs +++ b/crates/rune-cmd/cli.rs @@ -45,10 +45,10 @@ pub enum Command { #[command(subcommand)] cmd: DaemonCommand, }, - /// Stop a deployment (scale to 0 replicas) - Stop(StopAgentArgs), - /// Remove a deployment - Rm(RmAgentArgs), + /// Stop a deployment by agent name (scale to 0 replicas) + Stop(AgentStopArgs), + /// Remove a deployment by agent name + Rm(AgentRmArgs), /// Manage Raft cluster membership Cluster { #[command(subcommand)] @@ -149,19 +149,20 @@ pub struct AgentRmArgs { #[derive(clap::Args)] pub struct RunArgs { - /// Path to a Runefile (YAML). Mutually exclusive with AGENT_SPEC. - #[arg(short = 'f', long = "file", conflicts_with = "agent_spec")] - pub file: Option, - - /// Local path (agent dir or Runefile), artifact agent name from `rune artifact ls`, or git://... - #[arg(required_unless_present = "file")] - pub agent_spec: Option, + /// Agent name (artifact manifest `agent_name` under ~/.rune/artifacts; see `rune artifact build` / `rune artifact ls`) + pub name: String, + /// Bundle tag (materialized dir or `{name}-{tag}.tar.gz`) + #[arg(long, default_value = "latest")] + pub tag: String, #[arg(long, default_value = "dev")] pub namespace: String, #[arg(long, default_value = "stable")] pub alias: String, #[arg(long, default_value = "http://localhost:8081")] pub control_plane: String, + /// When the artifact is not local, GET `{base}/{name}-{tag}.tar.gz` (also env RUNE_ARTIFACT_REGISTRY_URL) + #[arg(long, env = "RUNE_ARTIFACT_REGISTRY_URL")] + pub artifact_registry: Option, } #[derive(clap::Args)] @@ -171,6 +172,9 @@ pub struct ChatArgs { /// Gateway base URL (default matches `rune daemon start`) #[arg(long, default_value = "http://localhost:8080")] pub gateway: String, + /// Stream SSE events (token, tool_start, tool_done, thinking, …). Requires ANTHROPIC_API_KEY or OPENAI_API_KEY on the gateway. + #[arg(long)] + pub stream: bool, } #[derive(clap::Args)] @@ -231,23 +235,6 @@ pub struct StopArgs { pub pid_file: String, } -#[derive(clap::Args)] -pub struct StopAgentArgs { - pub deployment_id: String, - #[arg(long, default_value = "http://localhost:8081")] - pub control_plane: String, -} - -#[derive(clap::Args)] -pub struct RmAgentArgs { - pub deployment_id: String, - /// Force delete even when cascade fails (disables foreign key checks) - #[arg(long)] - pub force: bool, - #[arg(long, default_value = "http://localhost:8081")] - pub control_plane: String, -} - #[derive(Subcommand)] pub enum DaemonCommand { /// Launch the gateway and control-plane servers diff --git a/crates/rune-cmd/commands/agent.rs b/crates/rune-cmd/commands/agent.rs index 5df69f8..2e003ec 100644 --- a/crates/rune-cmd/commands/agent.rs +++ b/crates/rune-cmd/commands/agent.rs @@ -50,28 +50,6 @@ pub fn resolve_agent_source( } } -/// `rune run -f /path/to/Runefile.yaml` -pub fn resolve_agent_from_runefile_path( - path: &Path, -) -> Result<(Option, rune_spec::AgentPackage)> { - use rune_spec::AgentPackage; - let pkg = AgentPackage::load_runefile(path)?; - Ok((None, pkg)) -} - -/// Local path, `git://...`, or stored artifact name (see `rune artifact ls`). -pub fn resolve_agent_source_or_artifact( - agent_spec: &str, -) -> Result<(Option, rune_spec::AgentPackage)> { - if agent_spec.starts_with("git://") { - return resolve_agent_source(agent_spec); - } - if Path::new(agent_spec).exists() { - return resolve_agent_source(agent_spec); - } - crate::commands::artifact::load_by_stored_agent_name(agent_spec) -} - // --------------------------------------------------------------------------- // Runtime management helpers // --------------------------------------------------------------------------- diff --git a/crates/rune-cmd/commands/artifact.rs b/crates/rune-cmd/commands/artifact.rs index 1eba358..ed64088 100644 --- a/crates/rune-cmd/commands/artifact.rs +++ b/crates/rune-cmd/commands/artifact.rs @@ -3,7 +3,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use rune_artifact::PackOptions; use rune_spec::AgentPackage; @@ -301,112 +301,111 @@ pub fn ls() -> Result<()> { Ok(()) } -#[derive(Debug)] -enum StoredBundle { - Dir(PathBuf), - Tar(PathBuf), -} - -/// Load an agent package from `~/.rune/artifacts` by manifest `agent_name`. -/// Prefers tag `latest`, then newest mtime. -pub fn load_by_stored_agent_name( +/// Load `~/.rune/artifacts/{name}-{tag}/` or `{name}-{tag}.tar.gz` (exact tag). +pub fn load_stored_artifact_exact( name: &str, + tag: &str, ) -> Result<(Option, AgentPackage)> { if name.is_empty() || name.contains('/') || name.contains('\\') { - anyhow::bail!( - "invalid artifact name {:?}: use a plain agent name, a filesystem path, or git://...", + bail!( + "invalid artifact name {:?}: use a plain agent name (no path separators)", name ); } - - let dir = artifacts_root()?; - if !dir.is_dir() { - anyhow::bail!( - "no stored artifact {:?}: artifacts directory {} does not exist; use a path, `rune artifact build`, or git://...", - name, - dir.display() + if tag.is_empty() || tag.contains('/') || tag.contains('\\') { + bail!( + "invalid artifact tag {:?}: use a plain tag (no path separators)", + tag ); } - let mut candidates: Vec<(StoredBundle, Option, std::time::SystemTime)> = Vec::new(); + let bundle_root = artifact_bundle_dir(name, tag)?; + if is_materialized_bundle(&bundle_root) { + let pkg = AgentPackage::load(&bundle_root.join("agent")).with_context(|| { + format!( + "load agent from materialized bundle {}", + bundle_root.display() + ) + })?; + return Ok((None, pkg)); + } - for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? { - let entry = entry.with_context(|| format!("read entry in {}", dir.display()))?; - let path = entry.path(); - let meta = entry - .metadata() - .with_context(|| format!("stat {}", path.display()))?; + let tar_path = artifact_tar_path(name, tag)?; + if tar_path.is_file() { + let f = File::open(&tar_path).with_context(|| format!("open {}", tar_path.display()))?; + let (tmp, pkg) = rune_artifact::extract_and_load_package(f) + .with_context(|| format!("extract artifact {}", tar_path.display()))?; + return Ok((Some(tmp), pkg)); + } - if meta.is_dir() { - if !is_materialized_bundle(&path) { - continue; - } - let m = match rune_artifact::read_manifest_dir(&path) { - Ok(m) => m, - Err(_) => continue, + let root = artifacts_root()?; + bail!( + "artifact not found locally: expected {} or {} under {}", + bundle_root.display(), + tar_path.display(), + root.display() + ); +} + +/// For `rune run`: resolve artifact locally first, then optional HTTP registry (`{base}/{sanitized}-{sanitized}.tar.gz`). +pub async fn ensure_artifact_for_deploy( + name: &str, + tag: &str, + registry_base: Option<&str>, + http: &reqwest::Client, +) -> Result<(Option, AgentPackage)> { + match load_stored_artifact_exact(name, tag) { + Ok(pkg) => Ok(pkg), + Err(local_err) => { + let Some(base) = registry_base.map(str::trim).filter(|s| !s.is_empty()) else { + return Err(local_err.context( + "set RUNE_ARTIFACT_REGISTRY_URL or pass --artifact-registry to download, or run `rune artifact build`", + )); }; - if m.agent_name != name { - continue; + + let fname = format!( + "{}-{}.tar.gz", + sanitize_filename_component(name), + sanitize_filename_component(tag) + ); + let url = format!("{}/{}", base.trim_end_matches('/'), fname); + + let mut req = http.get(&url); + if let Ok(tok) = std::env::var("RUNE_ARTIFACT_REGISTRY_TOKEN") { + if !tok.is_empty() { + req = req.bearer_auth(tok); + } } - let mtime = meta.modified().unwrap_or(UNIX_EPOCH); - candidates.push((StoredBundle::Dir(path), m.tag, mtime)); - continue; - } - if !meta.is_file() { - continue; - } - let fname = entry.file_name(); - let Some(fname_str) = fname.to_str() else { - continue; - }; - if !fname_str.ends_with(".tar.gz") { - continue; - } - let m = match File::open(&path) { - Ok(f) => match rune_artifact::read_manifest(f) { - Ok(m) => m, - Err(_) => continue, - }, - Err(_) => continue, - }; - if m.agent_name != name { - continue; - } - let mtime = meta.modified().unwrap_or(UNIX_EPOCH); - candidates.push((StoredBundle::Tar(path), m.tag, mtime)); - } + let resp = req.send().await.with_context(|| format!("GET {url}"))?; + let status = resp.status(); + if status == reqwest::StatusCode::NOT_FOUND { + bail!( + "artifact {name}:{tag} not found locally and registry returned 404 for {url}" + ); + } + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!( + "registry error for {url}: {status}{}", + if body.is_empty() { + String::new() + } else { + format!(" — {body}") + } + ); + } - if candidates.is_empty() { - anyhow::bail!( - "no stored artifact with agent name {:?} under {}; use `rune artifact ls`, a local path, or git://...", - name, - dir.display() - ); - } + let bytes = resp.bytes().await.with_context(|| format!("read body {url}"))?; + prepare_artifacts_parent()?; + let dest = artifact_tar_path(name, tag)?; + std::fs::write(&dest, &bytes) + .with_context(|| format!("write downloaded artifact to {}", dest.display()))?; - candidates.sort_by(|a, b| { - let a_latest = a.1.as_deref() == Some("latest"); - let b_latest = b.1.as_deref() == Some("latest"); - b_latest - .cmp(&a_latest) - .then_with(|| { - let ta = a.2.duration_since(UNIX_EPOCH).unwrap_or_default(); - let tb = b.2.duration_since(UNIX_EPOCH).unwrap_or_default(); - tb.cmp(&ta) - }) - }); - - match &candidates[0].0 { - StoredBundle::Dir(root) => { - let pkg = AgentPackage::load(&root.join("agent")) - .with_context(|| format!("load agent from {}", root.display()))?; - Ok((None, pkg)) - } - StoredBundle::Tar(tar_path) => { - let f = File::open(tar_path).with_context(|| format!("open {}", tar_path.display()))?; + let f = File::open(&dest).with_context(|| format!("open {}", dest.display()))?; let (tmp, pkg) = rune_artifact::extract_and_load_package(f) - .with_context(|| format!("extract artifact {}", tar_path.display()))?; + .with_context(|| format!("extract downloaded artifact {}", dest.display()))?; + eprintln!(" fetched artifact from registry: {url}"); Ok((Some(tmp), pkg)) } } diff --git a/crates/rune-cmd/commands/chat.rs b/crates/rune-cmd/commands/chat.rs index 6217b82..63a6992 100644 --- a/crates/rune-cmd/commands/chat.rs +++ b/crates/rune-cmd/commands/chat.rs @@ -1,7 +1,10 @@ use anyhow::{Context, Result}; +use futures_util::TryStreamExt; use reqwest::Url; use serde::Deserialize; +use serde_json::Value; use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio_util::io::StreamReader; use uuid::Uuid; use crate::cli::ChatArgs; @@ -37,13 +40,129 @@ fn format_output(output: &serde_json::Value) -> String { serde_json::to_string_pretty(output).unwrap_or_else(|_| output.to_string()) } +/// Strip `data: ` prefix from one SSE line (for tests and incremental parsing). +fn sse_data_payload(line: &str) -> Option<&str> { + line.strip_prefix("data: ").map(str::trim) +} + +/// Handle one parsed JSON event from gateway invoke SSE. Returns session id from `done` events. +fn handle_invoke_sse_json(v: &Value, saw_token: &mut bool) -> Option { + use std::io::Write; + + let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or(""); + match ty { + "thinking" => { + if let Some(t) = v.get("text").and_then(|x| x.as_str()) { + eprintln!("[thinking] {}", t); + } + } + "token" => { + if let Some(t) = v.get("text").and_then(|x| x.as_str()) { + *saw_token = true; + print!("{}", t); + let _ = std::io::stdout().flush(); + } + } + "tool_start" => { + println!(); + if let Some(n) = v.get("name").and_then(|x| x.as_str()) { + eprintln!("[tool_start] {}", n); + } + } + "tool_done" => { + let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("?"); + eprintln!("[tool_done] {} {}", name, v.get("result").unwrap_or(&Value::Null)); + } + "done" => { + println!(); + if let Some(s) = v.get("session_id").and_then(|x| x.as_str()) { + if let Ok(uuid) = Uuid::parse_str(s) { + return Some(uuid); + } + } + } + "error" => { + if let Some(m) = v.get("message").and_then(|x| x.as_str()) { + eprintln!("[error] {}", m); + } + } + _ => {} + } + None +} + +async fn send_invoke_stream( + client: &reqwest::Client, + url: Url, + text: &str, + session_id: Option, +) -> Result> { + let mut body = serde_json::json!({ + "input": { "text": text }, + "stream": true + }); + if let Some(sid) = session_id { + body["session_id"] = serde_json::json!(sid); + } + + let resp = client + .post(url) + .header("Accept", "text/event-stream") + .json(&body) + .send() + .await + .with_context(|| { + "failed to reach gateway — is `rune daemon start` running and `--gateway` correct?" + })?; + + if !resp.status().is_success() { + let status = resp.status(); + let err_body = resp.text().await.unwrap_or_default(); + anyhow::bail!("request failed ({status}): {err_body}"); + } + + let byte_stream = resp.bytes_stream().map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, e) + }); + let reader = StreamReader::new(byte_stream); + let mut lines = BufReader::new(reader).lines(); + + let mut session_from_done: Option = None; + let mut saw_token = false; + + while let Some(line) = lines.next_line().await? { + let data = match sse_data_payload(&line) { + Some(d) if !d.is_empty() => d, + _ => continue, + }; + let v: Value = match serde_json::from_str(data) { + Ok(v) => v, + Err(_) => continue, + }; + if let Some(sid) = handle_invoke_sse_json(&v, &mut saw_token) { + session_from_done = Some(sid); + } + } + + if saw_token { + println!(); + } + + Ok(session_from_done) +} + pub async fn exec(args: ChatArgs) -> Result<()> { let url = invoke_url(&args.gateway, &args.agent)?; let client = reqwest::Client::new(); println!( - "Chat with '{}'. Commands: /quit, /exit. Empty lines are ignored.", - args.agent + "Chat with '{}'. Commands: /quit, /exit. Empty lines are ignored.{}", + args.agent, + if args.stream { + " Streaming: token / tool / thinking events on stdout/stderr." + } else { + "" + } ); let stdin = tokio::io::stdin(); @@ -70,6 +189,28 @@ pub async fn exec(args: ChatArgs) -> Result<()> { break; } + if args.stream { + match send_invoke_stream(&client, url.clone(), text, session_id).await { + Ok(done_sid) => { + if let Some(sid) = done_sid { + if session_id.is_none() { + println!("(session {})", sid); + } + session_id = Some(sid); + } + } + Err(e) => { + eprintln!("{e:#}"); + if e.to_string().contains("404") { + eprintln!( + "hint: deploy from an artifact (`rune artifact build` then `rune run ` or registry) and ensure the gateway is up (`rune daemon start`)." + ); + } + } + } + continue; + } + let mut body = serde_json::json!({ "input": { "text": text }, "stream": false @@ -93,7 +234,7 @@ pub async fn exec(args: ChatArgs) -> Result<()> { eprintln!("request failed ({status}): {err_body}"); if status.as_u16() == 404 { eprintln!( - "hint: ensure the agent is deployed (`rune run …`) and the gateway is up (`rune daemon start`)." + "hint: deploy from an artifact (`rune artifact build` then `rune run ` or registry) and ensure the gateway is up (`rune daemon start`)." ); } continue; @@ -114,3 +255,32 @@ pub async fn exec(args: ChatArgs) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sse_data_payload_strips_prefix() { + assert_eq!( + sse_data_payload(r#"data: {"type":"token"}"#), + Some(r#"{"type":"token"}"#) + ); + assert_eq!(sse_data_payload("event: ping"), None); + } + + #[test] + fn handle_done_yields_session() { + let v = serde_json::json!({ + "type": "done", + "session_id": "550e8400-e29b-41d4-a716-446655440000", + "request_id": "660e8400-e29b-41d4-a716-446655440001" + }); + let mut saw = false; + let sid = handle_invoke_sse_json(&v, &mut saw); + assert_eq!( + sid, + Some(Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()) + ); + } +} diff --git a/crates/rune-cmd/commands/rm.rs b/crates/rune-cmd/commands/rm.rs index 26e66e2..db300c3 100644 --- a/crates/rune-cmd/commands/rm.rs +++ b/crates/rune-cmd/commands/rm.rs @@ -1,48 +1,7 @@ -use anyhow::{bail, Result}; -use uuid::Uuid; +use anyhow::Result; -use crate::cli::RmAgentArgs; +use crate::cli::AgentRmArgs; -pub async fn exec(args: RmAgentArgs) -> Result<()> { - let id: Uuid = args - .deployment_id - .parse() - .map_err(|_| anyhow::anyhow!("invalid deployment ID: {}", args.deployment_id))?; - - let http = reqwest::Client::new(); - let base = args.control_plane.trim_end_matches('/'); - let url = if args.force { - format!("{base}/v1/deployments/{id}?force=true") - } else { - format!("{base}/v1/deployments/{id}") - }; - - let resp = http.delete(&url).send().await?; - - if resp.status() == 404 { - bail!("Deployment {} not found", id); - } - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - let hint = if args.force && status.as_u16() == 500 { - "\n Hint: Restart the daemon (rune daemon stop; rune daemon start) to pick up --force support." - } else { - "" - }; - bail!( - "failed to remove deployment {}: {} {}{}", - id, - status, - if body.is_empty() { - "".into() - } else { - format!("— {}", body) - }, - hint, - ); - } - - println!("Deployment {} removed", id); - Ok(()) +pub async fn exec(args: AgentRmArgs) -> Result<()> { + crate::commands::agent::rm_by_name(args).await } diff --git a/crates/rune-cmd/commands/run.rs b/crates/rune-cmd/commands/run.rs index e635fc0..5d3c546 100644 --- a/crates/rune-cmd/commands/run.rs +++ b/crates/rune-cmd/commands/run.rs @@ -1,22 +1,20 @@ use anyhow::Result; use std::hash::{DefaultHasher, Hash, Hasher}; -use super::agent::{resolve_agent_from_runefile_path, resolve_agent_source_or_artifact}; use crate::cli::RunArgs; +use crate::commands::artifact::ensure_artifact_for_deploy; pub async fn exec(args: RunArgs) -> Result<()> { - let (_tmp, pkg) = if let Some(ref path) = args.file { - resolve_agent_from_runefile_path(path)? - } else { - let spec = args - .agent_spec - .as_deref() - .expect("clap requires agent_spec when --file is absent"); - resolve_agent_source_or_artifact(spec)? - }; + let http = reqwest::Client::new(); + let (_tmp, pkg) = ensure_artifact_for_deploy( + &args.name, + &args.tag, + args.artifact_registry.as_deref(), + &http, + ) + .await?; println!("Deploying {} v{} ...", pkg.spec.name, pkg.spec.version); - let http = reqwest::Client::new(); let base = args.control_plane.trim_end_matches('/'); let resp = http diff --git a/crates/rune-cmd/commands/stop_agent.rs b/crates/rune-cmd/commands/stop_agent.rs index 4d5e4f5..7c2c284 100644 --- a/crates/rune-cmd/commands/stop_agent.rs +++ b/crates/rune-cmd/commands/stop_agent.rs @@ -1,29 +1,7 @@ -use anyhow::{bail, Result}; -use uuid::Uuid; +use anyhow::Result; -use crate::cli::StopAgentArgs; +use crate::cli::AgentStopArgs; -pub async fn exec(args: StopAgentArgs) -> Result<()> { - let id: Uuid = args - .deployment_id - .parse() - .map_err(|_| anyhow::anyhow!("invalid deployment ID: {}", args.deployment_id))?; - - let http = reqwest::Client::new(); - let base = args.control_plane.trim_end_matches('/'); - let url = format!("{base}/v1/deployments/{id}/scale"); - - let resp = http - .post(&url) - .json(&serde_json::json!({ "desired_replicas": 0 })) - .send() - .await?; - - if resp.status() == 404 { - bail!("Deployment {} not found", id); - } - resp.error_for_status()?; - - println!("Deployment {} stopped (scaled to 0)", id); - Ok(()) +pub async fn exec(args: AgentStopArgs) -> Result<()> { + crate::commands::agent::stop_by_name(args).await } diff --git a/crates/rune-gateway/src/routes/a2a.rs b/crates/rune-gateway/src/routes/a2a.rs index 13e0f30..324d5b4 100644 --- a/crates/rune-gateway/src/routes/a2a.rs +++ b/crates/rune-gateway/src/routes/a2a.rs @@ -472,6 +472,30 @@ async fn handle_message_stream( while let Some(event) = sse_rx.recv().await { let (a2a_event, is_terminal) = match event { + SseEvent::Thinking { text } => { + let update = TaskArtifactUpdateEvent { + task_id: task_for_bridge.clone(), + context_id: context_for_bridge.clone(), + artifact: Artifact { + artifact_id: "thinking".into(), + name: Some("thinking".into()), + description: None, + parts: vec![Part::text(&text)], + metadata: None, + }, + append: false, + last_chunk: false, + metadata: None, + kind: "artifact-update".into(), + }; + ( + JsonRpcResponse::success( + rpc_for_bridge.clone(), + serde_json::to_value(&update).unwrap_or_default(), + ), + false, + ) + } SseEvent::Token { text } => { full_text.push_str(&text); let update = TaskArtifactUpdateEvent { diff --git a/crates/rune-runtime/src/engine/llm/anthropic.rs b/crates/rune-runtime/src/engine/llm/anthropic.rs index d9cbd45..f118b74 100644 --- a/crates/rune-runtime/src/engine/llm/anthropic.rs +++ b/crates/rune-runtime/src/engine/llm/anthropic.rs @@ -9,7 +9,7 @@ use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio_util::io::StreamReader; -use super::{ApiTool, ContentBlock, LlmProvider, LlmResponse, StreamChunk}; +use super::{ApiTool, ContentBlock, LlmProvider, LlmRequestOptions, LlmResponse, StreamChunk}; use crate::error::RuntimeError; pub struct AnthropicClient { @@ -103,6 +103,7 @@ impl LlmProvider for AnthropicClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + _opts: &LlmRequestOptions, ) -> Result { let body = Self::build_body(model, system, messages, tools, max_tokens, false)?; let resp = self.send_request(&body).await?; @@ -130,6 +131,7 @@ impl LlmProvider for AnthropicClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + _opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { let body = Self::build_body(model, system, messages, tools, max_tokens, true)?; @@ -182,6 +184,15 @@ impl LlmProvider for AnthropicClient { Some("content_block_delta") => { let idx = val["index"].as_u64().unwrap_or(0); match val["delta"]["type"].as_str() { + Some("thinking_delta") => { + let text = val["delta"]["thinking"] + .as_str() + .unwrap_or("") + .to_string(); + if !text.is_empty() { + on_chunk(StreamChunk::Thinking(text)); + } + } Some("text_delta") => { let text = val["delta"]["text"].as_str().unwrap_or("").to_string(); if !text.is_empty() { diff --git a/crates/rune-runtime/src/engine/llm/claude_code.rs b/crates/rune-runtime/src/engine/llm/claude_code.rs index d6d7870..ec2268c 100644 --- a/crates/rune-runtime/src/engine/llm/claude_code.rs +++ b/crates/rune-runtime/src/engine/llm/claude_code.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use super::anthropic::AnthropicClient; -use super::{ApiTool, LlmProvider, LlmResponse, StreamChunk}; +use super::{ApiTool, LlmProvider, LlmRequestOptions, LlmResponse, StreamChunk}; use crate::error::RuntimeError; pub struct ClaudeCodeClient(AnthropicClient); @@ -43,9 +43,10 @@ impl LlmProvider for ClaudeCodeClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, ) -> Result { self.0 - .call(model, system, messages, tools, max_tokens) + .call(model, system, messages, tools, max_tokens, opts) .await } @@ -56,10 +57,11 @@ impl LlmProvider for ClaudeCodeClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { self.0 - .stream(model, system, messages, tools, max_tokens, on_chunk) + .stream(model, system, messages, tools, max_tokens, opts, on_chunk) .await } } diff --git a/crates/rune-runtime/src/engine/llm/copilot.rs b/crates/rune-runtime/src/engine/llm/copilot.rs index 7cf3154..d2f61b4 100644 --- a/crates/rune-runtime/src/engine/llm/copilot.rs +++ b/crates/rune-runtime/src/engine/llm/copilot.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use super::openai::OpenAiClient; -use super::{ApiTool, LlmProvider, LlmResponse, StreamChunk}; +use super::{ApiTool, LlmProvider, LlmRequestOptions, LlmResponse, StreamChunk}; use crate::error::RuntimeError; const COPILOT_BASE_URL: &str = "https://api.githubcopilot.com"; @@ -54,9 +54,10 @@ impl LlmProvider for CopilotClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, ) -> Result { self.0 - .call(model, system, messages, tools, max_tokens) + .call(model, system, messages, tools, max_tokens, opts) .await } @@ -67,10 +68,11 @@ impl LlmProvider for CopilotClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { self.0 - .stream(model, system, messages, tools, max_tokens, on_chunk) + .stream(model, system, messages, tools, max_tokens, opts, on_chunk) .await } } diff --git a/crates/rune-runtime/src/engine/llm/gemini.rs b/crates/rune-runtime/src/engine/llm/gemini.rs index dd29ded..f757961 100644 --- a/crates/rune-runtime/src/engine/llm/gemini.rs +++ b/crates/rune-runtime/src/engine/llm/gemini.rs @@ -11,7 +11,7 @@ use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio_util::io::StreamReader; -use super::{ApiTool, ContentBlock, LlmProvider, LlmResponse, StreamChunk}; +use super::{ApiTool, ContentBlock, LlmProvider, LlmRequestOptions, LlmResponse, StreamChunk}; use crate::error::RuntimeError; const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/models"; @@ -208,6 +208,7 @@ impl LlmProvider for GeminiClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + _opts: &LlmRequestOptions, ) -> Result { let body = Self::build_body(system, messages, tools, max_tokens); let resp = self @@ -272,6 +273,7 @@ impl LlmProvider for GeminiClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + _opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { let body = Self::build_body(system, messages, tools, max_tokens); diff --git a/crates/rune-runtime/src/engine/llm/mod.rs b/crates/rune-runtime/src/engine/llm/mod.rs index e091a02..8db754d 100644 --- a/crates/rune-runtime/src/engine/llm/mod.rs +++ b/crates/rune-runtime/src/engine/llm/mod.rs @@ -40,9 +40,18 @@ pub struct LlmResponse { pub content: Vec, } +/// Per-request options for LLM calls (not all providers honor every field). +#[derive(Debug, Clone, Default)] +pub struct LlmRequestOptions { + /// When true, OpenAI uses `tool_choice: "required"` if `tools` is non-empty. + pub require_tool_call: bool, +} + /// A chunk produced during streaming. #[derive(Debug)] pub enum StreamChunk { + /// Model reasoning / extended thinking (when the provider exposes it). + Thinking(String), Token(String), ToolUse { id: String, @@ -277,6 +286,7 @@ pub trait LlmProvider: Send + Sync { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, ) -> Result; async fn stream( @@ -286,6 +296,7 @@ pub trait LlmProvider: Send + Sync { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError>; } @@ -359,13 +370,14 @@ impl LlmClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, ) -> Result { let model = model_override.unwrap_or_else(|| self.inner.default_model()); let provider = self.inner.provider_name(); let start = std::time::Instant::now(); let result = self .inner - .call(model, system, messages, tools, max_tokens) + .call(model, system, messages, tools, max_tokens, opts) .await; crate::metrics::record_model_call_duration(provider, model, start.elapsed().as_secs_f64()); result @@ -378,11 +390,12 @@ impl LlmClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { let model = model_override.unwrap_or_else(|| self.inner.default_model()); self.inner - .stream(model, system, messages, tools, max_tokens, on_chunk) + .stream(model, system, messages, tools, max_tokens, opts, on_chunk) .await } } diff --git a/crates/rune-runtime/src/engine/llm/openai.rs b/crates/rune-runtime/src/engine/llm/openai.rs index f502513..8434a59 100644 --- a/crates/rune-runtime/src/engine/llm/openai.rs +++ b/crates/rune-runtime/src/engine/llm/openai.rs @@ -9,7 +9,7 @@ use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio_util::io::StreamReader; -use super::{ApiTool, ContentBlock, LlmProvider, LlmResponse, StreamChunk}; +use super::{ApiTool, ContentBlock, LlmProvider, LlmRequestOptions, LlmResponse, StreamChunk}; use crate::error::RuntimeError; fn extract_text_from_blocks(content: &serde_json::Value) -> String { @@ -159,6 +159,7 @@ impl OpenAiClient { tools: &[ApiTool], max_tokens: u32, stream: bool, + opts: &LlmRequestOptions, ) -> serde_json::Value { let mut oai_msgs = vec![serde_json::json!({"role": "system", "content": system})]; oai_msgs.extend(Self::to_openai_messages(messages)); @@ -173,7 +174,11 @@ impl OpenAiClient { } if !tools.is_empty() { body["tools"] = serde_json::json!(Self::oai_tools(tools)); - body["tool_choice"] = serde_json::json!("auto"); + body["tool_choice"] = if opts.require_tool_call { + serde_json::json!("required") + } else { + serde_json::json!("auto") + }; } body } @@ -217,8 +222,9 @@ impl LlmProvider for OpenAiClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, ) -> Result { - let body = self.build_body(system, messages, tools, max_tokens, false); + let body = self.build_body(system, messages, tools, max_tokens, false, opts); let resp = self.send_request(&body).await?; #[derive(Deserialize)] @@ -287,9 +293,10 @@ impl LlmProvider for OpenAiClient { messages: &[serde_json::Value], tools: &[ApiTool], max_tokens: u32, + opts: &LlmRequestOptions, on_chunk: &mut (dyn FnMut(StreamChunk) + Send), ) -> Result<(), RuntimeError> { - let body = self.build_body(system, messages, tools, max_tokens, true); + let body = self.build_body(system, messages, tools, max_tokens, true, opts); let resp = match self.send_request(&body).await { Ok(r) => r, Err(e) => { diff --git a/crates/rune-runtime/src/engine/loader.rs b/crates/rune-runtime/src/engine/loader.rs index a8747e4..ed66ab4 100644 --- a/crates/rune-runtime/src/engine/loader.rs +++ b/crates/rune-runtime/src/engine/loader.rs @@ -118,7 +118,7 @@ toolset: let tools_dir = dir.path().join("tools"); std::fs::create_dir(&tools_dir).unwrap(); - std::fs::write(tools_dir.join("search.yaml"), "name: my_search\n").unwrap(); + std::fs::write(tools_dir.join("my_search.py"), "# tool\n").unwrap(); let plan = ExecutionPlan::from_dir(dir.path()).unwrap(); assert!(plan.toolset.contains(&"rune@shell".to_string())); @@ -405,6 +405,8 @@ pub struct ExecutionPlan { pub toolset: Vec, /// rune-network memberships for this agent (default: ["bridge"]). pub networks: Vec, + /// When true, OpenAI uses `tool_choice: required` when tools are registered (see `AgentSpec::require_tool_call`). + pub require_tool_call: bool, } fn skill_markdown_path(agent_dir: &Path, skill_ref: &str) -> PathBuf { @@ -433,11 +435,8 @@ fn collect_local_skills(agent_dir: &Path, skill_refs: &[String]) -> (String, Vec } fn load_tools_from_agent_dir(agent_dir: &Path) -> Result, RuntimeError> { - let dir = agent_dir.join("tools"); - if !dir.is_dir() { - return Ok(vec![]); - } - ToolDescriptor::load_dir(&dir).map_err(|e| RuntimeError::Spec(e.to_string())) + ToolDescriptor::discover_process_scripts(agent_dir) + .map_err(|e| RuntimeError::Spec(e.to_string())) } impl ExecutionPlan { @@ -472,6 +471,7 @@ impl ExecutionPlan { models: pkg.spec.models.clone(), toolset, networks: pkg.spec.networks.clone(), + require_tool_call: pkg.spec.require_tool_call, }) } @@ -528,6 +528,7 @@ impl ExecutionPlan { models: pkg.spec.models.clone(), toolset, networks: pkg.spec.networks.clone(), + require_tool_call: pkg.spec.require_tool_call, }) } @@ -545,6 +546,7 @@ impl ExecutionPlan { models: ModelsSpec::default(), toolset: vec![], networks: vec!["bridge".into()], + require_tool_call: false, } } } diff --git a/crates/rune-runtime/src/engine/mod.rs b/crates/rune-runtime/src/engine/mod.rs index 5184dfa..d1d3ebf 100644 --- a/crates/rune-runtime/src/engine/mod.rs +++ b/crates/rune-runtime/src/engine/mod.rs @@ -10,7 +10,7 @@ pub mod wasm_runner; pub mod workflow; pub use agent_ops::RuntimeAgentOps; -pub use llm::{AnthropicClient, ContentBlock, LlmClient, OpenAiClient, StreamChunk}; +pub use llm::{AnthropicClient, ContentBlock, LlmClient, LlmRequestOptions, OpenAiClient, StreamChunk}; pub use loader::ExecutionPlan; pub use planner::{Action, Planner, SseEvent, StubPlanner}; pub use policy::{audit_policy_decision, PolicyDecision, PolicyEngine}; diff --git a/crates/rune-runtime/src/engine/planner.rs b/crates/rune-runtime/src/engine/planner.rs index 6ee6334..64a9992 100644 --- a/crates/rune-runtime/src/engine/planner.rs +++ b/crates/rune-runtime/src/engine/planner.rs @@ -3,7 +3,9 @@ use uuid::Uuid; use crate::error::RuntimeError; use crate::metrics; -use super::llm::{tools_to_api, user_input_to_content, ContentBlock, LlmClient}; +use super::llm::{ + tools_to_api, user_input_to_content, ContentBlock, LlmClient, LlmRequestOptions, +}; use super::loader::ExecutionPlan; use super::policy::{PolicyDecision, PolicyEngine}; use super::session::{Message, SessionManager}; @@ -100,6 +102,9 @@ impl Planner { let api_messages = Self::to_api_messages(messages); let api_tools = tools_to_api(&self.plan.tools); + let llm_opts = LlmRequestOptions { + require_tool_call: self.plan.require_tool_call, + }; let resp = client .call( @@ -108,6 +113,7 @@ impl Planner { &api_messages, &api_tools, 4096, + &llm_opts, ) .await?; @@ -279,6 +285,10 @@ impl StubPlanner { #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum SseEvent { + /// Extended thinking / reasoning text (provider-dependent). + Thinking { + text: String, + }, Token { text: String, }, @@ -393,6 +403,16 @@ mod tests { // --- SseEvent serialization --- + #[test] + fn sse_thinking_serializes_with_type_field() { + let ev = SseEvent::Thinking { + text: "step 1".into(), + }; + let json = serde_json::to_value(&ev).unwrap(); + assert_eq!(json["type"], "thinking"); + assert_eq!(json["text"], "step 1"); + } + #[test] fn sse_token_serializes_with_type_field() { let ev = SseEvent::Token { @@ -515,6 +535,9 @@ impl Planner { } let api_messages = Self::to_api_messages(&messages); + let llm_opts = LlmRequestOptions { + require_tool_call: self.plan.require_tool_call, + }; // Collect chunks via synchronous callback — avoids spawning or lifetime issues. let mut full_text = String::new(); @@ -529,8 +552,13 @@ impl Planner { &api_messages, &api_tools, 4096, + &llm_opts, &mut |chunk| { match chunk { + StreamChunk::Thinking(text) => { + metrics::increment_streaming_events(); + let _ = tx.try_send(SseEvent::Thinking { text }); + } StreamChunk::Token(text) => { full_text.push_str(&text); metrics::increment_streaming_events(); diff --git a/crates/rune-runtime/src/lib.rs b/crates/rune-runtime/src/lib.rs index 14187e8..5ef73ee 100644 --- a/crates/rune-runtime/src/lib.rs +++ b/crates/rune-runtime/src/lib.rs @@ -25,9 +25,9 @@ pub use reconcile::ReconcileLoop; pub use router::{ReplicaLease, ReplicaRouter}; pub use engine::{ - Action, AnthropicClient, ContentBlock, ExecutionPlan, LlmClient, Message, OpenAiClient, - Planner, PolicyEngine, RuntimeAgentOps, SessionManager, SseEvent, StreamChunk, StubPlanner, - ToolDispatcher, WorkflowExecutor, + Action, AnthropicClient, ContentBlock, ExecutionPlan, LlmClient, LlmRequestOptions, Message, + OpenAiClient, Planner, PolicyEngine, RuntimeAgentOps, SessionManager, SseEvent, StreamChunk, + StubPlanner, ToolDispatcher, WorkflowExecutor, }; pub use rune_storage::{self, RuneStore}; diff --git a/crates/rune-spec/src/agent.rs b/crates/rune-spec/src/agent.rs index f68d35e..4a0d21a 100644 --- a/crates/rune-spec/src/agent.rs +++ b/crates/rune-spec/src/agent.rs @@ -25,8 +25,8 @@ pub struct AgentSpec { pub default_model: String, #[serde(default)] pub models: ModelsSpec, - /// Built-in (`rune@…`) and custom tool names; merged with tools discovered under `tools/*.yaml`. - #[serde(default)] + /// Built-in (`rune@…`) and custom tool names; merged with tools discovered as scripts under `tools/`. + #[serde(default, alias = "tools")] pub toolset: Vec, /// Network memberships for rune-network policy (default: `bridge`). #[serde(default = "default_networks")] @@ -34,6 +34,9 @@ pub struct AgentSpec { /// Remote or local skill refs (`owner/repo/skill-name`); local copies live under `skills/`. #[serde(default)] pub skills: Vec, + /// When true, OpenAI chat completions use `tool_choice: "required"` whenever tools are present (forces at least one tool call per step). + #[serde(default)] + pub require_tool_call: bool, } fn default_networks() -> Vec { @@ -78,6 +81,28 @@ models: assert_eq!(spec.models.model_mapping["default"], "claude-sonnet-4-6"); } + #[test] + fn parse_tools_alias_maps_to_toolset() { + let yaml = r#" +name: alias-agent +version: 0.1.0 +instructions: Hi. +default_model: default +models: + model_mapping: + default: claude-sonnet-4-6 +tools: + - sum + - rune@shell +"#; + let spec: AgentSpec = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(spec.name, "alias-agent"); + assert_eq!( + spec.toolset, + vec!["sum".to_string(), "rune@shell".to_string()] + ); + } + #[test] fn parse_full() { let yaml = r#" diff --git a/crates/rune-spec/src/tool.rs b/crates/rune-spec/src/tool.rs index cef890f..b0c74ef 100644 --- a/crates/rune-spec/src/tool.rs +++ b/crates/rune-spec/src/tool.rs @@ -101,17 +101,92 @@ impl ToolDescriptor { .map_err(|e| SpecError::Parse(path.display().to_string(), e.to_string())) } - /// Load all `*.yaml` tool descriptors from a directory. - pub fn load_dir(dir: &Path) -> Result, SpecError> { - let mut tools = vec![]; - for entry in std::fs::read_dir(dir).map_err(|e| SpecError::Io(dir.to_path_buf(), e))? { - let entry = entry.map_err(|e| SpecError::Io(dir.to_path_buf(), e))?; + /// Extensions supported for process tools (must match `rune-runtime` process runner interpreters). + pub const PROCESS_SCRIPT_EXTENSIONS: [&str; 4] = ["py", "js", "mjs", "ts"]; + + fn is_process_script_path(path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|e| { + Self::PROCESS_SCRIPT_EXTENSIONS + .iter() + .any(|ext| ext.eq_ignore_ascii_case(e)) + }) + .unwrap_or(false) + } + + /// Build a process-tool descriptor from a script file under `agent_dir` (e.g. `tools/sum.py`). + pub fn for_process_script_file(agent_dir: &Path, script_path: &Path) -> Result { + if !script_path.is_file() { + return Err(SpecError::Validation(format!( + "not a file: {}", + script_path.display() + ))); + } + if !Self::is_process_script_path(script_path) { + return Err(SpecError::Validation(format!( + "unsupported tool script extension: {}", + script_path.display() + ))); + } + let rel = script_path.strip_prefix(agent_dir).map_err(|_| { + SpecError::Validation(format!( + "script {} is not under agent directory {}", + script_path.display(), + agent_dir.display() + )) + })?; + let module = rel.to_string_lossy().replace('\\', "/"); + let name = script_path + .file_stem() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + SpecError::Validation(format!("invalid tool script name: {}", script_path.display())) + })? + .to_string(); + + Ok(Self { + name, + version: default_version(), + runtime: ToolRuntime::Process, + module, + timeout_ms: default_timeout_ms(), + retry_policy: RetryPolicy::default(), + capabilities: vec![], + input_schema_ref: None, + output_schema_ref: None, + agent_ref: None, + max_depth: None, + mcp_server: None, + }) + } + + /// Discover `tools/*.py`, `tools/*.js`, `tools/*.mjs`, `tools/*.ts` and build process tool descriptors. + pub fn discover_process_scripts(agent_dir: &Path) -> Result, SpecError> { + let dir = agent_dir.join("tools"); + if !dir.is_dir() { + return Ok(vec![]); + } + + let mut paths: Vec = Vec::new(); + for entry in std::fs::read_dir(&dir).map_err(|e| SpecError::Io(dir.clone(), e))? { + let entry = entry.map_err(|e| SpecError::Io(dir.clone(), e))?; let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("yaml") { - tools.push(Self::load(&path)?); + if path.is_file() && Self::is_process_script_path(&path) { + paths.push(path); } } - Ok(tools) + paths.sort_by(|a, b| { + a.file_name() + .unwrap_or_default() + .cmp(b.file_name().unwrap_or_default()) + }); + + paths + .into_iter() + .map(|p| Self::for_process_script_file(agent_dir, &p)) + .collect() } } @@ -290,47 +365,59 @@ timeout_ms: 30000 } #[test] - fn load_dir_missing_dir_returns_io_error() { - let err = ToolDescriptor::load_dir(Path::new("/nonexistent/tools")).unwrap_err(); - assert!(err.to_string().contains("IO error")); + fn discover_process_scripts_missing_tools_dir_returns_empty() { + let dir = tempfile::tempdir().unwrap(); + let tools = ToolDescriptor::discover_process_scripts(dir.path()).unwrap(); + assert!(tools.is_empty()); } #[test] - fn load_dir_loads_yaml_files_only() { + fn discover_process_scripts_ignores_non_scripts() { let dir = tempfile::tempdir().unwrap(); - let tool_yaml = "name: tool_a\ntimeout_ms: 1000\n"; - std::fs::write(dir.path().join("tool_a.yaml"), tool_yaml).unwrap(); - std::fs::write(dir.path().join("README.md"), "docs").unwrap(); - std::fs::write(dir.path().join("config.toml"), "key = val").unwrap(); + let tools_sub = dir.path().join("tools"); + std::fs::create_dir(&tools_sub).unwrap(); + std::fs::write(tools_sub.join("README.md"), "docs").unwrap(); + std::fs::write(tools_sub.join("config.yaml"), "name: x\n").unwrap(); - let tools = ToolDescriptor::load_dir(dir.path()).unwrap(); - assert_eq!(tools.len(), 1); - assert_eq!(tools[0].name, "tool_a"); + let tools = ToolDescriptor::discover_process_scripts(dir.path()).unwrap(); + assert!(tools.is_empty()); } #[test] - fn load_dir_loads_multiple_tools() { + fn discover_process_scripts_loads_py_files() { let dir = tempfile::tempdir().unwrap(); - for name in &["alpha", "beta", "gamma"] { - std::fs::write( - dir.path().join(format!("{name}.yaml")), - format!("name: {name}\n"), - ) - .unwrap(); - } + let tools_sub = dir.path().join("tools"); + std::fs::create_dir(&tools_sub).unwrap(); + std::fs::write(tools_sub.join("alpha.py"), "# x").unwrap(); - let mut tools = ToolDescriptor::load_dir(dir.path()).unwrap(); - tools.sort_by(|a, b| a.name.cmp(&b.name)); - assert_eq!(tools.len(), 3); + let tools = ToolDescriptor::discover_process_scripts(dir.path()).unwrap(); + assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "alpha"); - assert_eq!(tools[1].name, "beta"); - assert_eq!(tools[2].name, "gamma"); + assert_eq!(tools[0].module, "tools/alpha.py"); + assert!(matches!(tools[0].runtime, ToolRuntime::Process)); } #[test] - fn load_dir_empty_dir_returns_empty_vec() { + fn discover_process_scripts_sorts_by_filename() { let dir = tempfile::tempdir().unwrap(); - let tools = ToolDescriptor::load_dir(dir.path()).unwrap(); - assert!(tools.is_empty()); + let tools_sub = dir.path().join("tools"); + std::fs::create_dir(&tools_sub).unwrap(); + std::fs::write(tools_sub.join("z.py"), "#").unwrap(); + std::fs::write(tools_sub.join("a.py"), "#").unwrap(); + + let tools = ToolDescriptor::discover_process_scripts(dir.path()).unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].name, "a"); + assert_eq!(tools[1].name, "z"); + } + + #[test] + fn for_process_script_file_rejects_unsupported_extension() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("tools/x.rb"); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(&p, "#").unwrap(); + let err = ToolDescriptor::for_process_script_file(dir.path(), &p).unwrap_err(); + assert!(err.to_string().contains("unsupported")); } } diff --git a/examples/agent-python/Runefile b/examples/agent-python/Runefile new file mode 100644 index 0000000..0357353 --- /dev/null +++ b/examples/agent-python/Runefile @@ -0,0 +1,21 @@ +# Agent identity & behaviour +name: calculator-agent +version: 0.1.0 +instructions: | + You are a calculator agent. To add two numbers you MUST call the `sum` tool + with JSON arguments {"a": , "b": }. Do not add or sum numbers + in your own text without calling `sum` first; always use the tool for addition, + then briefly confirm the result to the user. +default_model: default +max_steps: 10 +timeout_ms: 30000 +models: + providers: + - openai + model_mapping: + default: gpt-4o-mini + token_budget: 100000 +# OpenAI: forces at least one tool call per planner step when tools exist (see AgentSpec::require_tool_call) +require_tool_call: true +tools: + - sum \ No newline at end of file diff --git a/examples/agent-python/tools/sum.py b/examples/agent-python/tools/sum.py new file mode 100644 index 0000000..e0c43d2 --- /dev/null +++ b/examples/agent-python/tools/sum.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Process tool: JSON stdin {"a": int, "b": int} -> JSON stdout {"result": int}.""" +import json +import sys + + +def main() -> None: + data = json.load(sys.stdin) + result = int(data["a"]) + int(data["b"]) + json.dump({"result": result}, sys.stdout) + sys.stdout.write("\n") + + +if __name__ == "__main__": + main()