diff --git a/crates/gateway/config-ui/ui/package.json b/crates/gateway/config-ui/ui/package.json index 15b6fe5fc..3a59cd21b 100644 --- a/crates/gateway/config-ui/ui/package.json +++ b/crates/gateway/config-ui/ui/package.json @@ -11,6 +11,7 @@ "build": "node build.mjs", "watch": "node build.mjs --watch", "typecheck": "tsc --noEmit", + "pretest": "npm run build", "test": "node --test \"src/**/*.test.mjs\"" }, "dependencies": { diff --git a/crates/gateway/config-ui/ui/src/main.test.mjs b/crates/gateway/config-ui/ui/src/main.test.mjs index ce11c3250..51694e8b4 100644 --- a/crates/gateway/config-ui/ui/src/main.test.mjs +++ b/crates/gateway/config-ui/ui/src/main.test.mjs @@ -2,8 +2,8 @@ // and asserts the live desk's first paint: standalone with no stored // key, the auto-boot on #app must land on the key prompt - medallion, // title, labeled password input, and submit button - without touching -// the network. Run after `npm run build` (a debug `cargo build` also -// produces dist/). +// the network. Run with `npm test`, whose `pretest` script builds dist/ +// first. import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import path from "node:path"; diff --git a/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs b/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs index f700108cb..0faec115a 100644 --- a/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs +++ b/crates/gateway/config-ui/ui/src/styles/tokens.test.mjs @@ -3,8 +3,8 @@ // (shared-ui/tokens.css) and the cascade layer order, and // dist/index.html must link that stylesheet - a dropped import in // main.ts or a dropped would ship an unstyled UI without any -// build failure. Run after `npm run build` (a debug `cargo build` also -// produces dist/). +// build failure. Run with `npm test`, whose `pretest` script builds +// dist/ first. import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import path from "node:path"; diff --git a/crates/gateway/config-ui/ui/src/test-support.mjs b/crates/gateway/config-ui/ui/src/test-support.mjs index 648f0503b..fb09fcfcb 100644 --- a/crates/gateway/config-ui/ui/src/test-support.mjs +++ b/crates/gateway/config-ui/ui/src/test-support.mjs @@ -16,7 +16,7 @@ const CONFIG_URL = "http://127.0.0.1:8081/config/"; let appModulePromise; -/** Imports the built bundle once; run `npm run build` (or a debug `cargo build`) first. */ +/** Imports the built bundle once; `npm test` builds it first through the `pretest` script. */ export function loadApp() { if (!appModulePromise) { // Module evaluation touches `document` (the #app auto-boot probe), diff --git a/crates/harness-internal/runner/src/host-run.rs b/crates/harness-internal/runner/src/host-run.rs index 7f1115e36..4a9021684 100644 --- a/crates/harness-internal/runner/src/host-run.rs +++ b/crates/harness-internal/runner/src/host-run.rs @@ -18,7 +18,7 @@ use runvil::effect::ToolCallOrigin; use runvil::packs::Prelude; use runvil::tools::{ToolCatalog, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput}; use runvil::vfs::Access; -use runvil::{MissingService, Prompt, Requirements, UnavailablePack}; +use runvil::{MissingService, Prompt, Requirements, UnavailablePack, UsablePack}; use runvil_plugin::{HostServices, Pack, PackId, PackInfo, ServiceId, ToolContext}; use serde_json::Value; @@ -107,15 +107,21 @@ impl HostRunContext { self.catalog.clone() } - /// The run's roster: the ids of its usable Packs, with or without - /// tools, and an entry with the reason for each one that can't serve, - /// both in id order. - pub(crate) fn roster(&self) -> (Vec, Vec) { + /// The run's roster: each of its usable Packs, with or without tools, + /// with the vendor of its FQN, and an entry with the reason for each + /// one that can't serve, both in id order. + pub(crate) fn roster(&self) -> (Vec, Vec) { let mut usable = Vec::new(); let mut unavailable = Vec::new(); for (name, pack) in &self.packs { match pack { - PackState::Usable { .. } => usable.push(name.clone()), + PackState::Usable { info, .. } => { + let vendor = info + .fqn + .split_once('/') + .map_or(info.fqn, |(vendor, _)| vendor); + usable.push(UsablePack::new(name.clone(), vendor)); + } PackState::Unavailable(reason) => { unavailable.push(UnavailablePack::new(name.clone(), reason.clone())); } diff --git a/crates/harness-internal/runner/src/host-tests.rs b/crates/harness-internal/runner/src/host-tests.rs index bdbdcefe4..b04f5da64 100644 --- a/crates/harness-internal/runner/src/host-tests.rs +++ b/crates/harness-internal/runner/src/host-tests.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex}; use runvil::effect::{ToolCallOrigin, ToolCaller}; use runvil::vfs::{Origin, VfsRef}; -use runvil::{MissingService, Prompt, Requirements, UnavailablePack}; +use runvil::{MissingService, Prompt, Requirements, UnavailablePack, UsablePack}; use runvil_plugin::{ HostServices, Pack, PackFuture, PackId, PackInfo, PackRegistry, ServiceId, ServiceKey, ToolContext, ToolError, ToolId, ToolInfo, ToolOutput, @@ -191,7 +191,33 @@ fn catalog_ids(host: &HostContext, services: HostServices, prompt: &Prompt) -> V fn usable(host: &HostContext, prompt: &Prompt) -> Vec { let (run, _requirements) = begin(host, HostServices::new(), BTreeMap::new(), prompt); let (usable, _unavailable) = run.roster(); - usable.iter().map(ToString::to_string).collect() + usable.iter().map(|entry| entry.pack.to_string()).collect() +} + +#[test] +fn the_roster_hands_each_usable_pack_with_the_vendor_the_run_picked() { + let host = host(&[ + (info("runvil/kit"), offering(&["kit/a"])), + (info("acme/kit"), offering(&["kit/b"])), + (info("runvil/web"), offering(&["web/fetch"])), + (info("acme/poop"), Value::Null), + ]); + let (run, _requirements) = begin( + &host, + HostServices::new(), + BTreeMap::new(), + &prompt("uses:\n - acme/kit\n"), + ); + let (usable, _unavailable) = run.roster(); + assert_eq!( + usable, + [ + UsablePack::new(name("kit"), "acme"), + UsablePack::new(name("subagents"), "runvil"), + UsablePack::new(name("web"), "runvil"), + ], + "the declared acme/kit reports acme, and every undeclared Pack reports runvil" + ); } #[test] @@ -328,7 +354,7 @@ fn the_roster_lists_the_builtin_stub_as_usable_and_each_pack_that_cannot_serve_w let (usable, unavailable) = run.roster(); assert_eq!( usable, - [name("subagents")], + [UsablePack::new(name("subagents"), "runvil")], "the undeclared stub with no tools is on the roster" ); assert_eq!( diff --git a/crates/harness-internal/runner/src/prepare.rs b/crates/harness-internal/runner/src/prepare.rs index b91e6652b..f96c8074f 100644 --- a/crates/harness-internal/runner/src/prepare.rs +++ b/crates/harness-internal/runner/src/prepare.rs @@ -357,8 +357,8 @@ pub(crate) async fn prepare_noting_cancel( }) } -/// Sets `snapshot`'s catalog, roster, and declared preludes on `ctx`, then -/// prepares it for `prompt`. +/// Sets `snapshot`'s catalog, roster with each usable Pack's vendor, and +/// declared preludes on `ctx`, then prepares it for `prompt`. fn prepare_from_snapshot( ctx: RunContext, snapshot: &HostRunContext, diff --git a/crates/harness-internal/runner/tests/it/prepare-ready.rs b/crates/harness-internal/runner/tests/it/prepare-ready.rs index 224ae88fe..959b5b5cb 100644 --- a/crates/harness-internal/runner/tests/it/prepare-ready.rs +++ b/crates/harness-internal/runner/tests/it/prepare-ready.rs @@ -73,7 +73,10 @@ const DECLARES_SLOW: &str = "---\nname: declares-slow\ndescription: d\nrunvil: 0 /// A Pack-free prompt returning how many undeclared tools the run /// offers. const COUNTS_OFFERED: &str = "---\nname: counts\ndescription: d\nrunvil: 0\n---\n\n\ - # Title\n\n## Only\n\n```lua\nreturn #tools.extras() .. ' offered'\n```\n"; + # Title\n\n## Only\n\n```lua\n\ + local count = 0\n\ + for _, pack in ipairs(tools.packs.extras()) do count = count + #pack.tools end\n\ + return count .. ' offered'\n```\n"; /// A Pack whose tools wait on the test's [`Startup`]. struct Starting { @@ -306,7 +309,7 @@ async fn an_undeclared_pack_whose_ready_fails_leaves_the_run_going_without_its_t #[tokio::test] async fn an_undeclared_pack_whose_ready_fails_raises_its_reason_when_the_prompt_offers_it() { let offers_slow = "---\nname: offers-slow\ndescription: d\nrunvil: 0\n---\n\n\ - # Title\n\n## Only\n\n```lua\npacks.offer('slow')\nreturn 'offered'\n```\n"; + # Title\n\n## Only\n\n```lua\ntools.offer('slow')\nreturn 'offered'\n```\n"; let (host, _moves, _startup) = slow_host(Phase::Failed("the fixture server is down")); let outcome = drive_over(offers_slow, host).await; let RunOutcome::Failed { message, .. } = &outcome else { @@ -314,7 +317,7 @@ async fn an_undeclared_pack_whose_ready_fails_raises_its_reason_when_the_prompt_ }; assert!( message.contains( - "packs.offer: the slow Pack is unavailable in this run: \ + "tools.offer: the slow Pack is unavailable in this run: \ the fixture server is down" ), "the failure names the Pack and its ready failure: {message}" @@ -344,8 +347,8 @@ async fn an_undeclared_third_party_pack_that_never_gets_ready_holds_no_run_up() async fn an_undeclared_builtin_with_no_tools_has_an_object_the_prompt_can_offer() { let reads_stub = "---\nname: reads-stub\ndescription: d\nrunvil: 0\n---\n\n\ # Title\n\n## Only\n\n```lua\n\ - local stub = packs.get('subagents')\n\ - packs.offer('subagents')\n\ + local stub = tools.packs.get('subagents')\n\ + tools.offer('subagents')\n\ return stub.name .. ':' .. #stub.tools\n```\n"; let host = HostContext::new(registry(&[]), HostServices::new()); assert_eq!( diff --git a/crates/harness-internal/runner/tests/it/prepare.rs b/crates/harness-internal/runner/tests/it/prepare.rs index 0e7467c3a..686319234 100644 --- a/crates/harness-internal/runner/tests/it/prepare.rs +++ b/crates/harness-internal/runner/tests/it/prepare.rs @@ -374,7 +374,7 @@ async fn an_undeclared_third_party_pack_is_not_in_the_run() { let undeclared = "---\nname: undeclared\ndescription: d\nrunvil: 0\n---\n\n\ # Title\n\n## Only\n\n```lua\n\ local ok = pcall(tools.call, 'tools/echo', { value = 'hi' })\n\ - return tostring(ok) .. '|' .. tostring(packs.get('tools'))\n```\n"; + return tostring(ok) .. '|' .. tostring(tools.packs.get('tools'))\n```\n"; assert_eq!( completed(drive_over(undeclared, building(TOOLS)).await), "false|nil", @@ -402,19 +402,20 @@ async fn a_bare_entry_another_vendor_answers_is_refused_naming_its_fqn() { } #[tokio::test] -async fn the_lists_split_the_offering_by_declared_pack_and_a_script_calls_a_tool_object() { +async fn the_packs_split_by_declaration_and_a_script_calls_an_extras_tool_object() { let offering = "---\nname: offering\ndescription: d\nrunvil: 0\n\ uses:\n - tests/tools\n---\n\n# Title\n\n## Only\n\n```lua\n\ - local required, extras = tools.required(), tools.extras()\n\ - local extra = extras[1]\n\ - return #required .. ':' .. required[1].id .. '|' .. #extras .. ':' .. extra.id .. '|' .. \ - tools.call(extra, { value = 'hi' })\n```\n"; + local declared = tools.packs.get('tools')\n\ + local extras = tools.packs.extras()\n\ + local extra = extras[1].tools[1]\n\ + return tostring(declared.required) .. ':' .. declared.tools[1].id .. '|' .. \ + extras[1].name .. ':' .. extra.id .. '|' .. tools.call(extra, { value = 'hi' })\n```\n"; let extra = echo_pack("runvil/extra", None); let host = HostContext::new(registry(&[TOOLS, extra]), HostServices::new()); assert_eq!( completed(drive_over(offering, host).await), - "1:tools/echo|1:extra/echo|hi", - "the declared Pack's tool is required, the undeclared one's an extra, and its \ + "true:tools/echo|extra:extra/echo|hi", + "the declared Pack is required, the undeclared one an extra, and its tool's \ object reaches its Pack" ); } diff --git a/crates/plugin-mcp/src/dynamic-tests.rs b/crates/plugin-mcp/src/dynamic-tests.rs new file mode 100644 index 000000000..a736dd412 --- /dev/null +++ b/crates/plugin-mcp/src/dynamic-tests.rs @@ -0,0 +1,27 @@ +//! Tests for the Pack's two static tools. + +use std::collections::BTreeMap; + +use runvil_plugin::{Pack, PackId}; +use tokio::runtime::Handle; + +use super::Mcp; + +#[tokio::test] +async fn only_the_listing_tool_describes_tools() { + let name = PackId::parse("mcp").expect("a valid Pack id"); + let mcp = Mcp::new(&name, BTreeMap::new(), Handle::current()) + .expect("the Pack names its tools under a valid Pack id"); + let flags: Vec<(String, bool)> = mcp + .tools() + .iter() + .map(|tool| (tool.id.to_string(), tool.describes_tools)) + .collect(); + assert_eq!( + flags, + [ + ("mcp/get_dynamic_tools".to_owned(), true), + ("mcp/call_dynamic_tool".to_owned(), false), + ] + ); +} diff --git a/crates/plugin-mcp/src/dynamic.rs b/crates/plugin-mcp/src/dynamic.rs index 51d59525f..a1f558639 100644 --- a/crates/plugin-mcp/src/dynamic.rs +++ b/crates/plugin-mcp/src/dynamic.rs @@ -7,7 +7,8 @@ use std::collections::BTreeMap; use rmcp::model::JsonObject; use rmcp::service::{Peer, RoleClient}; use runvil_plugin::{ - Pack, PackFuture, PackId, ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, ToolOutput, + Pack, PackFuture, PackId, ToolCaller, ToolContext, ToolError, ToolErrorKind, ToolId, ToolInfo, + ToolOutput, }; use serde_json::{Map, Value, json}; use tokio::runtime::Handle; @@ -70,6 +71,16 @@ pub(crate) struct Mcp { runtime: Handle, } +/// Marks `text` by who called: the section's own script receives it +/// trusted, and every other caller, a model round included, untrusted, +/// because the text comes from the servers. +fn marked(caller: ToolCaller, text: String) -> ToolOutput { + match caller { + ToolCaller::Script => ToolOutput::trusted(text), + _ => ToolOutput::untrusted(text), + } +} + fn invalid(text: impl Into) -> ToolError { ToolError::message(text).with_kind(ToolErrorKind::InvalidArguments) } @@ -150,7 +161,8 @@ impl Mcp { } } }), - ); + ) + .describes_tools(true); let call = ToolInfo::new( id(CALL)?, CALL_DESCRIPTION, @@ -265,9 +277,9 @@ impl Mcp { }) } - /// Performs `call_dynamic_tool`. Every argument is checked before - /// anything is sent. - async fn call_tool(&self, args: Value) -> Result { + /// Performs `call_dynamic_tool` and returns the result's text. Every + /// argument is checked before anything is sent. + async fn call_tool(&self, args: Value) -> Result { let mut members = members( args, "call_dynamic_tool takes a JSON object with the strings `namespace` and `toolName` \ @@ -303,13 +315,18 @@ impl Pack for Mcp { ) -> PackFuture<'a, Result> { Box::pin(async move { let tool = cx.tool(); + let caller = cx.origin().caller; if *tool == self.get.id { - self.list(args).map(ToolOutput::untrusted) + self.list(args).map(|text| marked(caller, text)) } else if *tool == self.call.id { - self.call_tool(args).await + self.call_tool(args).await.map(|text| marked(caller, text)) } else { Err(ToolError::message(format!("MCP has no tool {tool}"))) } }) } } + +#[cfg(test)] +#[path = "dynamic-tests.rs"] +mod tests; diff --git a/crates/plugin-mcp/src/lib.rs b/crates/plugin-mcp/src/lib.rs index 8bf4ba4b6..36827b890 100644 --- a/crates/plugin-mcp/src/lib.rs +++ b/crates/plugin-mcp/src/lib.rs @@ -15,14 +15,20 @@ //! Cursor's `GetDynamicTools` and `CallDynamicTool`: //! //! - `get_dynamic_tools` takes the optional strings `namespace` and -//! `toolName` and answers JSON text, which a script reads with -//! `json.decode`. With no arguments it lists each server's namespace, +//! `toolName` and answers JSON text. It describes tools, so a script's +//! call receives the text as the servers wrote it and reads it with +//! `json.decode`, while a model's call receives it in the untrusted +//! envelope. With no arguments it lists each server's namespace, //! status, and tool names, in name order; with `namespace`, that //! server's tools with their input schemas; with both, one tool. //! - `call_dynamic_tool` takes the strings `namespace` and `toolName` and //! an optional object `arguments`, which defaults to `{}`, and calls that //! server's tool. //! +//! Both tools answer a model round with untrusted text, and the section's +//! own script with trusted text, so the script receives the server's text +//! without the envelope. +//! //! A namespace is the server's name exactly as the configuration writes //! it, and a tool's name is the one its server lists. Both must match //! exactly. A server's status is `ready`, `loading` while it is still @@ -79,8 +85,10 @@ //! - May depend on: `runvil-plugin`, `shared-*` crates, and outside //! libraries. `cargo test -p build-xtask` enforces the Plugin family's //! allow-list. Only [`install`] is public. -//! - Every output is untrusted, because descriptions, schemas, and results -//! all come from the servers. +//! - Output to a model round is untrusted, because descriptions, schemas, +//! and results all come from the servers, and output to the section's +//! own script is trusted, so the script receives the server's text +//! without the envelope. //! - No reason, log line, `Debug` output, or error names an `env` or //! `headers` value. They may name keys. //! - It runs on the Host's runtime, read through the contract's diff --git a/crates/plugin-mcp/src/result-tests.rs b/crates/plugin-mcp/src/result-tests.rs index 4972a758b..5787c31ea 100644 --- a/crates/plugin-mcp/src/result-tests.rs +++ b/crates/plugin-mcp/src/result-tests.rs @@ -1,10 +1,10 @@ -//! Tests for how an MCP answer becomes tool output or a tool error. +//! Tests for how an MCP answer becomes text or a tool error. use std::time::Duration; use rmcp::ServiceError; use rmcp::model::{CallToolResult, ServerResult}; -use runvil_plugin::{OutputTrust, ToolErrorKind}; +use runvil_plugin::ToolErrorKind; use serde_json::{Value, json}; use super::{output, render, service_error}; @@ -14,9 +14,7 @@ fn result(value: Value) -> CallToolResult { } fn text_of(value: Value) -> String { - let output = render(&result(value)).expect("a success renders"); - assert_eq!(output.trust(), OutputTrust::Untrusted); - output.text().to_owned() + render(&result(value)).expect("a success renders") } #[test] @@ -83,7 +81,7 @@ fn content_blocks_win_over_structured_content() { } #[test] -fn a_result_with_nothing_is_empty_untrusted_text() { +fn a_result_with_nothing_is_empty_text() { assert_eq!(text_of(json!({ "content": [] })), ""); } @@ -165,8 +163,7 @@ fn a_failed_request_maps_through_output_and_a_good_one_renders() { json!({ "content": [{ "type": "text", "text": "fine" }] }), )))) .expect("renders"); - assert_eq!(good.text(), "fine"); - assert_eq!(good.trust(), OutputTrust::Untrusted); + assert_eq!(good, "fine"); } #[test] diff --git a/crates/plugin-mcp/src/result.rs b/crates/plugin-mcp/src/result.rs index f1cc889a4..0eaae44c1 100644 --- a/crates/plugin-mcp/src/result.rs +++ b/crates/plugin-mcp/src/result.rs @@ -1,8 +1,8 @@ -//! Turns an MCP answer into tool output or a tool error. +//! Turns an MCP answer into text or a tool error. use rmcp::ServiceError; use rmcp::model::{CallToolResult, ContentBlock, ResourceContents, ServerResult}; -use runvil_plugin::{ToolError, ToolErrorKind, ToolOutput}; +use runvil_plugin::{ToolError, ToolErrorKind}; /// What a content block this change does not know becomes. rmcp's content /// types are `#[non_exhaustive]`, so a new kind reaches this text. @@ -41,10 +41,8 @@ pub(crate) fn service_error(error: &ServiceError) -> ToolError { ToolError::message(text).with_kind(kind) } -/// Maps the answer to a `tools/call` request. -pub(crate) fn output( - response: Result, -) -> Result { +/// Maps the answer to a `tools/call` request to its text. +pub(crate) fn output(response: Result) -> Result { match response.map_err(|e| service_error(&e))? { ServerResult::CallToolResult(result) => render(&result), _ => Err(ToolError::message( @@ -56,7 +54,7 @@ pub(crate) fn output( /// Joins a result's text. An `is_error` result becomes a backend error that /// carries the server's text. -pub(crate) fn render(result: &CallToolResult) -> Result { +pub(crate) fn render(result: &CallToolResult) -> Result { let text = text_of(result); if result.is_error == Some(true) { let message = if text.is_empty() { @@ -66,7 +64,7 @@ pub(crate) fn render(result: &CallToolResult) -> Result { }; return Err(ToolError::message(message).with_kind(ToolErrorKind::Backend)); } - Ok(ToolOutput::untrusted(text)) + Ok(text) } /// The text blocks, embedded text resources, and placeholders, joined with diff --git a/crates/plugin-mcp/src/server.rs b/crates/plugin-mcp/src/server.rs index f3b3a584b..9d3c0d9b2 100644 --- a/crates/plugin-mcp/src/server.rs +++ b/crates/plugin-mcp/src/server.rs @@ -8,7 +8,7 @@ use rmcp::model::{ RequestId, Tool, }; use rmcp::service::{Peer, PeerRequestOptions, RoleClient}; -use runvil_plugin::{ToolError, ToolErrorKind, ToolOutput}; +use runvil_plugin::{ToolError, ToolErrorKind}; use serde_json::Value; use tokio::runtime::Handle; use tokio::sync::watch; @@ -96,7 +96,7 @@ impl Drop for Server { } /// A spawned call, aborted when the call awaiting it is dropped. -struct AbortOnDrop(JoinHandle>); +struct AbortOnDrop(JoinHandle>); impl Drop for AbortOnDrop { fn drop(&mut self) { @@ -125,14 +125,14 @@ impl Drop for CancelOnDrop { } } -/// Sends one `tools/call` and renders the answer. It runs on the Host's +/// Sends one `tools/call` and renders the answer as text. It runs on the Host's /// runtime, so dropping the awaiting call aborts it and fires the guard. async fn send( peer: Peer, name: String, arguments: JsonObject, runtime: Handle, -) -> Result { +) -> Result { let request = ClientRequest::CallToolRequest(CallToolRequest::new( CallToolRequestParams::new(name).with_arguments(arguments), )); @@ -157,7 +157,7 @@ pub(crate) async fn call( peer: Peer, name: String, arguments: JsonObject, -) -> Result { +) -> Result { let mut task = AbortOnDrop(runtime.spawn(send(peer, name, arguments, runtime.clone()))); match (&mut task.0).await { Ok(outcome) => outcome, diff --git a/crates/plugin-mcp/tests/it/calls.rs b/crates/plugin-mcp/tests/it/calls.rs index 14e09bdd3..8e0c1a7bb 100644 --- a/crates/plugin-mcp/tests/it/calls.rs +++ b/crates/plugin-mcp/tests/it/calls.rs @@ -5,11 +5,11 @@ use std::time::Duration; use runvil_plugin::testing::TestCall; -use runvil_plugin::{ToolErrorKind, ToolId}; +use runvil_plugin::{OutputTrust, ToolErrorKind, ToolId}; use serde_json::json; use crate::fixture::Fixture; -use crate::support::{Silent, call, invoke, pack_over, ready, remote, settled}; +use crate::support::{Silent, call, invoke, invoke_as_script, pack_over, ready, remote, settled}; #[tokio::test(flavor = "multi_thread")] async fn a_call_sends_the_tools_own_name_and_returns_untrusted_text() { @@ -23,6 +23,40 @@ async fn a_call_sends_the_tools_own_name_and_returns_untrusted_text() { assert_eq!(text, r#"echo: {"q":"hello"}"#); } +#[tokio::test(flavor = "multi_thread")] +async fn a_script_call_returns_the_servers_text_trusted_without_the_envelope() { + let fixture = Fixture::start().await; + let pack = pack_over(json!({ "GitHub": remote(&fixture, &json!({})) })); + ready(&pack, "GitHub").await; + + let output = invoke_as_script( + &pack, + "call_dynamic_tool", + json!({ "namespace": "GitHub", "toolName": "Echo", "arguments": { "q": "hello" } }), + ) + .await + .expect("succeeds"); + assert_eq!(output.trust(), OutputTrust::Trusted); + assert_eq!(output.text(), r#"echo: {"q":"hello"}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_script_calls_is_error_result_is_the_same_backend_error_as_a_models() { + let fixture = Fixture::start().await; + let pack = pack_over(json!({ "GitHub": remote(&fixture, &json!({})) })); + ready(&pack, "GitHub").await; + + let error = invoke_as_script( + &pack, + "call_dynamic_tool", + json!({ "namespace": "GitHub", "toolName": "fail", "arguments": {} }), + ) + .await + .expect_err("isError fails"); + assert_eq!(error.kind(), ToolErrorKind::Backend); + assert_eq!(error.to_string(), "it failed"); +} + #[tokio::test(flavor = "multi_thread")] async fn a_call_without_arguments_sends_an_empty_object() { let fixture = Fixture::start().await; diff --git a/crates/plugin-mcp/tests/it/listing.rs b/crates/plugin-mcp/tests/it/listing.rs index 3d04b01a5..a557a993a 100644 --- a/crates/plugin-mcp/tests/it/listing.rs +++ b/crates/plugin-mcp/tests/it/listing.rs @@ -3,11 +3,11 @@ use std::time::Duration; -use runvil_plugin::ToolErrorKind; +use runvil_plugin::{OutputTrust, ToolErrorKind}; use serde_json::{Value, json}; use crate::fixture::Fixture; -use crate::support::{SECRET, Silent, invoke, list, pack_over, ready, remote}; +use crate::support::{SECRET, Silent, invoke, invoke_as_script, list, pack_over, ready, remote}; /// The fixture's tools as the listing with no arguments shows them. fn named_tools() -> Value { @@ -72,6 +72,20 @@ async fn the_listing_shows_each_server_by_its_own_name_with_the_tools_it_lists() ); } +#[tokio::test(flavor = "multi_thread")] +async fn a_script_listing_is_trusted_json_equal_to_the_models() { + let fixture = Fixture::start().await; + let pack = pack_over(json!({ "GitHub": remote(&fixture, &json!({})) })); + ready(&pack, "GitHub").await; + + let output = invoke_as_script(&pack, "get_dynamic_tools", Value::Null) + .await + .expect("the listing answers"); + assert_eq!(output.trust(), OutputTrust::Trusted); + let listing: Value = serde_json::from_str(output.text()).expect("the listing is JSON"); + assert_eq!(listing, list(&pack, json!({})).await); +} + #[tokio::test(flavor = "multi_thread")] async fn a_namespace_lists_its_tools_with_their_schemas_and_a_tool_name_lists_one() { let fixture = Fixture::start().await; diff --git a/crates/plugin-mcp/tests/it/support.rs b/crates/plugin-mcp/tests/it/support.rs index 1ddca5b05..eab5c40ab 100644 --- a/crates/plugin-mcp/tests/it/support.rs +++ b/crates/plugin-mcp/tests/it/support.rs @@ -8,7 +8,7 @@ use std::time::Duration; use runvil_plugin::testing::TestCall; use runvil_plugin::{ HostServices, OutputTrust, Pack, PackId, PackInfo, PackRegistry, ServiceKey, TOKIO_RUNTIME, - ToolError, ToolId, + ToolCaller, ToolError, ToolId, ToolOutput, }; use serde_json::{Map, Value, json}; use tokio::net::TcpListener; @@ -64,23 +64,37 @@ pub(crate) fn remote(fixture: &Fixture, headers: &Value) -> Value { json!({ "url": fixture.url, "headers": headers }) } -/// Calls the Pack's tool `mcp/` with `args` and returns its text, -/// checking that the output is untrusted. +fn tool_call(tool: &str) -> TestCall { + TestCall::new(ToolId::parse(&format!("mcp/{tool}")).expect("a tool id parses")) +} + +/// Calls the Pack's tool `mcp/` with `args` from a model round and +/// returns its text, checking that the output is untrusted. pub(crate) async fn invoke( pack: &Arc, tool: &str, args: Value, ) -> Result { - let test = TestCall::new(ToolId::parse(&format!("mcp/{tool}")).expect("a tool id parses")); + let test = tool_call(tool).with_caller(ToolCaller::Model); let output = pack.call(test.context(), args).await?; assert_eq!( output.trust(), OutputTrust::Untrusted, - "every output is untrusted" + "output to a model round is untrusted" ); Ok(output.text().to_owned()) } +/// Calls the Pack's tool `mcp/` with `args` from the section's own +/// script and returns the whole output. +pub(crate) async fn invoke_as_script( + pack: &Arc, + tool: &str, + args: Value, +) -> Result { + pack.call(tool_call(tool).context(), args).await +} + /// The JSON `get_dynamic_tools` answers for `args`. pub(crate) async fn list(pack: &Arc, args: Value) -> Value { let text = invoke(pack, "get_dynamic_tools", args) diff --git a/crates/plugin-user-input/src/lib.rs b/crates/plugin-user-input/src/lib.rs index 7519207ef..d934518cc 100644 --- a/crates/plugin-user-input/src/lib.rs +++ b/crates/plugin-user-input/src/lib.rs @@ -58,7 +58,7 @@ //! it. //! - The model can ask the operator only when the prompt's Lua opts in. //! A section offers the ask tool before its first loop round, with -//! `packs.offer('user-input')` or by its id with +//! the Pack, `tools.offer("user-input")`, or by its id with //! `tools.offer("user-input/ask")`, and the model sees it as //! `user-input_ask`. Neither inserting nor declaring the Pack //! advertises anything to the model. diff --git a/crates/runvil-internal/engine/src/error.rs b/crates/runvil-internal/engine/src/error.rs index faf2e221d..a30d80e83 100644 --- a/crates/runvil-internal/engine/src/error.rs +++ b/crates/runvil-internal/engine/src/error.rs @@ -269,7 +269,7 @@ pub(crate) enum Error { in_scope: Vec, }, - /// A `tools.call` named no local tool and no tool in the run's catalog. + /// A `tools.call` named no adhoc tool and no tool in the run's catalog. /// /// Script-initiated dispatch resolves a canonical id against the run's /// whole catalog, not the section's advertised scope - the scope shapes diff --git a/crates/runvil-internal/engine/src/execute.rs b/crates/runvil-internal/engine/src/execute.rs index 8680ef2f8..66c8fb50d 100644 --- a/crates/runvil-internal/engine/src/execute.rs +++ b/crates/runvil-internal/engine/src/execute.rs @@ -59,7 +59,7 @@ pub use bindings::ModelBindings; pub use config::{RunContext, RunLimits}; pub use error::{RunError, RunErrorKind, SourceLocation}; pub use requirements::{ - MissingService, RequirementCheck, Requirements, UnavailablePack, UnmetRequirement, + MissingService, RequirementCheck, Requirements, UnavailablePack, UnmetRequirement, UsablePack, }; pub use run::{ AnswerRecord, ChatAnswerRecord, Effect, EffectAnswer, EffectId, EffectRecord, FailureRecord, diff --git a/crates/runvil-internal/engine/src/execute/config.rs b/crates/runvil-internal/engine/src/execute/config.rs index d1e13aa29..9392e7142 100644 --- a/crates/runvil-internal/engine/src/execute/config.rs +++ b/crates/runvil-internal/engine/src/execute/config.rs @@ -22,7 +22,7 @@ use crate::tools::ToolCatalog; use super::bindings::ModelBindings; use super::fill; -use super::requirements::{Requirements, UnavailablePack}; +use super::requirements::{Requirements, UnavailablePack, UsablePack}; /// The inputs for one run of a prompt. /// @@ -103,6 +103,10 @@ pub struct RunContext { /// it can't. Set with [`packs`](RunContext::packs). Empty by /// default. pub(super) roster: BTreeMap>, + /// The vendor the caller reports for each Pack of the roster that + /// can serve, by name. Set with [`packs`](RunContext::packs). Empty + /// by default. + pub(super) vendors: BTreeMap, /// The run's Pack preludes, in declaration order. Set with /// [`preludes`](RunContext::preludes); every section VM installs each /// one before `require`. Empty by default. @@ -140,6 +144,7 @@ impl RunContext { model_bindings: ModelBindings::default(), tools: ToolCatalog::default(), roster: BTreeMap::new(), + vendors: BTreeMap::new(), preludes: Vec::new(), } } @@ -260,19 +265,32 @@ impl RunContext { } /// Sets the run's roster: every installed Pack that can serve the run, - /// declared by the prompt or not, and every one that can't, with the - /// reason. + /// declared by the prompt or not, with its vendor, and every one that + /// can't, with the reason. /// /// Each usable Pack gets an object in the run's Lua, with or without /// tools. An unavailable one gets none, and offering it raises its /// reason. A name in both lists counts as unavailable. Without a roster, /// the run's Lua sees the declared Packs and the Packs with a tool /// in the catalog. + /// + /// The caller reports each usable Pack's vendor, as it picked the + /// Pack. Every tool-call and tool-result event for a catalog tool of + /// that Pack records the vendor, and a run given no roster records + /// none. #[must_use] - pub fn packs(mut self, usable: Vec, unavailable: Vec) -> RunContext { + pub fn packs( + mut self, + usable: Vec, + unavailable: Vec, + ) -> RunContext { + self.vendors = usable + .iter() + .map(|entry| (entry.pack.clone(), entry.vendor.clone())) + .collect(); self.roster = usable .into_iter() - .map(|pack| (pack, None)) + .map(|entry| (entry.pack, None)) .chain( unavailable .into_iter() @@ -322,11 +340,18 @@ impl RunContext { /// so `prepare` skips them. When the context's current model is /// `None`, `prepare` skips the fill and the checks, and the model /// bindings stay empty. + /// + /// `prepare` also sets [`Requirements::reserved_adhoc`] when a tool of + /// the catalog or a Pack of the roster, usable or not, has the Pack + /// id `adhoc`, which holds a section's own tools. #[must_use] pub fn prepare(mut self, prompt: &Prompt) -> (RunContext, Requirements) { let mut requirements = Requirements::default(); self.model_bindings = fill::fill_model_bindings(prompt, self.model.as_ref(), &mut requirements); + let adhoc = |pack: &PackId| pack.to_string() == "adhoc"; + requirements.reserved_adhoc = self.tools.tools().iter().any(|tool| adhoc(&tool.id.pack())) + || self.roster.keys().any(adhoc); (self, requirements) } @@ -415,6 +440,7 @@ impl fmt::Debug for RunContext { .field("model_bindings", &self.model_bindings) .field("tools", &self.tools) .field("roster", &self.roster) + .field("vendors", &self.vendors) .field( "preludes", &self.preludes.iter().map(Prelude::pack).collect::>(), diff --git a/crates/runvil-internal/engine/src/execute/context-bound.rs b/crates/runvil-internal/engine/src/execute/context-bound.rs index 965706840..3ffadf29c 100644 --- a/crates/runvil-internal/engine/src/execute/context-bound.rs +++ b/crates/runvil-internal/engine/src/execute/context-bound.rs @@ -16,9 +16,9 @@ use super::super::config::RunContext; /// Builds the run's shared tool set: the frontmatter's declared Packs, /// the `roster` moved out of the context, and every catalog tool the run -/// can offer bound with the tool's info (its schema, -/// description, and output kind), so run-time execution never consults the -/// catalog again and never holds an implementation. +/// can offer bound with the tool's info (its schema, description, output +/// kind, and listing flag) and its Pack's vendor, so run-time execution +/// never consults the catalog again and never holds an implementation. pub(super) fn bound_tool_set( prompt: &Prompt, ctx: &RunContext, @@ -51,12 +51,18 @@ fn offered_bindings(ctx: &RunContext) -> Vec { tracing::warn!(tool = %tool.id, %name, %reason, "a tool is left out of the offering"); continue; } - offered.push(ToolBinding::from_info(&name, tool)); + offered.push(catalog_binding(ctx, &name, tool)); taken.insert(name); } offered } +/// Binds `tool` under `alias`, stamped with the vendor the caller +/// reported for its Pack, if any. +fn catalog_binding(ctx: &RunContext, alias: &str, tool: &ToolInfo) -> ToolBinding { + ToolBinding::from_info(alias, tool).with_vendor(ctx.vendors.get(&tool.id.pack()).cloned()) +} + /// Why `tool` can't be offered under `name`: no model round could /// advertise it, or an earlier offered tool has it. `None` when it can be /// offered. @@ -84,7 +90,7 @@ pub(super) fn catalog_bindings(ctx: &RunContext) -> BTreeMap PackId { PackId::parse(text).expect("a static valid id") } +/// A prompt that declares no Pack and no model. +fn declares_nothing() -> Prompt { + Prompt::parse( + "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n# Run\n\n## Only\n\nDone.\n", + "requirements-test", + ) + .0 + .expect("the requirements test prompt parses") +} + +fn context() -> RunContext { + RunContext::new("requirements-test", TEST_SEED, Timestamp::UNIX_EPOCH) +} + +fn catalog(ids: &[&str]) -> ToolCatalog { + ToolCatalog::new(ids.iter().map(|tool| { + ToolInfo::new( + ToolId::parse(tool).expect("a static valid tool id"), + format!("{tool} tool"), + json!({ "type": "object" }), + ) + })) + .expect("distinct ids make a valid catalog") +} + +#[test] +fn a_catalog_or_roster_naming_the_adhoc_pack_id_refuses_the_run_once() { + let contexts = [ + ( + "the catalog", + context().tools(catalog(&["web/fetch", "adhoc/grab"])), + ), + ( + "the usable roster", + context().packs(vec![UsablePack::new(id("adhoc"), "acme")], Vec::new()), + ), + ( + "the unavailable roster", + context().packs( + Vec::new(), + vec![UnavailablePack::new(id("adhoc"), "the server is down")], + ), + ), + ]; + for (named_by, ctx) in contexts { + let (_, requirements) = ctx.prepare(&declares_nothing()); + assert!(requirements.reserved_adhoc, "{named_by} names adhoc"); + assert!(!requirements.is_satisfied(), "{named_by} names adhoc"); + let refusal = requirements + .refusal() + .expect("a reserved Pack id refuses the run"); + assert_eq!( + refusal.kind(), + RunErrorKind::RequirementsUnmet, + "{named_by}" + ); + let notice = refusal.to_string(); + assert_eq!( + notice.matches(RESERVED_ADHOC).count(), + 1, + "{named_by}: the notice names the reservation once: {notice}" + ); + } +} + +#[test] +fn a_context_naming_no_adhoc_pack_leaves_the_reservation_clear() { + let ctx = context().tools(catalog(&["web/fetch"])).packs( + vec![UsablePack::new(id("web"), "runvil")], + vec![UnavailablePack::new(id("mcp"), "the server is down")], + ); + let (_, requirements) = ctx.prepare(&declares_nothing()); + assert!(!requirements.reserved_adhoc); + assert!(requirements.is_satisfied(), "{requirements:?}"); +} + +#[test] +fn merge_keeps_a_set_reservation_from_either_side() { + let reserved = || Requirements { + reserved_adhoc: true, + ..Requirements::default() + }; + let mut kept = reserved(); + kept.merge(Requirements::default()); + assert!(kept.reserved_adhoc, "merging a clear report keeps the flag"); + let mut gained = Requirements::default(); + gained.merge(reserved()); + assert!(gained.reserved_adhoc, "merging a set report sets the flag"); + assert_eq!( + gained.notice(), + format!("the environment cannot satisfy this prompt:\n{RESERVED_ADHOC}") + ); +} + fn web_down() -> UnavailablePack { UnavailablePack::new(id("web"), "the search provider is missing") } diff --git a/crates/runvil-internal/engine/src/execute/requirements.rs b/crates/runvil-internal/engine/src/execute/requirements.rs index 4df53ec85..83c79d30e 100644 --- a/crates/runvil-internal/engine/src/execute/requirements.rs +++ b/crates/runvil-internal/engine/src/execute/requirements.rs @@ -39,26 +39,35 @@ pub struct Requirements { /// /// The caller adds these. The run fails until the Pack can serve. pub unavailable: Vec, + /// Whether the run's tool catalog or Pack roster names the Pack id + /// `adhoc`, which holds a section's own tools. + /// + /// [`RunContext::prepare`](super::RunContext::prepare) sets it. The run + /// fails while it is set. + pub reserved_adhoc: bool, } impl Requirements { /// Returns whether the report lets the run proceed. /// - /// That holds when every model requirement is met and every required - /// Pack is present, can serve, and has the services it needs. + /// That holds when every model requirement is met, every required + /// Pack is present, can serve, and has the services it needs, and no + /// Pack takes the Pack id `adhoc`. #[must_use] pub fn is_satisfied(&self) -> bool { self.unmet_requirements.is_empty() && self.missing_required.is_empty() && self.missing_services.is_empty() && self.unavailable.is_empty() + && !self.reserved_adhoc } /// Adds the entries of `other` to this report. /// /// Use it to combine the caller's Pack report with the report /// from `RunContext::prepare`, so that one refusal names every gap. - /// The merge skips an entry the report already holds. + /// The merge skips an entry the report already holds, and keeps + /// `reserved_adhoc` set when either report sets it. /// /// The merge drops the `missing_required` entry of a Pack that /// lacks a service or is unavailable: the service or unavailable entry @@ -67,6 +76,7 @@ impl Requirements { push_new(&mut self.missing_required, other.missing_required); push_new(&mut self.missing_services, other.missing_services); push_new(&mut self.unavailable, other.unavailable); + self.reserved_adhoc |= other.reserved_adhoc; let (services, unavailable) = (&self.missing_services, &self.unavailable); self.missing_required.retain(|id| { !services.iter().any(|missing| missing.pack == *id) @@ -120,6 +130,12 @@ impl Requirements { entry.pack, entry.reason ); } + if self.reserved_adhoc { + notice.push_str( + "\n- the run's tool catalog or Pack roster names the Pack id adhoc, \ + which holds a section's own tools", + ); + } for unmet in &self.unmet_requirements { let _ = match unmet.check { RequirementCheck::ContextMinimum => write!( @@ -149,6 +165,36 @@ fn push_new(entries: &mut Vec, incoming: Vec) { } } +/// An installed Pack that can serve the run, and its vendor. +/// +/// The run's roster, set with +/// [`RunContext::packs`](super::RunContext::packs), lists every one the +/// run holds. The caller reports each Pack's vendor as it picked the +/// Pack, and every tool-call and tool-result event for one of the Pack's +/// tools records it. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct UsablePack { + /// The installed Pack. + pub pack: PackId, + /// The vendor the caller picked the Pack from, such as `runvil` or + /// `acme`. + pub vendor: String, +} + +impl UsablePack { + /// Creates an entry stating that `pack`, from `vendor`, can serve. + /// + /// Only the caller builds these, for its roster. + #[must_use] + pub fn new(pack: PackId, vendor: impl Into) -> UsablePack { + UsablePack { + pack, + vendor: vendor.into(), + } + } +} + /// An installed Pack that cannot serve the run, and why. /// /// The caller's Pack report lists each one the prompt declares, and the diff --git a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs index ea1db6b1a..651a40111 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/apply.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/apply.rs @@ -184,15 +184,16 @@ impl Scheduler { /// `ToolResult` report - under the model's call id when the model /// issued the call (a tool's own failure then resumes as untrusted /// failure text), else as a script call classified by the binding's - /// declared output kind. The counts were taken at dispatch, so the - /// body is handed `None` for them. + /// declared output kind. A script call that receives a listing marks + /// the chain's frame's innermost open adhoc call. The counts were + /// taken at dispatch, so the body is handed `None` for them. fn accept_tool_call( - &self, + &mut self, chain: ChainIndex, call: &ToolCallContinuation, result: std::result::Result, ) -> Result { - let chain = &self.chains[chain.index()]; + let chain = &mut self.chains[chain.index()]; // The shared dispatch body reports through this chain's emitter, so // its reports land in the buffer under this chain's task. let emitter = chain.ctx.emitter(); @@ -228,12 +229,17 @@ impl Scheduler { section, Some(call.report), ) { - Ok(outcome) => ToolCallOutcome::from_dispatch( - call.binding.output_kind(), - call.binding.alias(), - outcome.into_content(), - ) - .map_err(Error::from), + Ok(outcome) => { + if outcome.listing() { + chain.frame_mut()?.mark_listing(); + } + ToolCallOutcome::from_dispatch( + call.binding.output_kind(), + call.binding.alias(), + outcome.into_content(), + ) + .map_err(Error::from) + } Err(error) => Err(Error::from(error)), }, } diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs index e8a09f5f0..e1fdaadc5 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chain_record.rs @@ -11,7 +11,6 @@ use std::sync::Arc; use mlua::Thread; use runvil_types::ids::{ChainId, TaskId}; use runvil_types::metrics::Usage; -use runvil_types::tools::ToolId; use runvil_vfs::Access; use crate::error::DepthVia; @@ -19,7 +18,7 @@ use crate::execute::context::RunState; use crate::execute::protocol::{Answer, Blocked}; use crate::execute::section_context::{SectionContext, TaskSeed}; use crate::execute::walk_target::SlicePath; -use crate::lua::UsageAnchor; +use crate::lua::{Advertised, UsageAnchor}; use crate::model::{Message, ModelId}; use crate::parser::{Block, BodyText, Prompt, Section}; use crate::{Error, Result}; @@ -267,12 +266,13 @@ pub(super) struct Chain { /// `tasks.concurrency` lowers it, clamped to the parent's. pub(super) concurrency: NonZeroUsize, /// The tool scope the chain's last `chat` round advertised, keyed by - /// alias, each name's catalog tool or `None` for a local tool: a copy - /// of the section's rendered tool list's map. The round's answer is - /// checked against it, so a tool name the model invents or reaches for - /// outside the scope fails as out of scope. `None` before the chain's - /// first round. - pub(super) advertised: Option>>, + /// wire name, each name's tool id and vendor: a copy of the section's + /// rendered tool list's map. The round's answer is checked against it, + /// so a tool name the model invents or reaches for outside the scope + /// fails as out of scope, and each requested call's event is stamped + /// from it. It never routes a call. `None` before the chain's first + /// round. + pub(super) advertised: Option>, /// The provider's token counts for the chain's `chat` rounds, which the /// next round's context precheck counts from. pub(super) anchor: ChatAnchor, diff --git a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs index e73d25ec1..24e22026f 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/chat.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/chat.rs @@ -2,8 +2,8 @@ //! section VM. //! //! Dispatch first refuses an empty list. It then resolves the round's -//! binding and the section's rendered tool list - built from the bound -//! and local halves and attached to the list by the first round to reach +//! binding and the section's rendered tool list - built from the +//! section's offers and attached to the list by the first round to reach //! the build, then reused by every later round - copies the list's dispatch //! map onto the chain as `advertised`, prechecks the projected //! conversation, with room kept for the reply, against the model's @@ -29,15 +29,14 @@ use runvil_types::emitter::Emitter; use runvil_types::event::{ReplyOrigin, lifecycle}; use runvil_types::ids::RoundId; use runvil_types::metrics::ToolCallEvent; -use runvil_types::tools::ToolId; use crate::execute::protocol::{Answer, ChatOutcome, ChatResult}; use crate::execute::run::Effect; use crate::execute::scope::prepare_effective_scope; use crate::execute::turn::{Served, advance_turn, report_model_turn}; use crate::lua::{ - MessageList, OverflowReason, current_tool_bindings, output_reserve, precheck, project_messages, - resolve_model_binding, + Advertised, MessageList, OverflowReason, current_dynamic_tools, current_tool_bindings, + output_reserve, precheck, project_messages, resolve_model_binding, }; use crate::model::{Completion, CompletionErrorKind, CompletionResult, ModelBinding, ToolCall}; use crate::{Error, Result}; @@ -123,15 +122,16 @@ impl Scheduler { }; let handles = frame.reporting_handles(); let emitter = handles.emitter; - // The section's first round renders the effective bindings plus - // every local Lua tool, and every offer raises after that, so each + // The section's first round renders its static offers and records + // its dynamic ones, and every offer raises after that, so each // later round sends the same list. let rendered = if let Some(rendered) = list.rendered_tools() { rendered } else { - let bound = current_tool_bindings(chain.ctx.tool_set(), vm.tool_runtime())?; - let locals = vm.local_tool_schemas()?; - let rendered = prepare_effective_scope(&bound, &locals, emitter.as_ref(), §ion)?; + let (set, runtime, adhoc) = (chain.ctx.tool_set(), vm.tool_runtime(), vm.adhoc_tools()); + let bound = current_tool_bindings(set, runtime, adhoc)?; + let mut rendered = prepare_effective_scope(&bound, emitter.as_ref(), §ion)?; + rendered.dynamic = current_dynamic_tools(set, runtime, adhoc, &bound)?; list.attach_rendered_tools(rendered.clone()); rendered }; @@ -338,14 +338,19 @@ impl ArrivedRound { served: &Served, turn: u32, calls: &[ToolCall], - advertised: &BTreeMap>, + advertised: &BTreeMap, global_exists: impl Fn(&str) -> bool, ) -> Result { let events: Vec = calls .iter() .map(|call| { + let (tool, vendor) = match advertised.get(call.name()).cloned() { + Some(Advertised { id, vendor }) => (Some(id), vendor), + None => (None, None), + }; ToolCallEvent::new(call.id(), call.name(), tool_call_arguments(call).clone()) - .with_tool(advertised.get(call.name()).cloned().flatten()) + .with_tool(tool) + .with_vendor(vendor) }) .collect(); self.emitter diff --git a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs index bea112ad7..26ed0df93 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/dispatch.rs @@ -5,7 +5,7 @@ //! lands. Every store operation is a leaf yield, handed to the caller //! uniformly for all backends - no inline fast path - so interleaving //! behavior never depends on which backend serves the mount. -//! The `tool_call`, `tool_calls`, `local_tool_done`, `chat`, `spawn`, +//! The `tool_call`, `tool_calls`, `adhoc_tool_done`, `chat`, `spawn`, //! `timer`, and task wait, inspection, note, concurrency, and cancel arms //! are defined in their own modules. @@ -93,7 +93,7 @@ fn blocked_on(request: &Request) -> Option { | Request::Concurrency { .. } | Request::Note { .. } | Request::Cancel { .. } - | Request::LocalToolDone { .. } => None, + | Request::AdhocToolDone { .. } => None, } } @@ -119,7 +119,7 @@ impl Scheduler { /// /// # Errors /// Returns the store arm's error when the chain's access capability is - /// gone, or the `local_tool_done` arm's error when no local tool call is + /// gone, or the `adhoc_tool_done` arm's error when no adhoc tool call is /// open. pub(super) fn dispatch(&mut self, id: ChainIndex, request: Request) -> Result<()> { self.chains[id.index()].blocked = blocked_on(&request); @@ -195,7 +195,7 @@ impl Scheduler { self.dispatch_tool_calls(id, calls, turn); Ok(()) } - Request::LocalToolDone { outcome } => self.dispatch_local_tool_done(id, outcome), + Request::AdhocToolDone { outcome } => self.dispatch_adhoc_tool_done(id, outcome), Request::Store { op } => self.dispatch_vfs(id, op), Request::Chat { list, binding } => { self.dispatch_chat(id, &list, binding); diff --git a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs index 1f4281b79..941ac2b24 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/test_hooks.rs @@ -12,7 +12,7 @@ use runvil_types::tools::ToolId; use crate::Result; use crate::execute::walk_target::SlicePath; -use crate::lua::ToolSet; +use crate::lua::{Advertised, ToolSet}; use super::chain::ChainStart; use super::chain_record::{Admission, CallDepth, ChainKind, SectionIndex}; @@ -62,7 +62,13 @@ impl Scheduler { .advertised .as_mut() .expect("the chain advertised a round") - .insert(name.to_owned(), Some(tool)); + .insert( + name.to_owned(), + Advertised { + id: tool, + vendor: None, + }, + ); } /// The state of one task's slot, or `None` when no task with that id diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs new file mode 100644 index 000000000..dba3159f0 --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call-adhoc.rs @@ -0,0 +1,113 @@ +//! The adhoc arm of the `tool_call` arm: a call routed to an adhoc tool, +//! answered with its handler, and the `adhoc_tool_done` request that +//! closes it. + +use std::sync::Arc; + +use runvil_types::event::lifecycle; +use runvil_types::tools::OutputTrust; + +use crate::execute::protocol::{AdhocToolOutcome, Answer, ToolCallOutcome}; +use crate::execute::section_context::AdhocCall; +use crate::lua::{ScriptReport, ToolBinding}; +use crate::{Error, Result}; + +use super::super::{CallSite, ChainIndex, Scheduler}; +use super::ToolCallDispatch; + +impl Scheduler { + /// Answers a call routed to the adhoc tool `binding` binds: the + /// one-time counts install, the attempt counted under the tool's id, + /// the call opened on the frame, then the handler's answer. + pub(super) fn prepare_adhoc_call( + &mut self, + id: ChainIndex, + binding: &ToolBinding, + args: serde_json::Value, + site: &CallSite, + ) -> Result { + let report = ScriptReport { + turn: self.call_turn(id, site), + }; + let counts = self.section_call_counts(id)?; + // The attempt counts at dispatch, before the handler runs, so a + // handler that then fails still counts. + let counted = binding.id().to_string(); + counts.ensure(&counted)?; + counts.increment(&counted)?; + self.chains[id.index()] + .frame_mut()? + .push_adhoc_call(AdhocCall { + tool: binding.id().clone(), + alias: binding.alias().to_owned(), + call_id: site.call_id().map(str::to_owned), + report, + listing: false, + }); + Ok(ToolCallDispatch::Answered(Answer::ToolCallResult(Ok( + ToolCallOutcome::Adhoc { + alias: binding.alias().to_owned(), + args, + }, + )))) + } + + /// Dispatches an `adhoc_tool_done` request: closes the chain's + /// innermost open adhoc tool call and answers it. A returned text + /// reports `TOOL_CALL_SUCCEEDED` and a trusted `ToolResult` - the + /// prompt author wrote the handler, so its output passes verbatim - + /// under the tool's id, no vendor, the call id (or none for a script + /// call), and the turn recorded at dispatch, then resumes as the text. + /// A call marked as having fetched a listing while it was open instead + /// returns its text wrapped with the run's nonce and reports it + /// untrusted. A rejected return reports `TOOL_CALL_FAILED` and resumes + /// as its error. A raise reports `TOOL_CALL_FAILED` and resumes empty: + /// the shim raises the handler's own value in its place. + /// + /// # Errors + /// Returns [`Error::Internal`] when the chain has no frame or no open + /// adhoc call, which only a shim bug produces. + pub(in crate::execute::scheduler) fn dispatch_adhoc_tool_done( + &mut self, + id: ChainIndex, + outcome: AdhocToolOutcome, + ) -> Result<()> { + let chain = &mut self.chains[id.index()]; + let emitter = Arc::clone(chain.ctx.emitter()); + let section = chain.section_name().to_owned(); + let call = chain.frame_mut()?.pop_adhoc_call().ok_or(Error::internal( + "an adhoc tool's completion closes the call that opened it", + ))?; + let answer = match outcome { + AdhocToolOutcome::Returned(text) => { + emitter.report(§ion, lifecycle::TOOL_CALL_SUCCEEDED); + let (text, trust) = if call.listing { + (chain.ctx.nonce().wrap(&text), OutputTrust::Untrusted) + } else { + (text, OutputTrust::Trusted) + }; + emitter.tool_result( + §ion, + call.report.turn, + call.call_id.as_deref().unwrap_or(""), + &call.alias, + Some(&call.tool), + None, + &text, + trust, + ); + Ok(ToolCallOutcome::Plain(text)) + } + AdhocToolOutcome::BadReturn(error) => { + emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); + Err(Error::from(error)) + } + AdhocToolOutcome::Raised => { + emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); + Ok(ToolCallOutcome::Plain(String::new())) + } + }; + self.answer_inline(id, Answer::ToolCallResult(answer)); + Ok(()) + } +} diff --git a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs index a98737972..4d82dc5b7 100644 --- a/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs +++ b/crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs @@ -1,16 +1,18 @@ //! The `tool_call` arm: one dispatched tool call yielded by a section VM, //! script-initiated (`tools.call`) or model-issued (the loop shim, with the -//! model's `call_id`), plus the `local_tool_done` arm that closes a local +//! model's `call_id`), plus the `adhoc_tool_done` arm that closes an adhoc //! tool call. //! //! Each call's route is decided once, by `tool_route`, on the driver thread -//! before any leaf work. A local Lua tool is answered with its handler; -//! anything else is a catalog tool, which resolves against the run's -//! offering (a model call by wire name, a script call by canonical id, -//! which also reaches a catalog tool left out of the offering), has its -//! attempt counted, and is issued as a `ToolCall` effect, carrying an -//! identity forked from the chain's access, whose answer the driver applies -//! through the shared dispatch body. The call's `CallSite` names who asked. +//! before any leaf work, from the call's `CallSite`, which names who +//! asked. A model call's wire name routes by the entry the current frame's +//! section's rendered list holds for it, and a script call routes by id. +//! An adhoc tool is answered with its handler; anything else is a catalog +//! tool, which resolves against the run's offering (a model call by wire +//! name, a script call by id, which also reaches a catalog tool left out +//! of the offering), has its attempt counted, and is issued as a +//! `ToolCall` effect, carrying an identity forked from the chain's access, +//! whose answer the driver applies through the shared dispatch body. //! A model-issued call, alone or batched, always resumes a bound tool with //! content - a tool's own failure becomes untrusted failure text - and //! `ToolResult` fires under the id and the turn of the round that requested @@ -20,7 +22,7 @@ //! The loop sends a round's unanswered calls together as one //! `tool_calls` request. The arm walks them in the model's order and //! issues each catalog call at once into a slot of the chain's batch, -//! stopping at the first call whose route is a local tool. A call that +//! stopping at the first call whose route is an adhoc tool. A call that //! fails to route or to prepare fills its slot with the error and stops //! the batch too. Each landed answer fills its call's slot, and the //! chain is answered once no slot is open: every text in call order, or @@ -29,34 +31,37 @@ //! an ordinary `tool_call`. The calls in flight run concurrently, each //! under its own forked identity. //! -//! A local tool call is a two-yield handshake. The `tool_call` answer is -//! [`ToolCallOutcome::Local`], which hands the shim the handler, and the -//! frame opens a [`LocalCall`] for it. The shim runs the handler inside the +//! An adhoc tool call is a two-yield handshake. The `tool_call` answer is +//! [`ToolCallOutcome::Adhoc`], which hands the shim the handler, and the +//! frame opens an [`AdhocCall`](crate::execute::section_context::AdhocCall) +//! for it. The shim runs the handler inside the //! chain's block coroutine, so the handler's own suspending calls are -//! ordinary requests of this chain, then yields `local_tool_done`. That +//! ordinary requests of this chain, then yields `adhoc_tool_done`. That //! arm closes the innermost open call and reports it: the succeeded or -//! failed observation and, on success, the trusted `ToolResult` under the -//! call id recorded at dispatch. A local call never issues leaf work of -//! its own. +//! failed observation and, on success, the `ToolResult` under the tool's +//! id and the call id recorded at dispatch, trusted unless the handler +//! fetched a listing while the call was open. An adhoc call never issues +//! leaf work of its own. The adhoc arm sits in the `adhoc` child. + +#[path = "tool_call-adhoc.rs"] +mod adhoc; use std::sync::Arc; use std::sync::atomic::Ordering; -use runvil_types::event::lifecycle; -use runvil_types::tools::{OutputTrust, ToolId}; +use runvil_types::tools::ToolId; use runvil_vfs::detail; -use crate::execute::protocol::{Answer, LocalToolOutcome, ToolCallOutcome, ToolCallRecord}; +use crate::execute::protocol::{Answer, ToolCallOutcome, ToolCallRecord}; use crate::execute::run::{Effect, ToolCallOrigin, ToolCaller}; -use crate::execute::section_context::LocalCall; -use crate::lua::{ScriptReport, ToolCallCounts, current_tool_bindings}; +use crate::lua::{ScriptReport, ToolBinding, ToolCallCounts, current_tool_bindings}; use crate::{Error, Result}; use super::dispatch::unbound_tool_call; use super::{CallSite, ChainIndex, Continuation, Scheduler, ToolCallContinuation, prompt_origin}; /// How one `tool_call` dispatch resolved: a bound call issued as an effect -/// and parked on the pending table, or a local Lua tool's answer settled +/// and parked on the pending table, or an adhoc tool's answer settled /// on the driver thread. A preparation failure returns `Err` instead. #[derive(Debug)] pub(super) enum ToolCallDispatch { @@ -68,9 +73,9 @@ pub(super) enum ToolCallDispatch { /// a lone call and a batched one route alike. #[derive(Debug)] enum ToolRoute { - /// A local Lua tool of the chain's section VM, answered with its + /// An adhoc tool of the chain's section VM, answered with its /// handler. - Local, + Adhoc(ToolBinding), /// A catalog tool, resolved against the run's offering and issued as /// an effect. Catalog, @@ -78,7 +83,7 @@ enum ToolRoute { impl Scheduler { /// Dispatches a `tool_call` request. An issued bound call parks the - /// chain in the pending table; a local Lua tool's handler resumes the + /// chain in the pending table; an adhoc tool's handler resumes the /// chain on the spot; every preparation failure - an unbound alias, /// the counts install - is the call's answer, resumed /// into the caller so an author `pcall` catches it exactly as a tool @@ -122,18 +127,16 @@ impl Scheduler { ) { let mut batch = Vec::with_capacity(calls.len()); for (slot, call) in calls.into_iter().enumerate() { - let issued = match self.tool_route(id, &call.name) { - Ok(ToolRoute::Local) => break, - Ok(ToolRoute::Catalog) => self.prepare_catalog_call( - id, - &call.name, - call.arguments, - CallSite::Batch { - call_id: call.id, - turn, - slot, - }, - ), + let site = CallSite::Batch { + call_id: call.id, + turn, + slot, + }; + let issued = match self.tool_route(id, &call.name, &site) { + Ok(ToolRoute::Adhoc(_)) => break, + Ok(ToolRoute::Catalog) => { + self.prepare_catalog_call(id, &call.name, call.arguments, site) + } Err(error) => Err(error), }; match issued { @@ -148,18 +151,29 @@ impl Scheduler { self.answer_full_batch(id); } - /// Decides the route of the call named `alias`, the one decision a - /// lone call and a batch share. A local tool of the chain's section VM - /// wins over the catalog. - fn tool_route(&self, id: ChainIndex, alias: &str) -> Result { - if self.chains[id.index()] - .frame()? - .vm()? - .has_local_tool(alias)? - { - return Ok(ToolRoute::Local); - } - Ok(ToolRoute::Catalog) + /// Decides the route of the call at `site` naming `name`, the one + /// decision a lone call and a batch share. A model call looks its wire + /// name up in the rendered list of the current frame's section, never + /// in the chain's advertised scope, which another section's rounds + /// overwrite while an adhoc tool's handler runs; with no rendered list + /// it routes as a catalog call. A script call routes by id. Either goes + /// to the adhoc tool the id names, when it is one. + fn tool_route(&self, id: ChainIndex, name: &str, site: &CallSite) -> Result { + let vm = self.chains[id.index()].frame()?.vm()?; + let target = if site.call_id().is_some() { + match vm.rendered_target(name) { + Some(advertised) => advertised.id.to_string(), + None => return Ok(ToolRoute::Catalog), + } + } else { + name.to_owned() + }; + let adhoc = if target.contains('/') { + vm.adhoc_tools().binding(&target)? + } else { + None + }; + Ok(adhoc.map_or(ToolRoute::Catalog, ToolRoute::Adhoc)) } /// The turn every report of a call at `site` carries: the requesting @@ -171,13 +185,22 @@ impl Scheduler { } /// The chain's section's tool-call counts, which the section's first - /// call installs. + /// call installs, seeded from its rendered list once there is one and + /// from its live scope before. fn section_call_counts(&mut self, id: ChainIndex) -> Result { let chain = &mut self.chains[id.index()]; - let effective = - current_tool_bindings(chain.ctx.tool_set(), chain.frame()?.vm()?.tool_runtime())?; + let vm = chain.frame()?.vm()?; + let ids = match vm.rendered_ids() { + Some(ids) => ids, + None => { + current_tool_bindings(chain.ctx.tool_set(), vm.tool_runtime(), vm.adhoc_tools())? + .iter() + .map(|binding| binding.id().clone()) + .collect() + } + }; let ctx = chain.ctx.clone(); - chain.frame_mut()?.script_call_counts(&ctx, &effective) + chain.frame_mut()?.script_call_counts(&ctx, &ids) } /// Fills `slot` of the batch the chain is parked on with a landed @@ -199,7 +222,7 @@ impl Scheduler { Answer::ToolCallResult(Ok(ToolCallOutcome::Plain(text))) => Ok(text), Answer::ToolCallResult(Err(error)) => Err(error), Answer::ToolCallResult(Ok( - ToolCallOutcome::Structured(_) | ToolCallOutcome::Local { .. }, + ToolCallOutcome::Structured(_) | ToolCallOutcome::Adhoc { .. }, )) | Answer::Infer(_) | Answer::Call(_) @@ -244,13 +267,13 @@ impl Scheduler { self.answer_inline(id, Answer::ToolCallsResult(answer)); } - /// The fallible half of tool-call dispatch, by the call's route: a - /// local tool (the one-time counts install, the attempt counted, then + /// The fallible half of tool-call dispatch, by the call's route: an + /// adhoc tool (the one-time counts install, the attempt counted, then /// the handler's answer), or a catalog tool, which /// `prepare_catalog_call` issues. /// /// Every report the call makes carries the turn of the model round - /// that requested it, so a local handler earlier in the same batch + /// that requested it, so an adhoc handler earlier in the same batch /// that runs model rounds cannot shift it. A script call, or a /// model-issued one yielded without a turn, reports the counter's /// value at dispatch. @@ -261,30 +284,8 @@ impl Scheduler { args: serde_json::Value, site: CallSite, ) -> Result { - match self.tool_route(id, alias)? { - ToolRoute::Local => { - let report = ScriptReport { - turn: self.call_turn(id, &site), - }; - let counts = self.section_call_counts(id)?; - // The attempt counts at dispatch, before the handler runs, so - // a handler that then fails still counts. - counts.ensure(alias)?; - counts.increment(alias)?; - self.chains[id.index()] - .frame_mut()? - .push_local_call(LocalCall { - alias: alias.to_owned(), - call_id: site.call_id().map(str::to_owned), - report, - }); - Ok(ToolCallDispatch::Answered(Answer::ToolCallResult(Ok( - ToolCallOutcome::Local { - alias: alias.to_owned(), - args, - }, - )))) - } + match self.tool_route(id, alias, &site)? { + ToolRoute::Adhoc(binding) => self.prepare_adhoc_call(id, &binding, args, &site), ToolRoute::Catalog => { self.prepare_catalog_call(id, alias, args, site)?; Ok(ToolCallDispatch::Issued) @@ -378,55 +379,4 @@ impl Scheduler { self.issue(id, effect, resume); Ok(()) } - - /// Dispatches a `local_tool_done` request: closes the chain's - /// innermost open local tool call and answers it. A returned text - /// reports `TOOL_CALL_SUCCEEDED` and a trusted `ToolResult` - the - /// prompt author wrote the handler, so its output passes verbatim - - /// under the call id (or none for a script call) and the turn recorded - /// at dispatch, then resumes as the text. A rejected return reports - /// `TOOL_CALL_FAILED` and resumes as its error. A raise reports - /// `TOOL_CALL_FAILED` and resumes empty: the shim raises the handler's - /// own value in its place. - /// - /// # Errors - /// Returns [`Error::Internal`] when the chain has no frame or no open - /// local call, which only a shim bug produces. - pub(super) fn dispatch_local_tool_done( - &mut self, - id: ChainIndex, - outcome: LocalToolOutcome, - ) -> Result<()> { - let chain = &mut self.chains[id.index()]; - let emitter = Arc::clone(chain.ctx.emitter()); - let section = chain.section_name().to_owned(); - let call = chain.frame_mut()?.pop_local_call().ok_or(Error::internal( - "a local tool's completion closes the call that opened it", - ))?; - let answer = match outcome { - LocalToolOutcome::Returned(text) => { - emitter.report(§ion, lifecycle::TOOL_CALL_SUCCEEDED); - emitter.tool_result( - §ion, - call.report.turn, - call.call_id.as_deref().unwrap_or(""), - &call.alias, - None, - &text, - OutputTrust::Trusted, - ); - Ok(ToolCallOutcome::Plain(text)) - } - LocalToolOutcome::BadReturn(error) => { - emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); - Err(Error::from(error)) - } - LocalToolOutcome::Raised => { - emitter.report(§ion, lifecycle::TOOL_CALL_FAILED); - Ok(ToolCallOutcome::Plain(String::new())) - } - }; - self.answer_inline(id, Answer::ToolCallResult(answer)); - Ok(()) - } } diff --git a/crates/runvil-internal/engine/src/execute/scope.rs b/crates/runvil-internal/engine/src/execute/scope.rs index 9b3fd31d4..1af4745a4 100644 --- a/crates/runvil-internal/engine/src/execute/scope.rs +++ b/crates/runvil-internal/engine/src/execute/scope.rs @@ -6,23 +6,23 @@ use runvil_model_client::detail; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; -use crate::lua::{RenderedTools, ToolBinding}; -use crate::model::ToolSchema; +use crate::lua::{Advertised, RenderedTools, ToolBinding}; use crate::{Error, Result}; -/// Builds the section's rendered tool list under the validation boundary -/// pair, reported through the chain's `emitter` under `section`. +/// Builds the section's rendered tool list from its static bindings, +/// adhoc tools included, under the validation boundary pair, reported +/// through the chain's `emitter` under `section`. The dynamic list is left +/// empty for the caller to fill. /// /// # Errors /// Returns the schema-construction error of [`prepare_scoped_tools`]. pub(super) fn prepare_effective_scope( bindings: &[ToolBinding], - local_schemas: &[ToolSchema], emitter: &Emitter, section: &str, ) -> Result { emitter.report(section, lifecycle::TOOL_SCOPE_VALIDATION_STARTED); - let result = prepare_scoped_tools(bindings, local_schemas); + let result = prepare_scoped_tools(bindings); emitter.report( section, if result.is_ok() { @@ -34,21 +34,10 @@ pub(super) fn prepare_effective_scope( result } -pub(super) fn prepare_scoped_tools( - bindings: &[ToolBinding], - local_schemas: &[ToolSchema], -) -> Result { - let mut schemas = Vec::with_capacity(bindings.len() + local_schemas.len()); +pub(super) fn prepare_scoped_tools(bindings: &[ToolBinding]) -> Result { + let mut schemas = Vec::with_capacity(bindings.len()); let mut dispatch = BTreeMap::new(); for binding in bindings { - // A local alias may equal an offered tool's wire name; the local - // tool wins. - if local_schemas - .iter() - .any(|schema| schema.name() == binding.alias()) - { - continue; - } // Build every advertised schema through the validated constructor, // so an unusable wire name or a non-object JSON Schema is refused here // rather than sent to the model. @@ -62,14 +51,17 @@ pub(super) fn prepare_scoped_tools( source: Box::new(error), })?; schemas.push(schema); - dispatch.insert(binding.alias().to_owned(), Some(binding.id().clone())); - } - // Local tools are prompt-author Lua functions with no live implementation; - // the `tool_call` arm answers their calls on the section VM. The alias - // was validated at `tools.offer_local` registration. - for schema in local_schemas { - dispatch.insert(schema.name().to_owned(), None); - schemas.push(schema.clone()); + dispatch.insert( + binding.alias().to_owned(), + Advertised { + id: binding.id().clone(), + vendor: binding.vendor().map(str::to_owned), + }, + ); } - Ok(RenderedTools { schemas, dispatch }) + Ok(RenderedTools { + schemas, + dispatch, + dynamic: Vec::new(), + }) } diff --git a/crates/runvil-internal/engine/src/execute/section_context-construct.rs b/crates/runvil-internal/engine/src/execute/section_context-construct.rs index 1c1193d53..0fe7a0dda 100644 --- a/crates/runvil-internal/engine/src/execute/section_context-construct.rs +++ b/crates/runvil-internal/engine/src/execute/section_context-construct.rs @@ -144,7 +144,7 @@ impl SectionContext { sys, item: seed.item, counts: None, - local_calls: Vec::new(), + adhoc_calls: Vec::new(), emitter: Arc::clone(ctx.emitter()), turns: Arc::clone(ctx.turns()), }) @@ -218,7 +218,7 @@ impl SectionContext { sys, item: None, counts: None, - local_calls: Vec::new(), + adhoc_calls: Vec::new(), emitter: Arc::clone(ctx.emitter()), turns: Arc::clone(ctx.turns()), }) diff --git a/crates/runvil-internal/engine/src/execute/section_context.rs b/crates/runvil-internal/engine/src/execute/section_context.rs index 922ad968d..3a81a1509 100644 --- a/crates/runvil-internal/engine/src/execute/section_context.rs +++ b/crates/runvil-internal/engine/src/execute/section_context.rs @@ -25,8 +25,9 @@ use std::sync::atomic::AtomicU32; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; +use runvil_types::tools::ToolId; -use crate::lua::{ProseState, ScriptReport, SectionVm, ToolBinding, ToolCallCounts}; +use crate::lua::{ProseState, ScriptReport, SectionVm, ToolCallCounts}; use crate::{Error, Result, subst}; use super::context::RunState; @@ -43,11 +44,13 @@ pub(super) struct TaskSeed { pub(super) index: Option, } -/// One local tool call whose handler is running in the frame's block +/// One adhoc tool call whose handler is running in the frame's block /// coroutine: opened when the `tool_call` is answered with the handler, -/// closed by the shim's `local_tool_done`, which reports under it. -pub(super) struct LocalCall { - /// The local tool's alias. +/// closed by the shim's `adhoc_tool_done`, which reports under it. +pub(super) struct AdhocCall { + /// The adhoc tool's id, which its result reports under. + pub(super) tool: ToolId, + /// The adhoc tool's wire name. pub(super) alias: String, /// The model-issued call id, `None` for a script call. pub(super) call_id: Option, @@ -55,6 +58,10 @@ pub(super) struct LocalCall { /// model turn the call was made in even when the handler runs model /// rounds of its own. pub(super) report: ScriptReport, + /// Whether the handler's own `tools.call` received a listing while + /// this call was the frame's innermost open one, so its result is + /// wrapped and reported untrusted. + pub(super) listing: bool, } /// One section entry's owned frame within a run. @@ -86,10 +93,10 @@ pub(super) struct SectionContext { /// The per-section tool-call counts, installed at the first /// script-initiated `tools.call`. counts: Option, - /// The local tool calls whose handlers are running, innermost last: a - /// handler that calls another local tool opens a nested entry, and - /// each `local_tool_done` closes the innermost. - local_calls: Vec, + /// The adhoc tool calls whose handlers are running, innermost last: a + /// handler that calls another adhoc tool opens a nested entry, and + /// each `adhoc_tool_done` closes the innermost. + adhoc_calls: Vec, /// The frame's task-scoped event emitter: the chain's own, so every /// report the frame makes - the teardown boundary, the completion - /// is stamped with the chain's task. @@ -183,8 +190,9 @@ impl SectionContext { /// The frame's tool-call counts for a script-initiated dispatch, /// running the same one-time scope install the first prose block /// performs (the counts and the Lua `tools.calls` table, the model - /// freeze, the `sys.model` enrichment), then seeding the id of any tool - /// the effective scope has gained since. The returned handle shares the + /// freeze, the `sys.model` enrichment), then seeding any of `ids` not + /// seeded yet. `ids` is the section's live scope before its first + /// render and its rendered list after. The returned handle shares the /// installed counts, so the dispatch task increments them off the /// driver thread. /// @@ -194,7 +202,7 @@ impl SectionContext { pub(super) fn script_call_counts( &mut self, ctx: &RunState, - effective: &[ToolBinding], + ids: &[ToolId], ) -> Result { let Self { vm, sys, counts, .. @@ -204,24 +212,32 @@ impl SectionContext { "the section frame's VM lives until the frame's own drop", )); }; - install_section_scope(vm, ctx, sys, counts, effective)?; + install_section_scope(vm, ctx, sys, counts, ids)?; let counts = counts .as_ref() .ok_or(Error::internal("the scope install seeds the counts"))?; - for binding in effective { - counts.ensure(&binding.id().to_string())?; + for id in ids { + counts.ensure(&id.to_string())?; } Ok(counts.clone()) } - /// Opens a local tool call whose handler the shim is about to run. - pub(super) fn push_local_call(&mut self, call: LocalCall) { - self.local_calls.push(call); + /// Opens an adhoc tool call whose handler the shim is about to run. + pub(super) fn push_adhoc_call(&mut self, call: AdhocCall) { + self.adhoc_calls.push(call); } - /// Closes the innermost open local tool call; `None` when none is open. - pub(super) fn pop_local_call(&mut self) -> Option { - self.local_calls.pop() + /// Closes the innermost open adhoc tool call; `None` when none is open. + pub(super) fn pop_adhoc_call(&mut self) -> Option { + self.adhoc_calls.pop() + } + + /// Marks the innermost open adhoc tool call as having received a + /// listing; a frame with no open call marks nothing. + pub(super) fn mark_listing(&mut self) { + if let Some(call) = self.adhoc_calls.last_mut() { + call.listing = true; + } } } @@ -252,9 +268,10 @@ fn prose_renderer( /// Installs the section's one-time tool-call counts and model resolution, /// gated on the counts slot: the first consumer - the section's first /// script-initiated `tools.call` - performs the install, and every later -/// call is a no-op. The counts install backs the Lua `tools.calls` table; -/// the model resolution freezes the section's binding and enriches -/// `sys.model`. +/// call is a no-op. The counts install backs the Lua `tools.calls` table, +/// seeded with `ids`: the section's live scope before its first render +/// and its rendered list after. The model resolution freezes the +/// section's binding and enriches `sys.model`. /// /// # Errors /// Returns the [`Error`] of the counts install, the model resolution, or @@ -264,12 +281,12 @@ fn install_section_scope( ctx: &RunState, sys: &mut serde_json::Value, counts: &mut Option, - effective_bindings: &[ToolBinding], + ids: &[ToolId], ) -> Result<()> { if counts.is_some() { return Ok(()); } - *counts = Some(vm.install_tool_call_counts(effective_bindings)?); + *counts = Some(vm.install_tool_call_counts(ids)?); let resolved_model = crate::lua::resolve_model_binding(ctx.models(), vm.model_runtime())?; if let Some(binding) = resolved_model.as_ref() { let current = vm.current_sys(sys)?; diff --git a/crates/runvil-internal/engine/src/execute/tests.rs b/crates/runvil-internal/engine/src/execute/tests.rs index 6f0aacaff..bc90f24c8 100644 --- a/crates/runvil-internal/engine/src/execute/tests.rs +++ b/crates/runvil-internal/engine/src/execute/tests.rs @@ -161,6 +161,7 @@ const STORE_SECTIONS: &str = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ ## First\n\n```lua\nstore.write('state.txt', 'first')\n```\n\n\ ## Second\n\n```lua\nstore.append('state.txt', '\\nsecond')\nreturn \"second\"\n```\n"; +mod adhoc_tools; mod batch_turn; mod chat_arm; mod chat_record_rebuild; @@ -168,13 +169,13 @@ mod chat_scope; mod compaction; mod debug_and_counts; mod effects; +mod event_vendors; mod exec_flow; mod exit_rules; mod fanout_acceptance; mod full_id_calls; mod happens_before; mod live_infer; -mod local_tools; mod model_and_reply; mod models_compact; mod models_loop; @@ -198,6 +199,7 @@ mod tasks; mod timeouts; mod tool_call_access; mod tool_call_arm; +mod tool_listings; mod tool_loop; mod tool_scoping; mod unified_pipeline; diff --git a/crates/runvil-internal/engine/src/execute/tests/adhoc_tools-wrappers.rs b/crates/runvil-internal/engine/src/execute/tests/adhoc_tools-wrappers.rs new file mode 100644 index 000000000..29341c49f --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/tests/adhoc_tools-wrappers.rs @@ -0,0 +1,90 @@ +//! Tests for an adhoc tool that wraps a catalog tool: it renders the +//! catalog tool's description and schema unchanged, and a model's call to +//! it gives the model the same result text as a call to the catalog tool. + +use runvil_types::event::Event; + +use super::super::fixtures::untrusted_echo_tool; +use super::*; + +/// The wrapper of the untrusted catalog tool `t`, offered as `search`, +/// whose function returns `tools.call(t, args)`. +const WRAPPER: &str = "local t = tools.get('tools/untrusted_echo') + tools.offer(tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, function(args) + return tools.call(t, args) + end))"; + +/// One loop over a section that runs `offer`, in which the model calls +/// `name` with `value = 'hi'` and then replies. +fn called(offer: &str, name: &str) -> (Trace, Vec) { + let gateway = ScriptedChat::new(vec![ + calls(&[("call_1", name, r#"{"value":"hi"}"#)]), + text("done"), + ]); + let source = one_section(&format!( + "{offer} + messages:user('search') + models.loop() + return 'ok'" + )); + let trace = loop_scenario(source) + .offer("untrusted_echo", untrusted_echo_tool()) + .chat(&gateway) + .run(); + assert_eq!(trace.text(), "ok"); + (trace, gateway.requests()) +} + +#[test] +fn a_wrapper_renders_the_catalog_tools_description_and_schema_unchanged() { + let (_, direct) = called("tools.offer('tools/untrusted_echo')", "untrusted_echo"); + let (_, through) = called(WRAPPER, "search"); + let (catalog, wrapper) = (&direct[0].tools[0], &through[0].tools[0]); + assert_eq!(wrapper.name(), "search"); + assert_eq!( + wrapper.description(), + catalog.description(), + "the description is the catalog text, never in the envelope" + ); + assert_eq!( + serde_json::to_string(wrapper.parameters()).expect("a schema serializes"), + serde_json::to_string(catalog.parameters()).expect("a schema serializes"), + "the schema is the same text" + ); +} + +#[test] +fn a_model_call_to_the_wrapper_gives_the_model_the_catalog_calls_text_byte_for_byte() { + let (_, direct) = called("tools.offer('tools/untrusted_echo')", "untrusted_echo"); + let (trace, through) = called(WRAPPER, "search"); + let text = last_tool_turn_content(&direct); + assert!( + text.contains(" Some(( + tool.as_ref().map(ToString::to_string), + vendor.clone(), + *trusted, + )), + _ => None, + }) + .expect("the wrapper's call reports its result"); + assert_eq!( + result, + (Some("adhoc/search".to_owned()), None, true), + "the result names the adhoc tool's id and no vendor, and is trusted because \ + the author's function produced the text" + ); +} diff --git a/crates/runvil-internal/engine/src/execute/tests/local_tools.rs b/crates/runvil-internal/engine/src/execute/tests/adhoc_tools.rs similarity index 68% rename from crates/runvil-internal/engine/src/execute/tests/local_tools.rs rename to crates/runvil-internal/engine/src/execute/tests/adhoc_tools.rs index c1b29553e..c2b60d8e4 100644 --- a/crates/runvil-internal/engine/src/execute/tests/local_tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/adhoc_tools.rs @@ -1,10 +1,12 @@ -//! Tests for `tools.offer_local`: the registration rules run end to end, and -//! the `models.loop` shim's local-tool rounds are driven at prompt level, -//! so a model-issued call to a local tool runs its handler inside the -//! block coroutine - store calls included - and its trusted result is sent -//! back to the model verbatim. `jump` is refused only while a handler -//! runs, even through a saved reference. The refusal of an offer once the -//! section's tool list is rendered sits in `rendered_tools`. +//! Tests for adhoc tools defined with `tools.adhoc` and offered with +//! `tools.offer`: the definition rules run end to end, and the +//! `models.loop` shim's adhoc-tool rounds are driven at prompt level, so a +//! model-issued call to an adhoc tool runs its handler inside the block +//! coroutine - store calls included - and its trusted result is sent back +//! to the model verbatim. `jump` is refused only while a handler runs, +//! even through a saved reference. An adhoc tool answers a model's call +//! only when offered. The refusal of an offer once the section's tool list +//! is rendered sits in `rendered_tools`. use runvil_parser::test_support::{one_section, prompt}; @@ -14,14 +16,14 @@ use super::models_loop::loop_scenario; use super::tool_call_arm::{ToolRecorder, tool_result_lines}; use super::*; -/// The `grab` local tool registration the loop tests open with, followed -/// by one loop over a single user message; `handler` is the Lua body of -/// the handler function, given `args`. +/// The `grab` adhoc tool definition and offer the loop tests open with, +/// followed by one loop over a single user message; `handler` is the Lua +/// body of the handler function, given `args`. fn grab_loop(handler: &str) -> String { one_section(&format!( - "tools.offer_local('grab', 'Grab a value', {{ value = 'string' }}, function(args) + "tools.offer(tools.adhoc({{ name = 'grab', description = 'Grab a value', parameters = {{ value = 'string' }} }}, function(args) {handler} - end) + end)) messages:user('Use the tool.') models.loop() return messages[#messages].content" @@ -33,7 +35,7 @@ fn grab_loop(handler: &str) -> String { const OTHER: (&str, &str) = ("Other", "return 'jumped'"); #[test] -fn local_tool_handler_result_returns_to_the_model() { +fn adhoc_tool_handler_result_returns_to_the_model() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "grab", "{\"value\":\"hi\"}")]), text("final answer"), @@ -60,7 +62,7 @@ fn local_tool_handler_result_returns_to_the_model() { } #[test] -fn local_tool_multiple_calls_in_one_response_all_run() { +fn adhoc_tool_multiple_calls_in_one_response_all_run() { let gateway = ScriptedChat::new(vec![ calls(&[ ("c1", "grab", "{\"value\":\"a\"}"), @@ -70,10 +72,10 @@ fn local_tool_multiple_calls_in_one_response_all_run() { ]); let source = one_section( "local calls = {} - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) calls[#calls + 1] = args.value return 'ok ' .. args.value - end) + end)) messages:user('Use the tool.') models.loop() return table.concat(calls, ',') .. '|' .. messages[#messages].content", @@ -98,7 +100,7 @@ fn local_tool_multiple_calls_in_one_response_all_run() { } #[test] -fn local_tool_handler_error_surfaces_as_a_tool_failure() { +fn adhoc_tool_handler_error_surfaces_as_a_tool_failure() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "grab", "{\"value\":\"hi\"}")]), text("unreachable"), @@ -130,7 +132,7 @@ fn local_tool_handler_error_surfaces_as_a_tool_failure() { } #[test] -fn cancel_during_a_looping_local_tool_handler_returns_promptly() { +fn cancel_during_a_looping_adhoc_tool_handler_returns_promptly() { // The round's answer hands the model's call to the looping handler in // the next step. A cancel landing before that step starts ends the run // the same way, and a hang fails through nextest's timeout. @@ -174,9 +176,9 @@ fn a_handler_that_calls_jump_fails_the_run() { calls(&[("call_1", "grab", "{\"value\":\"hi\"}")]), text("unreachable"), ]); - let only = "tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + let only = "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) jump('## Other') - end) + end)) messages:user('Use the tool.') models.loop() return 'no jump'"; @@ -190,20 +192,20 @@ fn a_handler_that_calls_jump_fails_the_run() { ); } -/// The start of the message `jump` fails with inside a local tool handler. -const JUMP_REFUSAL: &str = "jump is unavailable inside a local tool handler"; +/// The start of the message `jump` fails with inside an adhoc tool handler. +const JUMP_REFUSAL: &str = "jump is unavailable inside an adhoc tool handler"; -/// The `grab` registration whose handler calls a `jump` reference the -/// block saved before registering it. +/// The `grab` definition whose handler calls a `jump` reference the +/// block saved before defining it. const SAVED_JUMP_GRAB: &str = "local j = jump\n\ - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args)\n\ + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args)\n\ j('## Other')\n\ - end)\n"; + end))\n"; #[test] fn a_saved_jump_reference_is_refused_in_a_handler_the_script_calls() { let only = format!( - "{SAVED_JUMP_GRAB}tools.call('grab', {{ value = 'hi' }})\n\ + "{SAVED_JUMP_GRAB}tools.call('adhoc/grab', {{ value = 'hi' }})\n\ return 'no jump'" ); let trace = loop_scenario(prompt("", &[("Only", &only), OTHER])).run(); @@ -238,7 +240,7 @@ fn a_saved_jump_reference_is_refused_in_a_handler_the_model_calls() { #[test] fn a_caller_that_catches_the_jump_refusal_continues_the_block() { let only = format!( - "{SAVED_JUMP_GRAB}local ok, err = pcall(tools.call, 'grab', {{ value = 'hi' }})\n\ + "{SAVED_JUMP_GRAB}local ok, err = pcall(tools.call, 'adhoc/grab', {{ value = 'hi' }})\n\ assert(not ok, 'the refusal reaches the call site')\n\ assert(tostring(err):find('{JUMP_REFUSAL}', 1, true), tostring(err))\n\ return 'no jump'" @@ -250,15 +252,15 @@ fn a_caller_that_catches_the_jump_refusal_continues_the_block() { #[test] fn jump_stays_refused_in_an_outer_handler_after_an_inner_one_returns() { let only = format!( - "tools.offer_local('inner', 'Inner', {{}}, function() return 'inner' end) - tools.offer_local('outer', 'Outer', {{}}, function() - local inner = tools.call('inner', {{}}) + "tools.offer(tools.adhoc({{ name = 'inner', description = 'Inner' }}, function() return 'inner' end)) + tools.offer(tools.adhoc({{ name = 'outer', description = 'Outer' }}, function() + local inner = tools.call('adhoc/inner', {{}}) local ok, err = pcall(jump, '## Other') assert(not ok, 'jump is still refused in the outer handler') assert(tostring(err):find('{JUMP_REFUSAL}', 1, true), tostring(err)) return inner - end) - assert(tools.call('outer', {{}}) == 'inner', 'the outer handler returns') + end)) + assert(tools.call('adhoc/outer', {{}}) == 'inner', 'the outer handler returns') jump('## Other')" ); let trace = loop_scenario(prompt("", &[("Only", &only), OTHER])).run(); @@ -271,9 +273,9 @@ fn jump_works_in_the_same_block_after_the_loop_returns() { calls(&[("call_1", "grab", "{\"value\":\"hi\"}")]), text("final answer"), ]); - let only = "tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + let only = "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) return 'got ' .. args.value - end) + end)) messages:user('Use the tool.') models.loop() jump('## Other')"; @@ -310,7 +312,37 @@ fn a_handler_returning_a_table_raises_and_is_observed_as_a_failure() { } #[test] -fn a_local_tool_may_take_an_offered_wire_name_and_wins_in_scope() { +fn a_model_call_to_an_offered_catalog_name_never_reaches_an_unoffered_adhoc_tool_of_that_name() { + let gateway = ScriptedChat::new(vec![ + calls(&[("call_1", "tools_concrete", "{}")]), + text("final answer"), + ]); + let (tool, called) = scoped_tool("concrete", "Concrete description."); + let source = one_section( + "tools.adhoc({ name = 'tools_concrete', description = 'Local concrete' }, function() return 'local' end) + tools.offer('tools/concrete') + messages:user('Use the tool.') + models.loop() + return messages[#messages].content", + ); + let trace = loop_scenario(source) + .offer("tools_concrete", tool) + .chat(&gateway) + .run(); + assert_eq!(trace.text(), "final answer"); + let bodies = gateway.requests(); + assert_eq!(bodies[0].tools.len(), 1); + assert_eq!(bodies[0].tools[0].description(), "Concrete description."); + assert_eq!( + called.load(Ordering::SeqCst), + 1, + "the catalog tool answers the model's call" + ); + assert_ne!(last_tool_turn_content(&bodies), "local"); +} + +#[test] +fn an_adhoc_tool_may_take_an_offered_wire_name_and_wins_in_scope() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "tools_concrete", "{}")]), text("final answer"), @@ -318,7 +350,7 @@ fn a_local_tool_may_take_an_offered_wire_name_and_wins_in_scope() { let (tool, called) = scoped_tool("concrete", "Concrete description."); let source = one_section( "tools.offer('tools/concrete') - tools.offer_local('tools_concrete', 'Local concrete', {}, function() return 'local' end) + tools.offer(tools.adhoc({ name = 'tools_concrete', description = 'Local concrete' }, function() return 'local' end)) messages:user('Use the tool.') models.loop() return messages[#messages].content", @@ -344,15 +376,20 @@ fn a_local_tool_may_take_an_offered_wire_name_and_wins_in_scope() { } #[test] -fn local_tool_alias_cannot_be_registered_twice() { +fn an_adhoc_tool_name_cannot_be_defined_twice() { let source = one_section( - "tools.offer_local('grab', 'First grab', {}, function() return 'first' end) - tools.offer_local('grab', 'Second grab', {}, function() return 'second' end)", + "tools.offer(tools.adhoc({ name = 'grab', description = 'First grab' }, function() return 'first' end)) + tools.offer(tools.adhoc({ name = 'grab', description = 'Second grab' }, function() return 'second' end))", ); let trace = Scenario::new(source).run(); let error = trace.error(); assert!( - error.to_string().contains("is already registered"), - "the error must identify the duplicate local alias: {error}" + error + .to_string() + .contains("tools.adhoc name \"grab\" is already registered"), + "the error must identify the duplicate adhoc name: {error}" ); } + +#[path = "adhoc_tools-wrappers.rs"] +mod wrappers; diff --git a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs index 728cabf26..3879489a4 100644 --- a/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs +++ b/crates/runvil-internal/engine/src/execute/tests/batch_turn.rs @@ -1,9 +1,9 @@ //! Tests for the turn a model-issued tool result reports: every result in //! a batch carries the turn of the round that requested the batch, even -//! when an earlier local handler in that batch runs model rounds of its +//! when an earlier adhoc handler in that batch runs model rounds of its //! own - through `models.infer` or a `call` into a section that runs one - //! and advances the chain's turn counter before the later calls dispatch. -//! Bound tools and local tools both report the requesting round's turn. +//! Bound tools and adhoc tools both report the requesting round's turn. //! A handler or a compactor cannot start its own loop while a loop runs in //! the section; once no loop runs, the next one starts normally. @@ -94,15 +94,15 @@ impl Turns { } } -/// The block every test here runs: registers `grab` with `handler` as its +/// The block every test here runs: defines and offers `grab` with `handler` as its /// body, then one loop over a single user message, returning the final /// record's text. `setup` runs first. fn grab_block(setup: &str, handler: &str) -> String { format!( "{setup}\ - tools.offer_local('grab', 'Grab a value', {{ value = 'string' }}, function(args)\n\ + tools.offer(tools.adhoc({{ name = 'grab', description = 'Grab a value', parameters = {{ value = 'string' }} }}, function(args)\n\ {handler}\n\ - end)\n\ + end))\n\ messages:user('Use the tools.')\n\ models.loop()\n\ return messages[#messages].content" @@ -179,11 +179,11 @@ fn a_handlers_loop_during_a_loop_raises_loop_running_as_a_lua_error() { ]); let source = one_section(&with_run_loop( "local refusal\n\ - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function()\n\ + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function()\n\ local ok, err = pcall(models.loop)\n\ refusal = tostring(ok) .. '|' .. err.kind .. '|' .. tostring(err)\n\ return 'refused'\n\ - end)\n\ + end))\n\ local final = run_loop('Use the tools.')\n\ return refusal .. '|' .. final", )); @@ -250,10 +250,10 @@ fn a_loop_after_an_earlier_loop_raised_under_pcall_runs_normally() { fn a_handlers_loop_runs_normally_when_tools_call_reaches_it_outside_any_loop() { let gateway = ScriptedChat::new(vec![text("inner reply")]); let source = one_section(&with_run_loop( - "tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args)\n\ + "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args)\n\ return run_loop(args.value)\n\ - end)\n\ - return tools.call('grab', { value = 'inner' })", + end))\n\ + return tools.call('adhoc/grab', { value = 'inner' })", )); let trace = loop_scenario(source).chat(&gateway).run(); assert_eq!(trace.text(), "inner reply"); diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs index 3cb9d0ca5..d5ee82d22 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_arm.rs @@ -143,7 +143,7 @@ fn a_tool_round_reports_the_batch_before_the_shim_dispatches_it() { } #[test] -fn a_batch_names_the_tool_behind_a_bound_alias_and_none_behind_a_local_one() { +fn a_batch_names_the_tool_behind_each_wire_name_an_adhoc_one_included() { let gateway = ScriptedChat::new(vec![ calls(&[ ("call_1", "grab", "{\"value\":\"x\"}"), @@ -153,9 +153,9 @@ fn a_batch_names_the_tool_behind_a_bound_alias_and_none_behind_a_local_one() { ]); let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) return 'grabbed ' .. args.value - end) + end)) messages:user('use both tools') models.loop() return 'ok'", @@ -174,9 +174,10 @@ fn a_batch_names_the_tool_behind_a_bound_alias_and_none_behind_a_local_one() { && calls .iter() .map(|call| call.tool.as_ref().map(ToString::to_string)) - .eq([None, Some("tools/echo".to_owned())]) + .eq([Some("adhoc/grab".to_owned()), Some("tools/echo".to_owned())]) + && calls.iter().all(|call| call.vendor.is_none()) )), - "the bound alias names its tool and the local one names none: {events:?}" + "each wire name names its tool, and neither call names a vendor: {events:?}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/chat_scope.rs b/crates/runvil-internal/engine/src/execute/tests/chat_scope.rs index d35c72fe8..4fb84b95d 100644 --- a/crates/runvil-internal/engine/src/execute/tests/chat_scope.rs +++ b/crates/runvil-internal/engine/src/execute/tests/chat_scope.rs @@ -1,6 +1,6 @@ //! Tests for the `Chat` arm's tool scope from a section VM: every round //! sends the section's rendered tool list, its effective scope plus every -//! local Lua tool at the first round. +//! adhoc tool at the first round. use runvil_parser::test_support::one_section; @@ -9,20 +9,20 @@ use super::models_loop::loop_scenario; use super::*; #[test] -fn a_round_advertises_the_section_scope_with_local_tools() { +fn a_round_advertises_the_section_scope_with_adhoc_tools() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "grab", "{\"value\":\"x\"}")]), text("done"), ]); let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) return 'grabbed ' .. args.value - end) - messages:user('use the local tool') + end)) + messages:user('use the adhoc tool') models.loop() - assert(messages[2].tool_calls[1].name == 'grab', 'a local tool is in the advertised scope') - assert(messages[3].role == 'tool', 'the shim dispatched the local tool') + assert(messages[2].tool_calls[1].name == 'grab', 'an adhoc tool is in the advertised scope') + assert(messages[3].role == 'tool', 'the shim dispatched the adhoc tool') return 'ok'", ); let trace = loop_scenario(source) @@ -34,6 +34,6 @@ fn a_round_advertises_the_section_scope_with_local_tools() { assert_eq!( body.tool_names(), ["echo", "grab"], - "the bound scope then the local tools: {body:?}" + "the bound scope then the adhoc tools: {body:?}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/compaction.rs b/crates/runvil-internal/engine/src/execute/tests/compaction.rs index d99eb0679..7fb0866fe 100644 --- a/crates/runvil-internal/engine/src/execute/tests/compaction.rs +++ b/crates/runvil-internal/engine/src/execute/tests/compaction.rs @@ -244,9 +244,9 @@ fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { end, 'restore') local kept = #messages models.compact(compactors.clear, 'start over') - local offered, refusal = pcall(tools.offer_local, 'grab', 'Local grab', {}, function() + local offered, refusal = pcall(tools.offer, tools.adhoc({ name = 'grab', description = 'Local grab' }, function() return '' - end) + end)) return table.concat({ tostring(restored), kept, #messages, tostring(offered), tostring(refusal), }, '|')", @@ -257,7 +257,7 @@ fn a_compaction_a_restore_and_a_clear_leave_the_rendered_tool_list_attached() { .run(); assert_eq!( trace.text(), - format!("false|2|0|false|tools.offer_local: {FIXED}") + format!("false|2|0|false|tools.offer: {FIXED}") ); let bodies = gateway.requests(); assert_eq!(bodies.len(), 2, "the overflowed round and its retry"); diff --git a/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs b/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs index a6d2107ba..721f496d3 100644 --- a/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs +++ b/crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs @@ -191,7 +191,7 @@ fn tool_calls_count_increments_even_when_tool_errors() { } #[test] -fn a_model_call_and_a_script_call_count_under_the_id_and_a_local_tool_under_its_alias() { +fn a_model_call_a_script_call_and_an_adhoc_tool_each_count_under_the_id() { let gateway = ScriptedChat::new(vec![ calls(&[ ("call_1", "echo", r#"{"value":"model"}"#), @@ -201,12 +201,12 @@ fn a_model_call_and_a_script_call_count_under_the_id_and_a_local_tool_under_its_ ]); let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) return 'got ' .. args.value end) + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) return 'got ' .. args.value end)) messages:user('ask the model') models.loop() tools.call('tools/echo', { value = 'script' }) - local ok = pcall(function() return tools.calls.echo end) - return tostring(tools.calls['tools/echo']) .. '|' .. tostring(tools.calls.grab) .. '|' .. tostring(ok)", + local ok = pcall(function() return tools.calls['echo'] end) + return tostring(tools.calls['tools/echo']) .. '|' .. tostring(tools.calls['adhoc/grab']) .. '|' .. tostring(ok)", ); let trace = loop_scenario(source) .offer("echo", echo_tool()) @@ -216,10 +216,32 @@ fn a_model_call_and_a_script_call_count_under_the_id_and_a_local_tool_under_its_ trace.text(), "2|1|false", "the model's wire-name call and the script's id call count once each under \ - the id, the local tool under its alias, and the wire name is no key" + the id, the adhoc tool under its own id, and the wire name is no key" ); } +#[test] +fn an_adhoc_tool_defined_after_the_render_has_no_seeded_count() { + let gateway = ScriptedChat::new(vec![text("rendered")]); + let source = one_section( + "local grab = tools.adhoc({ name = 'grab', description = 'Grab' }, function() return 'got' end) + tools.offer(tools.packs.get('adhoc')) + messages:user('ask the model') + models.loop() + tools.adhoc({ name = 'late', description = 'Late' }, function() return 'late' end) + tools.call(grab, {}) + local ok, err = pcall(function() return tools.calls['adhoc/late'] end) + assert(not ok, 'a tool the rendered list lacks reads no count') + local text = tostring(err) + assert(text:find('has no seeded count', 1, true), text) + assert(text:find('(an adhoc tool that was neither offered in this section nor called with tools.call)', 1, true), text) + assert(tools.calls['adhoc/grab'] == 1, 'grab counts once') + return 'ok'", + ); + let trace = loop_scenario(source).chat(&gateway).run(); + assert_eq!(trace.text(), "ok"); +} + #[test] fn tool_calls_count_zero_for_uncalled_alias_fails_epilog_assert() { // The first script dispatch installs the counts seeded from the diff --git a/crates/runvil-internal/engine/src/execute/tests/event_vendors.rs b/crates/runvil-internal/engine/src/execute/tests/event_vendors.rs new file mode 100644 index 000000000..f947bcb5c --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/tests/event_vendors.rs @@ -0,0 +1,147 @@ +//! Tests for the vendor a tool-call or tool-result event records: the +//! vendor the caller reported for the answering Pack, for a model call and +//! a script call to a catalog tool, and none for an adhoc call or a run +//! given no roster. + +use runvil_parser::test_support::one_section; +use runvil_types::event::Event; +use runvil_types::packs::PackId; + +use super::fixtures::echo_tool; +use super::models_loop::catalog_scenario; +use super::*; + +/// A section that offers `tools/echo` and an adhoc `grab`, lets the model +/// call both in one batch, then calls `tools/echo` from the script. +const BOTH_CALLERS: &str = "tools.offer('tools/echo') + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) + return 'grabbed ' .. args.value + end)) + messages:user('use both tools') + models.loop() + return tools.call('tools/echo', { value = 'script' })"; + +/// The run of [`BOTH_CALLERS`] with `tools/echo` in the catalog, its +/// context edited by `roster`. +fn both_callers(roster: impl FnOnce(RunContext) -> RunContext + 'static) -> Trace { + let gateway = ScriptedChat::new(vec![ + calls(&[ + ("call_1", "grab", r#"{"value":"x"}"#), + ("call_2", "tools_echo", r#"{"value":"y"}"#), + ]), + text("done"), + ]); + let trace = catalog_scenario(one_section(BOTH_CALLERS)) + .tool(echo_tool()) + .context(roster) + .chat(&gateway) + .run(); + assert_eq!(trace.text(), "echoed: script"); + trace +} + +/// Each requested call's wire name and vendor, in batch order. +fn batch_vendors(events: &[Event]) -> Vec<(String, Option)> { + events + .iter() + .find_map(|event| match event { + Event::AssistantToolCalls { calls, .. } => Some( + calls + .iter() + .map(|call| (call.name.clone(), call.vendor.clone())) + .collect(), + ), + _ => None, + }) + .unwrap_or_else(|| panic!("the round reports its batch: {events:?}")) +} + +/// Each result's call id, alias, and vendor, sorted by call id. +fn result_vendors(events: &[Event]) -> Vec<(String, String, Option)> { + let mut results: Vec<_> = events + .iter() + .filter_map(|event| match event { + Event::ToolResult { + tool_call_id, + alias, + vendor, + .. + } => Some((tool_call_id.clone(), alias.clone(), vendor.clone())), + _ => None, + }) + .collect(); + results.sort(); + results +} + +fn owned(name: &str, vendor: Option<&str>) -> (String, Option) { + (name.to_owned(), vendor.map(str::to_owned)) +} + +fn result(id: &str, alias: &str, vendor: Option<&str>) -> (String, String, Option) { + (id.to_owned(), alias.to_owned(), vendor.map(str::to_owned)) +} + +#[test] +fn a_catalog_call_records_the_vendor_the_caller_reported_and_an_adhoc_call_none() { + let tools = PackId::parse("tools").expect("a static valid Pack id"); + let trace = both_callers(move |ctx| ctx.packs(vec![UsablePack::new(tools, "acme")], vec![])); + let events = trace.events(); + assert_eq!( + batch_vendors(events), + [owned("grab", None), owned("tools_echo", Some("acme"))], + "the model's catalog call names acme and its adhoc call names none" + ); + assert_eq!( + result_vendors(events), + [ + result("", "tools_echo", Some("acme")), + result("call_1", "grab", None), + result("call_2", "tools_echo", Some("acme")), + ], + "the script's and the model's catalog results name acme, the adhoc result none" + ); +} + +#[test] +fn an_adhoc_calls_events_name_its_id() { + let trace = both_callers(|ctx| ctx); + let events = trace.events(); + let requested = events.iter().find_map(|event| match event { + Event::AssistantToolCalls { calls, .. } => calls + .iter() + .find(|call| call.name == "grab") + .map(|call| call.tool.as_ref().map(ToString::to_string)), + _ => None, + }); + let answered = events.iter().find_map(|event| match event { + Event::ToolResult { + tool_call_id, tool, .. + } if tool_call_id == "call_1" => Some(tool.as_ref().map(ToString::to_string)), + _ => None, + }); + let id = Some(Some("adhoc/grab".to_owned())); + assert_eq!( + requested, id, + "the requested call names the adhoc tool's id" + ); + assert_eq!(answered, id, "its result names the same id"); +} + +#[test] +fn a_run_given_no_roster_records_no_vendor() { + let trace = both_callers(|ctx| ctx); + let events = trace.events(); + assert_eq!( + batch_vendors(events), + [owned("grab", None), owned("tools_echo", None)] + ); + assert_eq!( + result_vendors(events), + [ + result("", "tools_echo", None), + result("call_1", "grab", None), + result("call_2", "tools_echo", None), + ] + ); +} diff --git a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs index 35ef31669..5d0f90744 100644 --- a/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs +++ b/crates/runvil-internal/engine/src/execute/tests/full_id_calls.rs @@ -140,17 +140,17 @@ fn a_script_call_advertises_nothing_until_the_tool_is_offered() { } #[test] -fn a_script_call_by_wire_name_or_local_looking_name_is_unbound() { +fn a_script_call_naming_a_string_without_a_slash_raises_before_any_resolution() { let source = one_section( "local wire_ok, wire_err = pcall(tools.call, 'tools_echo', {})\n\ local bare_ok, bare_err = pcall(tools.call, 'echo', {})\n\ - assert(not wire_ok and not bare_ok, 'neither name is a catalog id')\n\ - return wire_err.kind .. ':' .. wire_err.name .. '|' .. bare_err.kind .. ':' .. bare_err.name", + assert(not wire_ok and not bare_ok, 'neither name is a tool id')\n\ + return wire_err.kind .. ':' .. tostring(wire_err) .. '|' .. tostring(bare_err)", ); let rig = Rig::run(catalog_scenario(source).tool(echo_tool())); assert_eq!( rig.trace().text(), - "unbound_tool:tools_echo|unbound_tool:echo" + "lua:tools.call: \"tools_echo\" is not a tool id|tools.call: \"echo\" is not a tool id" ); assert!(rig.issued().is_empty(), "nothing was issued"); } diff --git a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs index c7ef1aa97..18e2c9525 100644 --- a/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs +++ b/crates/runvil-internal/engine/src/execute/tests/model_and_reply.rs @@ -213,7 +213,7 @@ fn offer_over_an_empty_catalog_fails_the_run_loudly() { assert!( error .to_string() - .contains("tools.offer: \"web/search\" is not a catalog tool in this run"), + .contains("tools.offer: \"web/search\" is not a tool in this run"), "the error must report the missing tool: {error}" ); } @@ -230,7 +230,7 @@ fn offer_after_an_h1_helper_fails_the_run_loudly() { assert!( error .to_string() - .contains("tools.offer: \"web/search\" is not a catalog tool in this run"), + .contains("tools.offer: \"web/search\" is not a tool in this run"), "the error must report the missing tool: {error}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs index 55b712483..6cb0b8c14 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_compact.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_compact.rs @@ -67,10 +67,10 @@ fn models_compact_refuses_while_a_loop_its_handler_or_a_compactor_runs() { local ok, err = pcall(models.compact, compactors.clear, label) seen[#seen + 1] = label .. ':' .. tostring(ok) .. ':' .. err.kind .. ':' .. tostring(err) end - tools.offer_local('probe', 'Probe models.compact', { value = 'string' }, function() + tools.offer(tools.adhoc({ name = 'probe', description = 'Probe models.compact', parameters = { value = 'string' } }, function() refuse('handler') return 'probed' - end) + end)) messages:user('go') models.loop() messages:user(string.rep('x', 100000)) diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs index 0970541dd..9f26115aa 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop.rs @@ -2,7 +2,7 @@ //! loop over the section's `messages` list. One terminal turn with no //! tools, repeated model-tool rounds with automatic assistant and //! tool-result appends, the nil return, explicit terminal removal through -//! `models.compact`, local and bound tools, the tool list the first loop +//! `models.compact`, adhoc and bound tools, the tool list the first loop //! round renders, explicit-handle calls on a //! frozen binding, and the atomic append of a tool-call batch. The shared //! loop fixtures (`loop_models`, `loop_scenario`, `catalog_scenario`, @@ -200,7 +200,7 @@ fn replayed_tool_calls_reach_the_chat_effect_in_the_openai_shape() { } #[test] -fn models_loop_dispatches_local_and_bound_tools() { +fn models_loop_dispatches_adhoc_and_bound_tools() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "grab", "{\"value\":\"x\"}")]), calls(&[("call_2", "echo", "{\"value\":\"y\"}")]), @@ -208,14 +208,14 @@ fn models_loop_dispatches_local_and_bound_tools() { ]); let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) return 'grabbed ' .. args.value - end) + end)) messages:user('use both tools') models.loop() assert(#messages == 6, 'both exchanges and the terminal record appended') - assert(messages[3].content == 'grabbed x', 'the local handler ran on the section VM') - assert(messages[3].tool_call_id == 'call_1', 'the local result correlates its call') + assert(messages[3].content == 'grabbed x', 'the adhoc handler ran on the section VM') + assert(messages[3].tool_call_id == 'call_1', 'the adhoc result correlates its call') assert(messages[5].content == 'echoed: y', 'the bound tool ran through dispatch') return 'ok'", ); @@ -228,7 +228,7 @@ fn models_loop_dispatches_local_and_bound_tools() { assert_eq!( bodies[0].tools.len(), 2, - "both the local and the bound tool are advertised: {bodies:?}" + "both the adhoc and the bound tool are advertised: {bodies:?}" ); } @@ -303,14 +303,14 @@ fn the_author_list_never_shows_a_half_answered_tool_batch() { let source = one_section( "messages:user('grab twice') local seen = {} - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) seen[#seen + 1] = #messages for _, m in ipairs(messages) do assert(m.tool_calls == nil, 'no assistant call record is visible mid-batch') assert(m.role ~= 'tool', 'no tool record is visible mid-batch') end return 'grabbed ' .. args.value - end) + end)) models.loop() assert(#seen == 2, 'both calls in the batch ran') assert(seen[1] == 1 and seen[2] == 1, 'each handler saw only the user message') diff --git a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract-rounds.rs b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract-rounds.rs index 3a6f0a7da..32f082743 100644 --- a/crates/runvil-internal/engine/src/execute/tests/models_loop_contract-rounds.rs +++ b/crates/runvil-internal/engine/src/execute/tests/models_loop_contract-rounds.rs @@ -1,6 +1,6 @@ //! Contract tests for `models.loop`'s rounds: the clean exit's call //! count, the batch rule under a raising handler, a dropped model-issued -//! call, a local tool's return and its cancellation, and the full trace a +//! call, an adhoc tool's return and its cancellation, and the full trace a //! run reports for the loop. use runvil_parser::test_support::{PromptSource, one_section}; @@ -168,11 +168,11 @@ fn a_handler_raising_mid_batch_leaves_the_list_untouched_and_raises_its_own_tabl let source = one_section( "local own = { reason = 'mine' } local count = 0 - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) count = count + 1 if count == 2 then error(own) end return 'grabbed ' .. args.value - end) + end)) messages:user('grab twice') local ok, err = pcall(models.loop) return tostring(ok) .. '|' .. tostring(err == own) .. '|' .. count \ @@ -227,7 +227,7 @@ fn a_dropped_model_issued_call_raises_cancelled_at_the_loop_call_site() { } #[test] -fn a_local_tools_first_return_value_is_its_text_and_nil_is_empty() { +fn an_adhoc_tools_first_return_value_is_its_text_and_nil_is_empty() { let gateway = ScriptedChat::new(vec![ calls(&[ ("c1", "none", "{}"), @@ -237,9 +237,9 @@ fn a_local_tools_first_return_value_is_its_text_and_nil_is_empty() { text("done"), ]); let source = one_section( - "tools.offer_local('none', 'Returns nothing', {}, function() end) - tools.offer_local('num', 'Returns a number', {}, function() return 42 end) - tools.offer_local('two', 'Returns two values', {}, function() return 'first', 'second' end) + "tools.offer(tools.adhoc({ name = 'none', description = 'Returns nothing' }, function() end)) + tools.offer(tools.adhoc({ name = 'num', description = 'Returns a number' }, function() return 42 end)) + tools.offer(tools.adhoc({ name = 'two', description = 'Returns two values' }, function() return 'first', 'second' end)) messages:user('call all three') models.loop() return '[' .. messages[3].content .. ']|' .. messages[4].content .. '|' .. messages[5].content", @@ -249,7 +249,7 @@ fn a_local_tools_first_return_value_is_its_text_and_nil_is_empty() { } #[test] -fn a_local_tool_cancelled_mid_handler_reports_no_failed_tool_call() { +fn an_adhoc_tool_cancelled_mid_handler_reports_no_failed_tool_call() { // The cancel lands between steps once the first round is answered, so // the step that hands the model's call to the looping handler runs // with the flag already set. @@ -258,9 +258,9 @@ fn a_local_tool_cancelled_mid_handler_reports_no_failed_tool_call() { text("unreachable"), ]); let source = one_section( - "tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) while true do end - end) + end)) messages:user('Use the tool.') models.loop() return 'unreachable'", @@ -284,7 +284,7 @@ fn a_local_tool_cancelled_mid_handler_reports_no_failed_tool_call() { } #[test] -fn the_scheduler_reports_the_whole_trace_of_a_bound_round_a_local_round_and_a_reply() { +fn the_scheduler_reports_the_whole_trace_of_a_bound_round_an_adhoc_round_and_a_reply() { let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "echo", "{\"value\":\"a\"}")]), calls(&[("call_2", "grab", "{\"value\":\"b\"}")]), @@ -293,9 +293,9 @@ fn the_scheduler_reports_the_whole_trace_of_a_bound_round_a_local_round_and_a_re let source = PromptSource::new().title("Loop").section( "Only", "tools.offer('tools/echo') - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) return 'grabbed ' .. args.value - end) + end)) messages:user('use both tools') models.loop() return messages[#messages].content", diff --git a/crates/runvil-internal/engine/src/execute/tests/offering-packs.rs b/crates/runvil-internal/engine/src/execute/tests/offering-packs.rs index c4305726a..360a0fd20 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering-packs.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering-packs.rs @@ -1,4 +1,4 @@ -//! Tests for `packs.offer` across section entries: an offered Pack's +//! Tests for a Pack offered by id across section entries: an offered Pack's //! tools reach the rounds of the section entry that offered it, through //! `require` too, and no other entry's. @@ -26,12 +26,12 @@ fn an_offered_pack_reaches_only_the_rounds_of_the_section_entry_that_offered_it( let source = format!( "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # Packs\n\n\ - ```lua\npacks.offer('kit')\n{h1}```\n\n\ + ```lua\ntools.offer('kit')\n{h1}```\n\n\ ## A\n\n\ ```lua\n\ tools.offer('kit/a')\n\ require '## offers'\n\ - packs.offer('kit')\n\ + tools.offer('kit')\n\ {a}\ call('## Called')\n\ tasks.join({{ tasks.spawn('## Worker') }})\n\ @@ -39,7 +39,7 @@ fn an_offered_pack_reaches_only_the_rounds_of_the_section_entry_that_offered_it( ## B\n\n\ ```lua\n{b}return 'ok'\n```\n\n\ ## offers\n\n\ - ```lua\npacks.offer('web')\n```\n\n\ + ```lua\ntools.offer('web')\n```\n\n\ ## Called\n\n\ ```lua\n{called}return 'called'\n```\n\n\ ## Worker\n\n\ diff --git a/crates/runvil-internal/engine/src/execute/tests/offering.rs b/crates/runvil-internal/engine/src/execute/tests/offering.rs index dd0b4e7f7..d2ccc9efc 100644 --- a/crates/runvil-internal/engine/src/execute/tests/offering.rs +++ b/crates/runvil-internal/engine/src/execute/tests/offering.rs @@ -1,6 +1,6 @@ //! Tests for the offering: every catalog tool the run can offer, bound -//! under its wire name, which `tools.offer` scopes by id or tool object, a -//! model call resolves by wire name, and a script call resolves by id. +//! under its wire name, which `tools.offer` scopes by id, Pack, or object, +//! a model call resolves by wire name, and a script call resolves by id. use runvil_parser::test_support::{PromptSource, one_section, prompt}; @@ -11,7 +11,6 @@ use crate::execute::run::{EffectRecord, ToolCallOrigin, ToolCaller}; use crate::execute::scope::prepare_scoped_tools; use crate::test_support::rig::Rig; use crate::test_support::scenario::EXECUTION; -use runvil_model_client::detail::tool_schema_new; use runvil_types::packs::PackId; /// A catalog tool under any id, described by its id, echoing its `value` @@ -141,7 +140,7 @@ fn a_script_offers_the_extras_and_the_model_calls_an_offered_tool_by_its_wire_na text("done"), ]); let source = one_section( - "tools.offer(tools.extras())\n\ + "tools.offer(tools.packs.extras())\n\ messages:user('go')\n\ models.loop()\n\ return 'ok'", @@ -173,7 +172,8 @@ fn a_script_offers_the_extras_and_the_model_calls_an_offered_tool_by_its_wire_na } /// Every key on a section's `tools` table, sorted, each with its Lua type. -const TOOLS_TABLE_KEYS: &str = "call:function,extras:function,get:function,offer:function,offer_local:function,required:function"; +const TOOLS_TABLE_KEYS: &str = + "adhoc:function,call:function,get:function,offer:function,offer_dynamic:function,packs:table"; #[test] fn a_section_tools_table_holds_only_its_api_and_its_round_advertises_only_the_offered_tool() { @@ -182,7 +182,7 @@ fn a_section_tools_table_holds_only_its_api_and_its_round_advertises_only_the_of "local keys = {}\n\ for key, value in pairs(tools) do keys[#keys + 1] = key .. ':' .. type(value) end\n\ table.sort(keys)\n\ - tools.offer(tools.extras())\n\ + tools.offer(tools.packs.extras())\n\ messages:user('go')\n\ models.loop()\n\ return table.concat(keys, ',')", @@ -211,7 +211,7 @@ fn a_section_tools_table_holds_only_its_api_and_its_round_advertises_only_the_of #[test] fn a_script_calls_an_offered_tool_by_its_object_and_by_its_id_without_offering_it() { let source = one_section( - "local tool = tools.extras()[1]\n\ + "local tool = tools.packs.get('tools').tools[1]\n\ return tools.call(tool, { value = 'a' }) .. '|' .. \ tools.call('tools/echo', { value = 'b' }) .. '|' .. tool.id", ); @@ -264,7 +264,7 @@ fn a_script_offers_a_declared_packs_tools_and_the_model_calls_one_by_its_wire_na "uses: [tools]", &[( "Only", - "tools.offer(packs.get('tools').tools)\n\ + "tools.offer(tools.packs.get('tools').tools)\n\ messages:user('go')\n\ models.loop()\n\ return 'ok'", @@ -293,8 +293,8 @@ fn a_script_offers_an_extra_packs_tools_after_checking_packs_get() { text("done"), ]); let source = one_section( - "assert(packs.get('missing') == nil, 'an unknown Pack reads nil')\n\ - local gh = packs.get('gh')\n\ + "assert(tools.packs.get('missing') == nil, 'an unknown Pack reads nil')\n\ + local gh = tools.packs.get('gh')\n\ if gh then tools.offer(gh.tools) end\n\ messages:user('go')\n\ models.loop()\n\ @@ -317,12 +317,14 @@ fn a_script_offers_an_extra_packs_tools_after_checking_packs_get() { } #[test] -fn the_packs_table_is_installed_in_the_h1_vm() { +fn the_pack_objects_and_the_adhoc_pack_are_installed_in_the_h1_vm() { let source = PromptSource::new() .front("uses: [tools]") .h1( - "return packs.required()[1].name .. '|' .. packs.extras()[1].name .. '|' .. \ - tostring(tools.get('gh/search').pack == packs.get('gh'))", + "return tostring(tools.packs.get('tools').required) .. '|' .. \ + tools.packs.extras()[1].name .. '|' .. \ + tostring(tools.get('gh/search').pack == tools.packs.get('gh')) .. '|' .. \ + #tools.packs.get('adhoc').tools", ) .build(); let rig = Rig::run( @@ -332,28 +334,40 @@ fn the_packs_table_is_installed_in_the_h1_vm() { ); assert_eq!( rig.trace().text(), - "tools|gh|true", - "the H1 pass reads the packs table" + "true|gh|true|0", + "the H1 pass reads the Pack objects, the empty adhoc Pack included" ); } #[test] -fn a_round_leaves_out_an_offered_binding_a_local_tool_shares_a_name_with() { - let offered = crate::lua::ToolBinding::from_info("tools_echo", &echo_tool().info()); - let local = tool_schema_new("tools_echo", "the local echo", json!({ "type": "object" })) - .expect("the local schema is valid"); - let rendered = prepare_scoped_tools(&[offered], &[local]).expect("the round's scope builds"); - assert_eq!( - rendered.schemas.len(), - 1, - "the model sees one tool under the name" +fn a_rendered_list_maps_every_wire_name_to_its_id_and_an_adhoc_tool_to_no_vendor() { + let offered = crate::lua::ToolBinding::from_info("tools_echo", &echo_tool().info()) + .with_vendor(Some("runvil".to_owned())); + let info = runvil_types::tools::ToolInfo::new( + ToolId::parse("adhoc/grab").expect("valid id"), + "Grab a value", + json!({ "type": "object", "properties": {}, "required": [] }), ); - assert_eq!(rendered.schemas[0].description(), "the local echo"); - assert!( - matches!(rendered.dispatch.get("tools_echo"), Some(None)), - "the local tool answers the name: {:?}", - rendered.dispatch + let adhoc = crate::lua::ToolBinding::from_info("grab", &info); + let rendered = prepare_scoped_tools(&[offered, adhoc]).expect("the round's scope builds"); + let names: Vec<&str> = rendered + .schemas + .iter() + .map(crate::model::ToolSchema::name) + .collect(); + assert_eq!(names, ["tools_echo", "grab"], "the bindings' order"); + let entry = |name: &str| { + rendered + .dispatch + .get(name) + .map(|advertised| (advertised.id.to_string(), advertised.vendor.clone())) + }; + assert_eq!( + entry("tools_echo"), + Some(("tools/echo".to_owned(), Some("runvil".to_owned()))) ); + assert_eq!(entry("grab"), Some(("adhoc/grab".to_owned(), None))); + assert!(rendered.dynamic.is_empty()); } #[path = "offering-packs.rs"] diff --git a/crates/runvil-internal/engine/src/execute/tests/parallel_tool_calls.rs b/crates/runvil-internal/engine/src/execute/tests/parallel_tool_calls.rs index 5406f7395..8cc6b902c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/parallel_tool_calls.rs +++ b/crates/runvil-internal/engine/src/execute/tests/parallel_tool_calls.rs @@ -1,7 +1,7 @@ //! A round's catalog tool calls run in parallel: the loop sends every //! unanswered call as one batch, the scheduler issues each catalog call in //! the same step and answers once every issued call has landed, and the -//! records append in call order. A local tool stops the batch and runs +//! records append in call order. An adhoc tool stops the batch and runs //! alone between its neighbors, a call that cannot be //! bound stops it and raises once the issued calls land, and a task //! cancelled while parked on a batch orphans every call in it and lists @@ -145,12 +145,12 @@ fn a_rounds_catalog_calls_are_issued_in_one_step_and_append_in_call_order() { } #[test] -fn local_calls_run_alone_between_their_neighbors() { +fn adhoc_calls_run_alone_between_their_neighbors() { let md = one_section( "tools.offer('tools/echo')\n\ - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args)\n\ + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args)\n\ return 'grabbed ' .. args.value\n\ - end)\n\ + end))\n\ messages:user('use them all')\n\ models.loop()\n\ local ids = {}\n\ @@ -172,7 +172,7 @@ fn local_calls_run_alone_between_their_neighbors() { assert_eq!( call_value(&effect), "a", - "the batch stops before the local call" + "the batch stops before the adhoc call" ); run.resume(first, tool_text("echoed a")); let (last, effect) = only_effect(run.step()); @@ -199,7 +199,7 @@ fn local_calls_run_alone_between_their_neighbors() { #[test] fn a_call_the_round_cannot_bind_stops_the_batch_and_raises_once_the_issued_calls_land() { let md = one_section( - "packs.offer('tools')\n\ + "tools.offer('tools')\n\ messages:user('asked')\n\ local ok, err = pcall(models.loop)\n\ return tostring(ok) .. ':' .. err.kind .. '|' .. #messages", diff --git a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs index 2906a4311..ecfbbd19c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/rendered_tools.rs @@ -47,9 +47,9 @@ fn a_first_round_the_precheck_refuses_still_fixes_the_tool_list() { messages:user(string.rep('x', 20000)) local ok, err = pcall(models.loop) assert(not ok and err.reason == 'precheck', 'the narrow window refuses the round') - local offered, refusal = pcall(tools.offer_local, 'grab', 'Local grab', {}, function() + local offered, refusal = pcall(tools.offer, tools.adhoc({ name = 'grab', description = 'Local grab' }, function() return '' - end) + end)) assert(not offered, 'the refused round fixed the list') models.use('wide') models.loop() @@ -60,7 +60,7 @@ fn a_first_round_the_precheck_refuses_still_fixes_the_tool_list() { .offer("echo", echo_tool()) .chat(&gateway) .run(); - assert_eq!(trace.text(), format!("lua:tools.offer_local: {FIXED}")); + assert_eq!(trace.text(), format!("lua:tools.offer: {FIXED}")); let bodies = gateway.requests(); assert_eq!(bodies.len(), 1, "only the round on the wide model left"); assert_eq!( @@ -80,9 +80,9 @@ fn every_later_round_sends_the_same_tool_bytes_across_a_model_change() { ]); let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Local grab', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab', parameters = { value = 'string' } }, function(args) return 'grabbed ' .. args.value - end) + end)) messages:user('one') models.loop() messages:user('two') @@ -109,7 +109,7 @@ fn every_later_round_sends_the_same_tool_bytes_across_a_model_change() { } #[test] -fn after_the_render_every_offer_function_raises_even_an_identical_one() { +fn after_the_render_every_offer_raises_even_an_identical_one_while_defining_still_works() { let gateway = ScriptedChat::new(vec![calls(&[("call_1", "grab", "{}")]), text("done")]); let source = prompt( "uses: [tools]", @@ -117,23 +117,25 @@ fn after_the_render_every_offer_function_raises_even_an_identical_one() { ( "Only", "tools.offer('tools/echo') - packs.offer('tools') - tools.offer_local('grab', 'Local grab', {}, function() + tools.offer('tools') + tools.offer(tools.adhoc({ name = 'grab', description = 'Local grab' }, function() local _, err = pcall(tools.offer, 'tools/echo') return err.kind .. ':' .. tostring(err) - end) + end)) messages:user('go') models.loop() local out = { messages[3].content } for _, offer in ipairs({ function() tools.offer('tools/echo') end, - function() packs.offer('tools') end, - function() tools.offer_local('grab', 'Again', {}, function() return '' end) end, + function() tools.offer('tools') end, + function() tools.offer(tools.adhoc({ name = 'late', description = 'Late' }, function() return '' end)) end, + function() tools.offer_dynamic('tools') end, function() require '## offers' end, }) do local _, err = pcall(offer) out[#out + 1] = err.kind .. ':' .. tostring(err) end + out[#out + 1] = tostring(tools.get('adhoc/late') ~= nil) return table.concat(out, '\\n')", ), ("offers", "tools.offer('tools/echo')"), @@ -146,27 +148,56 @@ fn after_the_render_every_offer_function_raises_even_an_identical_one() { let lines: Vec<&str> = trace.text().lines().collect(); let offer = format!("lua:tools.offer: {FIXED}"); assert_eq!( - lines[..4], + lines[..5], [ offer.clone(), offer.clone(), - format!("lua:packs.offer: {FIXED}"), - format!("lua:tools.offer_local: {FIXED}"), + offer.clone(), + offer.clone(), + format!("lua:tools.offer_dynamic: {FIXED}"), ], "a handler's offer during the loop, then an identical re-offer of a tool \ - and of a Pack, and a repeated local alias" + and of a Pack, an offer of a tool defined after the render, and a \ + dynamic offer" ); - assert_eq!(lines.len(), 5, "{lines:?}"); + assert_eq!(lines.len(), 7, "{lines:?}"); assert!( - lines[4].contains(&format!("tools.offer: {FIXED}")), + lines[5].contains(&format!("tools.offer: {FIXED}")), "an offer from a required library raises: {}", - lines[4] + lines[5] ); + assert_eq!(lines[6], "true", "the tool defined after the render exists"); let bodies = gateway.requests(); assert_eq!(bodies[0].tool_names(), ["tools_echo", "grab"]); assert_eq!(tool_bytes(&bodies)[0], tool_bytes(&bodies)[1]); } +#[test] +fn dynamic_offers_leave_the_rendered_schemas_byte_for_byte_unchanged() { + let run = |dynamic: &str| { + let gateway = ScriptedChat::new(vec![text("done")]); + let source = one_section(&format!( + "{dynamic}tools.offer('tools/echo') + tools.offer(tools.adhoc({{ name = 'grab', description = 'Local grab' }}, function() return '' end)) + messages:user('go') + models.loop()" + )); + loop_scenario(source) + .offer("echo", echo_tool()) + .offer("shape", scalar_schema_tool()) + .chat(&gateway) + .run(); + tool_bytes(&gateway.requests()) + }; + let plain = run(""); + assert_eq!( + run("tools.offer_dynamic('tools')\ntools.offer_dynamic(tools.packs)\n"), + plain, + "a dynamic offer, even of a tool no round could advertise, changes nothing \ + the model sees" + ); +} + #[test] fn a_first_round_refused_over_an_empty_list_renders_nothing() { let gateway = ScriptedChat::new(vec![text("done")]); @@ -241,7 +272,7 @@ fn a_first_round_whose_tool_build_fails_renders_nothing() { local ok, err = pcall(models.loop) assert(not ok, 'the scalar schema fails the build') tools.offer('tools/echo') - tools.offer_local('shape', 'Local shape', {}, function() return '' end) + tools.offer(tools.adhoc({ name = 'shape', description = 'Local shape' }, function() return '' end)) models.loop() return err.kind .. ':' .. tostring(err)", ); @@ -259,7 +290,7 @@ fn a_first_round_whose_tool_build_fails_renders_nothing() { assert_eq!( bodies[0].tool_names(), ["echo", "shape"], - "both offers after the failed build reach the next round, the local \ + "both offers after the failed build reach the next round, the adhoc \ tool in place of the unusable one" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs index ab95690a4..9381d977e 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/failures-script-tools.rs @@ -50,11 +50,11 @@ fn a_script_tools_call_with_an_unbound_name_lists_the_offered_ids() { let md = "---\nname: t\ndescription: d\nrunvil: 0\n---\n\n\ # ToolCall\n\n\ ## Only\n\n\ - ```lua\ntools.offer('tools/echo')\nreturn tools.call('missing', {})\n```\n"; + ```lua\ntools.offer('tools/echo')\nreturn tools.call('tools/missing', {})\n```\n"; let trace = scheduler_scenario(md).offer("echo", echo_tool()).run(); match trace.error().inner() { Error::UnboundToolCall { name, ids } => { - assert_eq!(name, "missing"); + assert_eq!(name, "tools/missing"); assert_eq!(ids, &["tools/echo".to_owned()]); } other => panic!("expected the typed unbound-tool error, got {other:?}"), diff --git a/crates/runvil-internal/engine/src/execute/tests/scheduler/live_h1/control_flow.rs b/crates/runvil-internal/engine/src/execute/tests/scheduler/live_h1/control_flow.rs index 2684d89d7..3ab84e97c 100644 --- a/crates/runvil-internal/engine/src/execute/tests/scheduler/live_h1/control_flow.rs +++ b/crates/runvil-internal/engine/src/execute/tests/scheduler/live_h1/control_flow.rs @@ -115,11 +115,11 @@ fn fanout_from_h1_runs_the_worker_over_the_collection() { #[test] fn the_h1_decision_tool_idiom_runs_before_the_walk() { - // The decision-tool idiom in H1: a local tool with an enum parameter is + // The decision-tool idiom in H1: an adhoc tool with an enum parameter is // the verdict channel - the model's loop call lands in the Lua handler, // and the captured verdict drives the run's shape before the walk. This - // needs `tools.offer_local` and `models.loop` in H1, both section-only - // before the one-install-path consolidation. + // needs `tools.adhoc`, `tools.offer`, and `models.loop` in H1, which + // installs them on the one path every section takes. let gateway = ScriptedChat::new(vec![ calls(&[("call_1", "decide", "{\"choice\":\"use_mcp\"}")]), text("decided"), @@ -128,10 +128,10 @@ fn the_h1_decision_tool_idiom_runs_before_the_walk() { # Decide\n\n\ ```lua\n\ models.default('writer')\n\ - tools.offer_local('decide', 'Record the verdict', { choice = 'string' }, function(args)\n\ + tools.offer(tools.adhoc({ name = 'decide', description = 'Record the verdict', parameters = { choice = 'string' } }, function(args)\n\ var.verdict = args.choice\n\ return 'recorded'\n\ - end)\n\ + end))\n\ messages:user('interpret the guidance')\n\ models.loop()\n\ ```\n\n\ diff --git a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs index 615f53dfb..93d3a2d70 100644 --- a/crates/runvil-internal/engine/src/execute/tests/section_messages.rs +++ b/crates/runvil-internal/engine/src/execute/tests/section_messages.rs @@ -280,15 +280,15 @@ fn replace_and_compactors_clear_work_inside_an_overflow_compactor_and_inside_mod fn meta_reaches_a_tool_through_a_view_but_never_the_model() { let source = PromptSource::new().section( "Only", - "tools.offer_local('probe', 'Read a record', {}, function(args) + "tools.offer(tools.adhoc({ name = 'probe', description = 'Read a record' }, function(args) return args.record.meta.hint - end) + end)) messages:system('be terse', { hint = 'system' }) :user('look', { hint = 'user' }) :assistant('', { { id = 'call_1', name = 'probe' } }, { hint = 'assistant' }) :tool('probed', 'call_1', { hint = 'tool' }) :append({ role = 'user', content = 'go on', meta = { hint = 'append' } }) - local probed = tools.call('probe', { record = messages[5] }) + local probed = tools.call('adhoc/probe', { record = messages[5] }) models.loop() local last = messages[#messages] return table.concat({ probed, #messages, last.content, tostring(last.meta) }, '|')", @@ -319,7 +319,7 @@ fn both_loop_entries_take_only_a_compactor_and_check_it_before_a_running_loop() local ok, err = pcall(call) refusals[#refusals + 1] = tostring(ok) .. ':' .. err.kind .. ':' .. tostring(err) end - tools.offer_local('probe', 'Probe the loop entries', { value = 'string' }, function() + tools.offer(tools.adhoc({ name = 'probe', description = 'Probe the loop entries', parameters = { value = 'string' } }, function() refuse(function() return models.loop(other) end) refuse(function() return models.loop(nil, nil) end) refuse(function() return models.loop(42) end) @@ -329,7 +329,7 @@ fn both_loop_entries_take_only_a_compactor_and_check_it_before_a_running_loop() refuse(function() return other:loop(42) end) refuse(function() return other:loop(compactors.fail) end) return 'probed' - end) + end)) messages:user('probe the entries') models.loop() return table.concat(refusals, '\\n') .. '\\n' .. #messages", diff --git a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs index ef1927c1a..b1d552bf1 100644 --- a/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs +++ b/crates/runvil-internal/engine/src/execute/tests/suite/exec_flow/run_setup.rs @@ -114,7 +114,7 @@ fn offering_a_tool_its_present_pack_does_not_offer_fails_the_section() { assert!( error .to_string() - .contains("tools.offer: \"tools/search\" is not a catalog tool in this run"), + .contains("tools.offer: \"tools/search\" is not a tool in this run"), "the failure names the id: {error}" ); } diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-local-handlers.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs similarity index 52% rename from crates/runvil-internal/engine/src/execute/tests/tool_call_arm-local-handlers.rs rename to crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs index 28eba959e..0f5ba01b9 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-local-handlers.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm-adhoc-handlers.rs @@ -1,26 +1,48 @@ -//! Local Lua tools: a handler runs inside the calling block coroutine +//! Adhoc tools: a handler runs inside the calling block coroutine //! with no leaf work of its own and reports its trusted result under //! the model's call id or none; it uses the store, raises to a script -//! caller that catches it, calls another local tool innermost first, +//! caller that catches it, calls another adhoc tool innermost first, //! and dispatches a bound tool as one leaf request, whose failure //! raises kind `tool` in the handler and whose untrusted wrapper -//! survives. +//! survives. A model-issued call routes by the section's rendered list, +//! so those cases run one loop round first. use runvil_parser::test_support::prompt; use super::*; use crate::execute::tests::fixtures::{echo_tool, failing_tool, untrusted_echo_tool}; -/// The `grab` local tool registration every local-tool test opens with. -const ADD_LOCAL_GRAB: &str = "tools.offer_local('grab', 'Grab a value', { value = 'string' }, \ - function(args) return 'got ' .. args.value end)\n"; +/// The `grab` adhoc tool definition every adhoc-tool test opens with, +/// offered to the section. +const ADD_ADHOC_GRAB: &str = "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a \ + value', parameters = { value = 'string' } }, function(args) return 'got ' .. args.value \ + end))\n"; + +/// One loop round the model answers with text, which renders the +/// section's tool list so a model-issued call can route by it. +const RENDER: &str = "messages:user('go')\nmodels.loop()\n"; + +/// The arm scenario over `source` with one text round for [`RENDER`]. +fn rendered_scenario(source: impl Into) -> (Scenario, ScriptedChat) { + let gateway = ScriptedChat::new(vec![text("rendered")]); + (arm_scenario(source), gateway) +} + +/// How many tool-call effects `trace` issued. +fn tool_call_effects(trace: &Trace) -> usize { + trace + .effects() + .iter() + .filter(|(effect, _)| matches!(effect, EffectRecord::ToolCall { .. })) + .count() +} #[test] -fn a_local_lua_tool_call_issues_no_leaf_work() { +fn an_adhoc_tool_call_issues_no_leaf_work() { let source = one_section(&format!( - "{ADD_LOCAL_GRAB}\ - local out = tools.call('grab', {{ value = 'hi' }})\n\ - return out .. '|' .. tostring(tools.calls.grab)" + "{ADD_ADHOC_GRAB}\ + local out = tools.call('adhoc/grab', {{ value = 'hi' }})\n\ + return out .. '|' .. tostring(tools.calls['adhoc/grab'])" )); let trace = arm_scenario(source).run(); assert_eq!( @@ -31,35 +53,57 @@ fn a_local_lua_tool_call_issues_no_leaf_work() { assert_eq!( trace.effects().len(), 0, - "a local tool call spawns no leaf request" + "an adhoc tool call spawns no leaf request" ); let lines = tool_lines(&trace).lines(); assert!( lines .iter() .any(|line| line == &format!("Only: {}", detail::TOOL_CALL_SUCCEEDED)), - "the local call is observed as a succeeded tool call: {lines:?}" + "the adhoc call is observed as a succeeded tool call: {lines:?}" ); assert!( lines .iter() .any(|line| line == "Only: tool_result id= alias=grab trust=Trusted content=got hi"), - "a script-initiated local call reports its trusted result under no call id: {lines:?}" + "a script-initiated adhoc call reports its trusted result under no call id: {lines:?}" ); } #[test] -fn a_model_issued_local_tool_call_reports_under_its_call_id() { - let source = one_section(&format!( - "{ADD_LOCAL_GRAB}\ +fn a_script_calls_an_unoffered_adhoc_tool_by_id_or_object_before_and_after_the_render() { + let (scenario, gateway) = rendered_scenario(one_section(&format!( + "local grab = tools.adhoc({{ name = 'grab', description = 'Grab a value', \ + parameters = {{ value = 'string' }} }}, function(args) return 'got ' .. args.value end)\n\ + local before = tools.call(grab, {{ value = 'a' }})\n\ + {RENDER}\ + local after = tools.call('adhoc/grab', {{ value = 'b' }})\n\ + return before .. '|' .. after .. '|' .. tostring(tools.calls['adhoc/grab'])" + ))); + let trace = scenario.chat(&gateway).run(); + assert_eq!( + trace.text(), + "got a|got b|2", + "an adhoc tool needs no offer to be called, and the render leaves it callable" + ); + assert!( + gateway.requests()[0].tools.is_empty(), + "an unoffered adhoc tool is never rendered" + ); +} + +#[test] +fn a_model_issued_adhoc_tool_call_reports_under_its_call_id() { + let (scenario, gateway) = rendered_scenario(one_section(&format!( + "{ADD_ADHOC_GRAB}{RENDER}\ return tools.call_as_model('call_7', 'grab', {{ value = 'hi' }})" - )); - let trace = arm_scenario(source).run(); + ))); + let trace = scenario.chat(&gateway).run(); assert_eq!(trace.text(), "got hi"); assert_eq!( - trace.effects().len(), + tool_call_effects(&trace), 0, - "a model-issued local call spawns no leaf request" + "a model-issued adhoc call spawns no leaf request" ); let lines = tool_lines(&trace).lines(); assert!( @@ -72,13 +116,13 @@ fn a_model_issued_local_tool_call_reports_under_its_call_id() { } #[test] -fn a_script_called_local_handler_uses_the_store() { +fn a_script_called_adhoc_handler_uses_the_store() { let source = one_section( - "tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + "tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) store.write('grab.txt', 'kept ' .. args.value) return store.read('grab.txt') - end) - return tools.call('grab', { value = 'hi' })", + end)) + return tools.call('adhoc/grab', { value = 'hi' })", ); let trace = arm_scenario(source).run(); assert_eq!(trace.text(), "kept hi"); @@ -91,8 +135,8 @@ fn a_script_called_local_handler_uses_the_store() { #[test] fn a_script_caller_catches_the_handlers_own_error_and_jump_is_restored() { let only = "local raised = { kind = 'custom', message = 'handler exploded' } - tools.offer_local('grab', 'Grab a value', {}, function() error(raised) end) - local ok, err = pcall(tools.call, 'grab', {}) + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value' }, function() error(raised) end)) + local ok, err = pcall(tools.call, 'adhoc/grab', {}) assert(not ok, 'the handler raise reaches the call site') assert(err == raised and err.kind == 'custom', 'the caller catches the handler table') jump('## Other')"; @@ -117,17 +161,17 @@ fn a_script_caller_catches_the_handlers_own_error_and_jump_is_restored() { } #[test] -fn a_nested_local_call_reports_both_results_innermost_first() { - let source = one_section( - "tools.offer_local('inner', 'Inner', { value = 'string' }, function(args) +fn a_nested_adhoc_call_reports_both_results_innermost_first() { + let (scenario, gateway) = rendered_scenario(one_section(&format!( + "tools.offer(tools.adhoc({{ name = 'inner', description = 'Inner', parameters = {{ value = 'string' }} }}, function(args) return 'inner ' .. args.value - end) - tools.offer_local('outer', 'Outer', { value = 'string' }, function(args) - return 'outer ' .. tools.call('inner', { value = args.value }) - end) - return tools.call_as_model('call_1', 'outer', { value = 'hi' })", - ); - let trace = arm_scenario(source).run(); + end)) + tools.offer(tools.adhoc({{ name = 'outer', description = 'Outer', parameters = {{ value = 'string' }} }}, function(args) + return 'outer ' .. tools.call('adhoc/inner', {{ value = args.value }}) + end)) + {RENDER}return tools.call_as_model('call_1', 'outer', {{ value = 'hi' }})", + ))); + let trace = scenario.chat(&gateway).run(); assert_eq!(trace.text(), "outer inner hi"); assert_eq!( tool_result_lines(&tool_lines(&trace)), @@ -144,33 +188,33 @@ fn a_nested_local_call_reports_both_results_innermost_first() { fn a_handler_dispatches_a_bound_tool_as_one_leaf_request() { let source = one_section( "tools.offer('tools/echo') - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) return tools.call('tools/echo', { value = args.value }) - end) - local out = tools.call('grab', { value = 'hi' }) - return out .. '|' .. tostring(tools.calls.grab) .. '|' .. tostring(tools.calls['tools/echo'])", + end)) + local out = tools.call('adhoc/grab', { value = 'hi' }) + return out .. '|' .. tostring(tools.calls['adhoc/grab']) .. '|' .. tostring(tools.calls['tools/echo'])", ); let trace = arm_scenario(source).offer("echo", echo_tool()).run(); assert_eq!(trace.text(), "echoed: hi|1|1", "both calls count once"); assert_eq!( trace.effects().len(), 1, - "the echo call is the one leaf request; the local call issues none" + "the echo call is the one leaf request; the adhoc call issues none" ); } #[test] -fn a_bound_failure_inside_a_model_issued_local_call_raises_kind_tool_in_the_handler() { - let source = one_section( +fn a_bound_failure_inside_a_model_issued_adhoc_call_raises_kind_tool_in_the_handler() { + let (scenario, gateway) = rendered_scenario(one_section(&format!( "tools.offer('tools/failing') - tools.offer_local('grab', 'Grab a value', {}, function() - local ok, err = pcall(tools.call, 'tools/failing', {}) + tools.offer(tools.adhoc({{ name = 'grab', description = 'Grab a value' }}, function() + local ok, err = pcall(tools.call, 'tools/failing', {{}}) assert(not ok, 'the bound failure raises inside the handler') return err.kind - end) - return tools.call_as_model('call_1', 'grab', {})", - ); - let trace = arm_scenario(source).offer("fail", failing_tool()).run(); + end)) + {RENDER}return tools.call_as_model('call_1', 'grab', {{}})", + ))); + let trace = scenario.offer("fail", failing_tool()).chat(&gateway).run(); assert_eq!( trace.text(), "tool", @@ -179,7 +223,7 @@ fn a_bound_failure_inside_a_model_issued_local_call_raises_kind_tool_in_the_hand assert_eq!( tool_result_lines(&tool_lines(&trace)), vec!["Only: tool_result id=call_1 alias=grab trust=Trusted content=tool".to_owned()], - "the inner failure fires no ToolResult; only the local call reports under call_1" + "the inner failure fires no ToolResult; only the adhoc call reports under call_1" ); } @@ -187,11 +231,11 @@ fn a_bound_failure_inside_a_model_issued_local_call_raises_kind_tool_in_the_hand fn a_handlers_text_stays_trusted_and_a_bound_tools_wrapper_survives() { let source = one_section( "tools.offer('tools/untrusted_echo') - tools.offer_local('plain', 'Plain', {}, function() return 'plain text' end) - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args) + tools.offer(tools.adhoc({ name = 'plain', description = 'Plain' }, function() return 'plain text' end)) + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args) return tools.call('tools/untrusted_echo', { value = args.value }) - end) - return tools.call('plain', {}) .. '||' .. tools.call('grab', { value = 'hi' })", + end)) + return tools.call('adhoc/plain', {}) .. '||' .. tools.call('adhoc/grab', { value = 'hi' })", ); let trace = arm_scenario(source) .offer("echo", untrusted_echo_tool()) diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs index 5a4389459..1d269466f 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_call_arm.rs @@ -1,15 +1,15 @@ //! Tests for the scheduler's `tool_call` arm: the model-issued form //! (`call_id: Some`), which always resumes with content and reports its //! `ToolResult` under the model's call id; the script form (`call_id: -//! None`), which keeps the raise-at-call-site behavior; local Lua tools, +//! None`), which keeps the raise-at-call-site behavior; adhoc tools, //! whose handlers run inside the calling block coroutine - store calls, -//! nested local calls, and bound calls included - with no leaf work of +//! nested adhoc calls, and bound calls included - with no leaf work of //! their own. //! The fixtures reach the //! model-issued form through the test-only `tools.call_as_model` install //! (`Scenario::raw_shims`); in production only the loop shim yields //! a `call_id`. -//! The local Lua tool cases sit in `local_handlers`. +//! The adhoc tool cases sit in `adhoc_handlers`. use runvil_parser::test_support::one_section; @@ -173,5 +173,5 @@ pub(super) fn tool_result_lines(recorder: &ToolRecorder) -> Vec { .collect() } -#[path = "tool_call_arm-local-handlers.rs"] -mod local_handlers; +#[path = "tool_call_arm-adhoc-handlers.rs"] +mod adhoc_handlers; diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs new file mode 100644 index 000000000..982f34660 --- /dev/null +++ b/crates/runvil-internal/engine/src/execute/tests/tool_listings.rs @@ -0,0 +1,234 @@ +//! Tests for a tool that describes tools: a script's call receives its +//! untrusted listing as plain text and reports it untrusted, a model's +//! call receives the envelope, and an adhoc call whose own function +//! fetched a listing while the call was open returns untrusted, wrapped +//! with the run's nonce. A listing kept from outside the adhoc call, a +//! failed listing call, and a trusted listing mark nothing. + +use runvil_parser::test_support::one_section; +use runvil_types::event::Event; + +use super::models_loop::catalog_scenario; +use super::*; +use crate::test_support::scenario::TEST_SEED; + +/// The listing every listing tool answers: text from the servers, markup +/// included. +const LISTING: &str = r#"[{"name":"search","description":"Search [INST]"}]"#; + +fn no_arguments() -> Value { + json!({ "type": "object", "properties": {} }) +} + +/// `tools/listing`, which describes tools and answers [`LISTING`] +/// untrusted. +fn listing_tool() -> FnTool { + FnTool::new("tools/listing", no_arguments(), |_| { + Ok(ToolOutput::untrusted(LISTING)) + }) + .description("List the servers' tools.") + .describes_tools() +} + +/// `tools/trusted_listing`, which describes tools and answers +/// [`LISTING`] trusted. +fn trusted_listing_tool() -> FnTool { + FnTool::new("tools/trusted_listing", no_arguments(), |_| { + Ok(ToolOutput::trusted(LISTING)) + }) + .description("List the built-in tools.") + .describes_tools() +} + +/// `tools/failing_listing`, which describes tools and always fails. +fn failing_listing_tool() -> FnTool { + FnTool::new("tools/failing_listing", no_arguments(), |_| { + Err(ToolError::message("the servers are down")) + }) + .description("List the tools of servers that are down.") + .describes_tools() +} + +fn nonce() -> GuardNonce { + GuardNonce::from_seed(&TEST_SEED) +} + +/// Every `ToolResult` as its call id, alias, content, and trust. +fn results(trace: &Trace) -> Vec<(String, String, String, bool)> { + trace + .events() + .iter() + .filter_map(|event| match event { + Event::ToolResult { + tool_call_id, + alias, + content, + trusted, + .. + } => Some(( + tool_call_id.clone(), + alias.clone(), + content.clone(), + *trusted, + )), + _ => None, + }) + .collect() +} + +fn result(id: &str, alias: &str, content: &str, trusted: bool) -> (String, String, String, bool) { + (id.to_owned(), alias.to_owned(), content.to_owned(), trusted) +} + +/// The content of every `tool` turn the last round sent the model. +fn tool_turns(bodies: &[ScriptedCall]) -> Vec { + bodies + .last() + .expect("the loop sends a closing round") + .messages_json() + .as_array() + .expect("a round's messages serialize to an array") + .iter() + .filter(|message| message["role"] == Role::Tool.as_str()) + .filter_map(|message| message["content"].as_str().map(str::to_owned)) + .collect() +} + +/// The run of a section whose loop the model answers with `requested`, +/// one round each, then a closing text, over every listing tool and +/// `tools/untrusted_echo`. +fn looped(section: &str, requested: &[&[(&str, &str, &str)]]) -> (Trace, ScriptedChat) { + let mut replies: Vec<_> = requested.iter().map(|batch| calls(batch)).collect(); + replies.push(text("done")); + let gateway = ScriptedChat::new(replies); + let trace = catalog_scenario(one_section(&format!( + "{section}\n\ + messages:user('list the tools')\n\ + models.loop()\n\ + return 'ok'" + ))) + .tool(listing_tool()) + .tool(trusted_listing_tool()) + .tool(failing_listing_tool()) + .tool(untrusted_echo_tool()) + .chat(&gateway) + .run(); + assert_eq!(trace.text(), "ok"); + (trace, gateway) +} + +#[test] +fn a_scripts_listing_call_returns_the_text_unwrapped_and_reports_it_untrusted() { + let trace = catalog_scenario(one_section("return tools.call('tools/listing', {})")) + .tool(listing_tool()) + .run(); + assert_eq!( + trace.text(), + LISTING, + "the script reads the listing as written" + ); + assert_eq!( + results(&trace), + [result("", "tools_listing", LISTING, false)], + "the result event holds the text the script received, untrusted" + ); +} + +#[test] +fn a_script_call_to_a_tool_without_the_flag_stays_wrapped() { + let trace = catalog_scenario(one_section( + "return tools.call('tools/untrusted_echo', { value = 'hi' })", + )) + .tool(untrusted_echo_tool()) + .run(); + assert_eq!(trace.text(), nonce().wrap("echoed: hi")); +} + +#[test] +fn a_model_call_and_a_model_call_to_an_adhoc_wrapper_receive_the_same_envelope() { + let (trace, gateway) = looped( + "tools.offer('tools/listing') + tools.offer(tools.adhoc({ name = 'wrap', description = 'List the tools' }, function() + return tools.call('tools/listing', {}) + end))", + &[&[("call_1", "tools_listing", "{}"), ("call_2", "wrap", "{}")]], + ); + let envelope = nonce().wrap(LISTING); + assert_eq!( + tool_turns(&gateway.requests()), + [envelope.clone(), envelope.clone()], + "the direct call and the wrapper hand the model one envelope, byte for byte" + ); + let results = results(&trace); + for expected in [ + result("call_1", "tools_listing", &envelope, false), + result("", "tools_listing", LISTING, false), + result("call_2", "wrap", &envelope, false), + ] { + assert!(results.contains(&expected), "{expected:?} in {results:?}"); + } +} + +#[test] +fn an_adhoc_function_returning_its_own_text_around_a_listing_returns_untrusted() { + let (trace, gateway) = looped( + "tools.offer(tools.adhoc({ name = 'wrap', description = 'List the tools' }, function() + return 'tools: ' .. tools.call('tools/listing', {}) + end))", + &[&[("call_1", "wrap", "{}")]], + ); + let envelope = nonce().wrap(&format!("tools: {LISTING}")); + assert_eq!( + tool_turns(&gateway.requests()), + std::slice::from_ref(&envelope) + ); + assert!( + results(&trace).contains(&result("call_1", "wrap", &envelope, false)), + "{:?}", + results(&trace) + ); +} + +#[test] +fn a_listing_kept_from_outside_the_adhoc_call_comes_back_unwrapped_and_trusted() { + let (trace, gateway) = looped( + "local kept = tools.call('tools/listing', {}) + tools.offer(tools.adhoc({ name = 'kept', description = 'The kept listing' }, function() return kept end))", + &[&[("call_1", "kept", "{}")]], + ); + assert_eq!(tool_turns(&gateway.requests()), [LISTING.to_owned()]); + assert!( + results(&trace).contains(&result("call_1", "kept", LISTING, true)), + "{:?}", + results(&trace) + ); +} + +#[test] +fn a_failed_listing_call_and_a_trusted_listing_mark_nothing() { + let (trace, gateway) = looped( + "tools.offer(tools.adhoc({ name = 'failed', description = 'A failed listing' }, function() + local ok = pcall(tools.call, 'tools/failing_listing', {}) + return 'listed ' .. tostring(ok) + end)) + tools.offer(tools.adhoc({ name = 'trusted', description = 'A trusted listing' }, function() + return tools.call('tools/trusted_listing', {}) + end))", + &[ + &[("call_1", "failed", "{}")], + &[("call_2", "trusted", "{}")], + ], + ); + assert_eq!( + tool_turns(&gateway.requests()), + ["listed false".to_owned(), LISTING.to_owned()] + ); + let results = results(&trace); + for expected in [ + result("call_1", "failed", "listed false", true), + result("", "tools_trusted_listing", LISTING, true), + result("call_2", "trusted", LISTING, true), + ] { + assert!(results.contains(&expected), "{expected:?} in {results:?}"); + } +} diff --git a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs index 622058c75..914232d63 100644 --- a/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs +++ b/crates/runvil-internal/engine/src/execute/tests/tool_scoping.rs @@ -7,6 +7,7 @@ use super::models_loop::{LOOP_TO_TEXT, loop_scenario}; use super::*; use crate::lua::{ToolBinding, ToolSet}; use crate::test_support::scenario::TEST_SEED; +use runvil_lua::{AdhocTools, OfferMode}; /// The tool set offering `tool` under the wire name `alias`, described as /// `description`, for a test that reads the offering without a run. @@ -27,7 +28,8 @@ fn offered_tools_are_not_injected_without_an_offer() { let (tool, _) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "capability", &tool); let runtime = Mutex::new(runvil_lua::ToolRuntime::default()); - let effective = current_tool_bindings(&tools, &runtime).expect("the scope must snapshot"); + let effective = current_tool_bindings(&tools, &runtime, &AdhocTools::default()) + .expect("the scope must snapshot"); assert!( effective.is_empty(), "an offering must not expose a tool without an explicit offer" @@ -42,12 +44,14 @@ fn an_offered_tool_advertises_its_concrete_schema_under_its_wire_name_and_dispat ]); let (tool, called) = scoped_tool("concrete", "Concrete description."); let tools = offering_of("tools_concrete", "Concrete description.", &tool); - let runtime = Mutex::new(runvil_lua::ToolRuntime { - added: vec!["tools_concrete".to_owned()], - ..runvil_lua::ToolRuntime::default() - }); - let effective = current_tool_bindings(&tools, &runtime).expect("the offered scope snapshots"); - let schemas = prepare_scoped_tools(&effective, &[]) + let mut runtime = runvil_lua::ToolRuntime::default(); + runtime.add_tool( + ToolId::parse("tools/concrete").expect("a fixture tool id"), + OfferMode::Static, + ); + let effective = current_tool_bindings(&tools, &Mutex::new(runtime), &AdhocTools::default()) + .expect("the offered scope snapshots"); + let schemas = prepare_scoped_tools(&effective) .expect("schemas must build") .schemas; assert_eq!(schemas.len(), 1); @@ -108,9 +112,9 @@ fn a_section_offer_by_id_scopes_the_tool_and_dispatches_it() { vm.run_chunk(&offer, &null_emitter(), "Only") .expect("tools.offer must succeed"); let (tool_bindings, tool_runtime) = vm.tool_bag_handles(); - let scope = - current_tool_bindings(&tool_bindings, &tool_runtime).expect("tool scope must snapshot"); - let schemas = prepare_scoped_tools(&scope, &[]) + let scope = current_tool_bindings(&tool_bindings, &tool_runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); + let schemas = prepare_scoped_tools(&scope) .expect("schemas must build") .schemas; assert_eq!(schemas.len(), 1); diff --git a/crates/runvil-internal/engine/src/lib.rs b/crates/runvil-internal/engine/src/lib.rs index 170a01aeb..f9edc5593 100644 --- a/crates/runvil-internal/engine/src/lib.rs +++ b/crates/runvil-internal/engine/src/lib.rs @@ -37,5 +37,5 @@ pub use crate::execute::{ AnswerRecord, ChatAnswerRecord, Effect, EffectAnswer, EffectId, EffectRecord, FailureRecord, MissingService, ModelBindings, RequirementCheck, Requirements, Round, Run, RunContext, RunError, RunErrorKind, RunLimits, RunResult, SourceLocation, Step, ToolAnswerRecord, - ToolCallOrigin, ToolCaller, UnavailablePack, UnmetRequirement, perform_vfs_op, + ToolCallOrigin, ToolCaller, UnavailablePack, UnmetRequirement, UsablePack, perform_vfs_op, }; diff --git a/crates/runvil-internal/engine/src/lua.rs b/crates/runvil-internal/engine/src/lua.rs index 56aeee79d..76bfe7358 100644 --- a/crates/runvil-internal/engine/src/lua.rs +++ b/crates/runvil-internal/engine/src/lua.rs @@ -15,12 +15,13 @@ // a caller answers a `Vfs` effect through, and the // model-facing message renderers a store failure carries. pub(crate) use runvil_lua::{ - Argv, CoroStep, Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, ModelReport, - NonEmpty, OverflowReason, ProseState, QuotaResource, RenderedTools, ScriptReport, SectionText, - SectionVm, ToolBinding, ToolCallCounts, ToolSet, Uninjected, UsageAnchor, - current_tool_bindings, enrich_sys_model, exhaustion_detail, install_preludes, - install_section_loop_shim, install_store_shims, install_ui, output_reserve, precheck, - prepare_dispatch, prepare_model_dispatch, project_messages, render_item, resolve_model_binding, + Advertised, Argv, CoroStep, Library, LibraryBlock, LibraryKey, LuaBlockResult, MessageList, + ModelReport, NonEmpty, OverflowReason, ProseState, QuotaResource, RenderedTools, ScriptReport, + SectionText, SectionVm, ToolBinding, ToolCallCounts, ToolSet, Uninjected, UsageAnchor, + current_dynamic_tools, current_tool_bindings, enrich_sys_model, exhaustion_detail, + install_preludes, install_section_loop_shim, install_store_shims, install_ui, output_reserve, + precheck, prepare_dispatch, prepare_model_dispatch, project_messages, render_item, + resolve_model_binding, }; pub(crate) use runvil_lua::{run_store_op, store_error_message, store_probe_message}; diff --git a/crates/runvil-internal/engine/src/lua/tests/shims.rs b/crates/runvil-internal/engine/src/lua/tests/shims.rs index 02130f0cd..80852c82b 100644 --- a/crates/runvil-internal/engine/src/lua/tests/shims.rs +++ b/crates/runvil-internal/engine/src/lua/tests/shims.rs @@ -1,6 +1,6 @@ //! The yield shims installed on a scheduler-mode section VM produce //! well-formed protocol requests: `models.infer`, `call`, `tasks.fanout`, and -//! `tools.call` in their id and tool-object forms, the local tool +//! `tools.call` in their id and tool-object forms, the adhoc tool //! handshake, the loop's batch and its requesting turn, a model handle's //! `infer` method, and the `infer` and `loop` methods a model handle //! carries. @@ -9,7 +9,7 @@ use runvil_types::metrics::ToolCallEvent; use serde_json::json; use crate::execute::protocol::{ - Answer, ChatOutcome, ChatResult, LocalToolOutcome, Request, SpawnVia, ToolCallOutcome, VfsOp, + AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, SpawnVia, ToolCallOutcome, VfsOp, VfsOutcome, YieldParse, }; use crate::model::ModelSet; @@ -181,29 +181,29 @@ fn tools_call_yields_a_well_formed_request() { } #[test] -fn a_local_tool_handler_runs_inside_the_block_coroutine() { +fn an_adhoc_tool_handler_runs_inside_the_block_coroutine() { // The two-yield handshake end to end: the `tool_call` yield, the - // `Local` answer handing over the handler, the handler's own `store` - // yield from inside the same coroutine, the `local_tool_done` yield + // `Adhoc` answer handing over the handler, the handler's own `store` + // yield from inside the same coroutine, the `adhoc_tool_done` yield // carrying its return, and the block's return with the answered text. // `jump` is refused while the handler runs and back afterward. let vm = scheduler_vm(&ModelSet::default(), None); let (thread, yielded) = start( &vm, "local withheld\n\ - tools.offer_local('grab', 'Grab a value', { value = 'string' }, function(args)\n\ + tools.offer(tools.adhoc({ name = 'grab', description = 'Grab a value', parameters = { value = 'string' } }, function(args)\n\ local ok, err = pcall(jump, '## Other')\n\ withheld = not ok\n\ - and tostring(err):find('jump is unavailable inside a local tool handler', 1, true) ~= nil\n\ + and tostring(err):find('jump is unavailable inside an adhoc tool handler', 1, true) ~= nil\n\ store.write('grab.txt', args.value)\n\ return 'stored ' .. args.value\n\ - end)\n\ - local out = tools.call('grab', { value = 'hi' })\n\ + end))\n\ + local out = tools.call('adhoc/grab', { value = 'hi' })\n\ return out .. '|' .. tostring(withheld) .. '|' .. tostring(jump ~= nil)", ); match parse_request(&vm, yielded) { Request::ToolCall { alias, call_id, .. } => { - assert_eq!(alias, "grab"); + assert_eq!(alias, "adhoc/grab"); assert_eq!(call_id, None); } other => panic!("the block's first yield is the tool call, got {other:?}"), @@ -211,7 +211,7 @@ fn a_local_tool_handler_runs_inside_the_block_coroutine() { let yielded = resume_with( &vm, &thread, - Answer::ToolCallResult(Ok(ToolCallOutcome::Local { + Answer::ToolCallResult(Ok(ToolCallOutcome::Adhoc { alias: "grab".to_owned(), args: json!({ "value": "hi" }), })), @@ -227,8 +227,8 @@ fn a_local_tool_handler_runs_inside_the_block_coroutine() { } let yielded = resume_with(&vm, &thread, Answer::Store(Ok(VfsOutcome::Unit))); match parse_request(&vm, yielded) { - Request::LocalToolDone { - outcome: LocalToolOutcome::Returned(text), + Request::AdhocToolDone { + outcome: AdhocToolOutcome::Returned(text), } => assert_eq!(text, "stored hi"), other => panic!("the handler's return is reported, got {other:?}"), } @@ -315,9 +315,9 @@ fn tools_call_accepts_a_tool_object_in_place_of_the_id() { #[test] fn tools_call_rejects_a_first_argument_that_is_neither_a_string_nor_a_tool() { - // The polymorphism is a string (a tool id or a local alias) or a tool - // object; anything else is the call's own error at the protocol - // boundary, so an author pcall catches it at the call site. + // The polymorphism is a tool id or a tool object; anything else is the + // call's own error at the protocol boundary, so an author pcall + // catches it at the call site. let vm = scheduler_vm(&ModelSet::default(), None); let (_thread, yielded) = start(&vm, "return tools.call(42, {})"); let value = yielded.into_iter().next().expect("one yielded value"); @@ -325,7 +325,7 @@ fn tools_call_rejects_a_first_argument_that_is_neither_a_string_nor_a_tool() { YieldParse::Call(answer) => { let message = format!("{answer:?}"); assert!( - message.contains("tools.call takes a tool id, a local alias, or a tool object"), + message.contains("tools.call takes a tool id or a tool object, got integer"), "the rejection names the expected forms: {message}" ); } diff --git a/crates/runvil-internal/engine/src/test_support/recording/forward-tests-variants.rs b/crates/runvil-internal/engine/src/test_support/recording/forward-tests-variants.rs index e2c166dd5..4513fc291 100644 --- a/crates/runvil-internal/engine/src/test_support/recording/forward-tests-variants.rs +++ b/crates/runvil-internal/engine/src/test_support/recording/forward-tests-variants.rs @@ -286,6 +286,7 @@ fn one_of_every_event_variant() -> Vec<(Event, Seam)> { tool_call_id: "call_1".to_owned(), alias: "search".to_owned(), tool: None, + vendor: None, content: "found".to_owned(), trusted: false, }, diff --git a/crates/runvil-internal/engine/src/test_support/recording/forward-tests.rs b/crates/runvil-internal/engine/src/test_support/recording/forward-tests.rs index 5c3b962b5..062d8d29f 100644 --- a/crates/runvil-internal/engine/src/test_support/recording/forward-tests.rs +++ b/crates/runvil-internal/engine/src/test_support/recording/forward-tests.rs @@ -243,6 +243,7 @@ fn a_tool_result_forwards_its_coordinates_and_trust_to_the_observer() { tool_call_id: "call_1".to_owned(), alias: "search".to_owned(), tool: None, + vendor: None, content: "found".to_owned(), trusted, }; diff --git a/crates/runvil-internal/engine/src/test_support/tools.rs b/crates/runvil-internal/engine/src/test_support/tools.rs index 3c5c3c04b..1856cce96 100644 --- a/crates/runvil-internal/engine/src/test_support/tools.rs +++ b/crates/runvil-internal/engine/src/test_support/tools.rs @@ -24,6 +24,7 @@ pub(crate) struct FnTool { description: String, schema: serde_json::Value, structured: bool, + describes_tools: bool, handler: Arc, } @@ -42,6 +43,7 @@ impl FnTool { description: String::new(), schema, structured: false, + describes_tools: false, handler: Arc::new(handler), } } @@ -59,6 +61,13 @@ impl FnTool { self } + /// Marks the tool as one whose output lists tools, so a script's call + /// receives its untrusted output as plain text. + pub(crate) fn describes_tools(mut self) -> FnTool { + self.describes_tools = true; + self + } + /// The tool's stable identity: the catalog key and what a `ToolCall` /// effect names. pub(crate) fn id(&self) -> ToolId { @@ -68,7 +77,9 @@ impl FnTool { /// The tool as data: the tool info the Engine binds and advertises, /// what a scenario installs in the run's catalog. pub(crate) fn info(&self) -> ToolInfo { - ToolInfo::new(self.id(), &self.description, self.schema.clone()).structured(self.structured) + ToolInfo::new(self.id(), &self.description, self.schema.clone()) + .structured(self.structured) + .describes_tools(self.describes_tools) } /// Answers one call with `args` at once. diff --git a/crates/runvil-internal/lua/src/__impl_coro.lua b/crates/runvil-internal/lua/src/__impl_coro.lua index 471bd10c5..4373eb813 100644 --- a/crates/runvil-internal/lua/src/__impl_coro.lua +++ b/crates/runvil-internal/lua/src/__impl_coro.lua @@ -12,9 +12,9 @@ -- block's raised value for the Engine before the guard re-raises it, and -- `normalize_failure` turns a Rust callback's raised failure (mlua's -- opaque userdata) into the error table, passing every other value --- through unchanged, `enter_local_handler()` and `leave_local_handler()` --- step the counter the Engine's `jump` reads, so `jump` refuses while a --- local tool's handler runs, and `cancel_requested()` reports whether the +-- through unchanged, `enter_adhoc_handler()` and `leave_adhoc_handler()` +-- step the counter the Engine's `jump` reads, so `jump` refuses while an +-- adhoc tool's handler runs, and `cancel_requested()` reports whether the -- run's cancel flag is set: a failure caught under cancellation is the -- instruction hook's abort, which must unwind to the block guard, so every -- protected call below raises it again instead of returning it, @@ -29,7 +29,7 @@ -- `__impl_fanout.lua`), installed by the Engine right after this one over -- the failure helpers this chunk returns. local yield, var_snapshot, models, tools, error_value, stash_failure, - normalize_failure, enter_local_handler, leave_local_handler, + normalize_failure, enter_adhoc_handler, leave_adhoc_handler, cancel_requested, is_model_handle, loop_begin, compact_begin = ... -- The base library's pcall and xpcall, captured before the replacements @@ -157,22 +157,22 @@ local function call_section(target, input) return result end --- Runs a local tool's handler inside this block coroutine, so every +-- Runs an adhoc tool's handler inside this block coroutine, so every -- suspending call the handler makes is an ordinary yield of the chain. -- `jump` refuses while the handler runs; the raw pcall catches every -- failure, so the leave always follows the enter, and it keeps a Rust -- callback's failure in mlua's own form. --- The `local_tool_done` yield carries the handler's first return value +-- The `adhoc_tool_done` yield carries the handler's first return value -- (only when it returned) for the driver to report; afterward the -- handler's own failure is raised again unchanged, a rejected return -- raises the driver's error, and a returned value resumes as its text. -- A failure under cancellation is raised at once, with no yield. -local function run_local_tool(handler, args) - enter_local_handler() +local function run_adhoc_tool(handler, args) + enter_adhoc_handler() local ok, value = raw_pcall(handler, args) - leave_local_handler() + leave_adhoc_handler() if not ok and cancel_requested() then error(value, 0) end - local done = { op = "local_tool_done", ok = ok } + local done = { op = "adhoc_tool_done", ok = ok } if ok then done.value = value end local answered, result = yield(done) if not ok then error(value, 0) end @@ -181,29 +181,29 @@ local function run_local_tool(handler, args) end -- One `tool_call` yield and its resume. A bound tool resumes as --- `(ok, result)`; a local tool resumes as `(true, nil, handler, args)`, +-- `(ok, result)`; an adhoc tool resumes as `(true, nil, handler, args)`, -- and the handler runs here. local function dispatch_tool(request) local ok, result, handler, args = yield(request) if not ok then fail(result) end - if handler ~= nil then return run_local_tool(handler, args) end + if handler ~= nil then return run_adhoc_tool(handler, args) end return result end --- Suspending dispatch of a tool. The first argument is a tool id, a local --- tool's alias, or a tool object; the polymorphism decodes once, in the --- protocol parse. The driver resumes a bound tool's result by --- the binding's declared output kind: a plain binding's text as a string, --- a structured binding's JSON output as a table. -local function tools_call(alias_or_tool, args) - return dispatch_tool({ op = "tool_call", alias = alias_or_tool, args = args }) +-- Suspending dispatch of a tool. The first argument is a tool id or a tool +-- object; the polymorphism decodes once, in the protocol parse. The driver +-- resumes a bound tool's result by the binding's declared output kind: a +-- plain binding's text as a string, a structured binding's JSON output as +-- a table. +local function tools_call(id_or_tool, args) + return dispatch_tool({ op = "tool_call", alias = id_or_tool, args = args }) end -- The model-issued form of the same dispatch: `call_id` is the id the model -- attached to its tool call, and `turn` the turn of the round that -- requested it. The driver always resumes a bound tool with content (a -- tool's own failure becomes untrusted failure text) and fires ToolResult --- under the id and that turn, however far a local handler earlier in the +-- under the id and that turn, however far an adhoc handler earlier in the -- batch moved the counter. Only the test-only `tools.call_as_model` hook -- calls it now, since the loop builds its `tool_call` requests in Rust; -- authors never see it, and a hand-built yield including `call_id` or @@ -223,7 +223,7 @@ end -- step returns. A Rust function called from Lua cannot yield, and cannot -- call a Lua function that may yield, so the step hands back what to do -- next instead of doing it: yield a request and pass every resume value --- back; run a local tool's handler between the depth captures, or the +-- back; run an adhoc tool's handler between the depth captures, or the -- compactor, under the raw pcall and pass `ok` and the first result back; -- raise a value at level 0; or return nil. The raw pcall keeps a Rust -- callback's failure in mlua's own form for the step to judge. @@ -232,9 +232,9 @@ local function drive(step, action, a, b) if action == "yield" then action, a, b = step(yield(a)) elseif action == "handler" then - enter_local_handler() + enter_adhoc_handler() local ok, value = raw_pcall(a, b) - leave_local_handler() + leave_adhoc_handler() action, a, b = step(ok, value) elseif action == "compactor" then local ok, failure = raw_pcall(a, b) diff --git a/crates/runvil-internal/lua/src/alias.rs b/crates/runvil-internal/lua/src/alias.rs index e94465df0..5b385c960 100644 --- a/crates/runvil-internal/lua/src/alias.rs +++ b/crates/runvil-internal/lua/src/alias.rs @@ -5,8 +5,8 @@ use crate::error::{Error, Result}; /// A prompt-local alias that passed the supported wire grammar. /// -/// Local tool aliases and model labels share the one rule. An alias never -/// contains `/`, so it never reads as a tool id. +/// An adhoc tool's name and a model label share the one rule. An alias +/// never contains `/`, so it never reads as a tool id. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Alias(String); diff --git a/crates/runvil-internal/lua/src/compactors-tests.rs b/crates/runvil-internal/lua/src/compactors-tests.rs index 35f8dd921..788916e2f 100644 --- a/crates/runvil-internal/lua/src/compactors-tests.rs +++ b/crates/runvil-internal/lua/src/compactors-tests.rs @@ -176,6 +176,7 @@ fn compactors_clear_removes_every_record_and_keeps_the_send_and_the_rendered_too list.attach_rendered_tools(RenderedTools { schemas: Vec::new(), dispatch: BTreeMap::new(), + dynamic: Vec::new(), }); let sent = [Message::user("one")]; let _ = list.commit(RoundId::new(0), &sent); diff --git a/crates/runvil-internal/lua/src/coro.rs b/crates/runvil-internal/lua/src/coro.rs index f9bf8a4bd..0952f17a1 100644 --- a/crates/runvil-internal/lua/src/coro.rs +++ b/crates/runvil-internal/lua/src/coro.rs @@ -13,12 +13,12 @@ //! frames as verbatim `file:line:` references with no `[string "..."]` //! wrapper, and the line mapper (`program.rs`) never touches them. //! -//! A local tool call is a two-yield handshake inside the calling block -//! coroutine. The `tools.call` shim yields `tool_call`; for a local alias +//! An adhoc tool call is a two-yield handshake inside the calling block +//! coroutine. The `tools.call` shim yields `tool_call`; for an adhoc tool //! the driver resumes it with the handler instead of a result. The shim //! calls the handler under a yield-safe `pcall` with `jump` refused, so //! the handler's own suspending calls are ordinary yields of the same -//! coroutine, then yields `local_tool_done` with the outcome for the +//! coroutine, then yields `adhoc_tool_done` with the outcome for the //! driver to report, and finally returns the text or raises. #[path = "coro-failure.rs"] @@ -152,9 +152,9 @@ static FANOUT_PROGRAM: LazyLock> = /// replacements, which run every caught value through that capture, are /// installed over the base library's globals here, so an Engine function that /// fails directly from Rust reaches author code in the one shape. The next -/// two captures, `enter_local_handler` and `leave_local_handler`, count up -/// and down on `local_handler_depth`, the counter the VM's `jump` reads, so -/// `jump` refuses while a local tool's handler runs, whatever reference +/// two captures, `enter_adhoc_handler` and `leave_adhoc_handler`, count up +/// and down on `adhoc_handler_depth`, the counter the VM's `jump` reads, so +/// `jump` refuses while an adhoc tool's handler runs, whatever reference /// the handler calls it through. Then `cancel_requested` reads the /// cancel flag of `instruction_budget`, so the chunk's protected calls /// raise a failure caught under cancellation again instead of returning @@ -170,7 +170,7 @@ static FANOUT_PROGRAM: LazyLock> = pub(crate) fn install_shim_prelude( lua: &Lua, max_tool_iterations: usize, - local_handler_depth: &Arc, + adhoc_handler_depth: &Arc, loop_running: &Arc, instruction_budget: &InstructionBudget, ) -> Result<()> { @@ -187,8 +187,8 @@ pub(crate) fn install_shim_prelude( let error_value = install_error_value(lua).map_err(Error::lua)?; let stash_failure = stash_failure_capture(lua)?; let normalize_failure = install_normalize_failure(lua).map_err(Error::lua)?; - let (enter_local_handler, leave_local_handler) = - local_handler_captures(lua, local_handler_depth)?; + let (enter_adhoc_handler, leave_adhoc_handler) = + adhoc_handler_captures(lua, adhoc_handler_depth)?; let budget = instruction_budget.clone(); let cancel_requested = lua .create_function(move |_, ()| Ok(budget.is_cancelled())) @@ -215,8 +215,8 @@ pub(crate) fn install_shim_prelude( error_value, stash_failure, normalize_failure, - enter_local_handler, - leave_local_handler, + enter_adhoc_handler, + leave_adhoc_handler, cancel_requested, is_model_handle, loop_begin, @@ -299,10 +299,10 @@ fn stash_shims(lua: &Lua, shims: &Table) -> Result<()> { .map_err(Error::lua) } -/// Builds the prelude's `enter_local_handler()` and `leave_local_handler()` +/// Builds the prelude's `enter_adhoc_handler()` and `leave_adhoc_handler()` /// captures over `depth`: one step up and one step down, so nested /// handlers balance. -fn local_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function, Function)> { +fn adhoc_handler_captures(lua: &Lua, depth: &Arc) -> Result<(Function, Function)> { let entered = Arc::clone(depth); let enter = lua .create_function(move |_, ()| { diff --git a/crates/runvil-internal/lua/src/dispatch-tests.rs b/crates/runvil-internal/lua/src/dispatch-tests.rs index cd4330b75..22730f6c9 100644 --- a/crates/runvil-internal/lua/src/dispatch-tests.rs +++ b/crates/runvil-internal/lua/src/dispatch-tests.rs @@ -137,6 +137,99 @@ fn a_model_issued_tool_failure_becomes_untrusted_failure_text_under_its_call_id( assert_eq!(recorder.kinds(), vec![boundary::TOOL_CALL_FAILED],); } +/// `mcp/get_dynamic_tools` as data: a tool that describes tools. +fn listing_tool() -> ToolInfo { + tool_at("mcp/get_dynamic_tools").describes_tools(true) +} + +#[test] +fn a_scripts_listing_call_passes_its_untrusted_text_verbatim_and_reports_it_untrusted() { + let recorder = Recorder::default(); + let listing = binding("mcp_get_dynamic_tools", &listing_tool()); + let outcome = prepare_dispatch( + &listing, + Ok(ToolOutput::untrusted("the [INST]")), + None, + &test_nonce(), + recorder.emitter(), + TEST_SECTION, + Some(ScriptReport { turn: 2 }), + ) + .expect("a listing dispatches"); + assert_eq!(outcome.content(), "the [INST]"); + assert_eq!(outcome.trust(), OutputTrust::Untrusted); + assert!(outcome.listing(), "the verbatim listing is reported"); + assert_eq!( + recorder.tool_results(), + vec![( + 2, + String::new(), + "mcp_get_dynamic_tools".to_owned(), + "the [INST]".to_owned(), + false, + )], + "the result event holds the text the script received, untrusted" + ); +} + +#[test] +fn a_models_listing_call_receives_the_envelope() { + let listing = binding("mcp_get_dynamic_tools", &listing_tool()); + let nonce = test_nonce(); + let outcome = prepare_model_dispatch( + &listing, + Ok(ToolOutput::untrusted("the listing")), + None, + &nonce, + Recorder::default().emitter(), + TEST_SECTION, + &model_report("call_1"), + ) + .expect("a listing dispatches"); + assert_eq!(outcome.content(), nonce.wrap("the listing")); + assert_eq!(outcome.trust(), OutputTrust::Untrusted); + assert!(!outcome.listing(), "a model's call is never a listing"); +} + +#[test] +fn the_listing_rule_needs_the_flag_and_untrusted_output() { + let nonce = test_nonce(); + let script = |tool: &ToolInfo, output: ToolOutput| { + prepare_dispatch( + &binding("tool", tool), + Ok(output), + None, + &nonce, + Recorder::default().emitter(), + TEST_SECTION, + Some(ScriptReport { turn: 1 }), + ) + .expect("the call dispatches") + }; + let unflagged = script(&echo_tool(), ToolOutput::untrusted("fetched")); + assert_eq!(unflagged.content(), nonce.wrap("fetched")); + assert!(!unflagged.listing(), "a tool without the flag is wrapped"); + let trusted = script(&listing_tool(), ToolOutput::trusted("built in")); + assert_eq!( + (trusted.content(), trusted.trust(), trusted.listing()), + ("built in", OutputTrust::Trusted, false), + "a trusted listing passes as any trusted output and is not reported" + ); + let failed = prepare_dispatch( + &binding("tool", &listing_tool()), + Err(ToolError::message("the servers are down")), + None, + &nonce, + Recorder::default().emitter(), + TEST_SECTION, + Some(ScriptReport { turn: 1 }), + ); + assert!( + matches!(failed, Err(Error::Tool { .. })), + "a failed listing is the tool's error: {failed:?}" + ); +} + #[test] fn a_model_issued_dispatch_reports_its_result_under_the_call_id() { let recorder = Recorder::default(); diff --git a/crates/runvil-internal/lua/src/dispatch.rs b/crates/runvil-internal/lua/src/dispatch.rs index 39ae703cf..1ed503ab7 100644 --- a/crates/runvil-internal/lua/src/dispatch.rs +++ b/crates/runvil-internal/lua/src/dispatch.rs @@ -52,34 +52,43 @@ pub struct ModelReport { } /// The resolved outcome of one dispatched tool call: the final content - -/// nonce-wrapped when untrusted - beside its trust marking. A model -/// tool-loop dispatch reports the pair through the `ToolResult` event -/// itself; a script-initiated dispatch reads only the content, its report -/// already fired inside [`prepare_dispatch`]. +/// nonce-wrapped when untrusted, except a script's listing - beside its +/// trust marking. A model tool-loop dispatch reports the pair through the +/// `ToolResult` event itself; a script-initiated dispatch reads only the +/// content, its report already fired inside [`prepare_dispatch`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ToolDispatch { content: String, trust: OutputTrust, + listing: bool, } impl ToolDispatch { - /// The final content: trusted output verbatim, anything else - /// nonce-wrapped. + /// The final content: trusted output and a script's listing verbatim, + /// anything else nonce-wrapped. #[cfg(test)] #[must_use] pub(crate) fn content(&self) -> &str { &self.content } - /// The content's trust marking: [`OutputTrust::Trusted`] - /// for verbatim output, [`OutputTrust::Untrusted`] for the - /// nonce-wrapped envelope. + /// The content's trust marking: [`OutputTrust::Trusted`] for trusted + /// output, [`OutputTrust::Untrusted`] for the nonce-wrapped envelope + /// and for a script's listing. #[cfg(test)] #[must_use] pub(crate) fn trust(&self) -> OutputTrust { self.trust } + /// Whether the content is untrusted text passed verbatim: a script's + /// call to a tool that describes tools, answered with untrusted + /// output. Trusted output from such a tool is not a listing. + #[must_use] + pub fn listing(&self) -> bool { + self.listing + } + /// Dissolves the outcome into its content. #[must_use] pub fn into_content(self) -> String { @@ -94,11 +103,14 @@ impl ToolDispatch { /// executor that counted the attempt at dispatch passes `None`), the /// succeeded/failed observation, /// then the trust rule - a trusted output passes verbatim, anything else is -/// nonce-wrapped before it can reach a model turn or a calling script. A -/// script-initiated call (`script` is `Some`) also fires -/// the `ToolResult` event with the final content; a model-loop call -/// fires no content event here: the loop reports the returned -/// [`ToolDispatch`] under the model-issued call id. +/// nonce-wrapped before it can reach a model turn or a calling script, +/// except that a script-initiated call to a binding that describes tools +/// receives its untrusted output verbatim, still marked untrusted, as a +/// listing the script reads. A script-initiated call (`script` is `Some`) +/// also fires the `ToolResult` event with the final content and the +/// binding's vendor; a model-loop call fires no content event here: the +/// loop reports the returned [`ToolDispatch`] under the model-issued call +/// id. /// /// `call_result` is the tool's own answer, however the caller obtained it. /// Nothing here awaits: the Harness performs the call, and the Engine @@ -130,17 +142,24 @@ pub fn prepare_dispatch( ); let output = call_result.map_err(Error::tool)?; // Trust travels with the output: an untrusted result is nonce-wrapped - // before it can reach the next model turn or the calling script. Every - // wrap in the run shares the run's nonce, so identical content yields a - // byte-identical envelope and KV-cache prefixes stay shared across - // rounds and fanout arms; the `<`-escaping is what actually blocks a - // forged close tag, so the reuse costs nothing. - let (content, trust) = match output.trust() { - OutputTrust::Trusted => (output.text().to_owned(), OutputTrust::Trusted), + // before it can reach the next model turn or the calling script, except + // a listing a script reads, which passes verbatim. Its `listing` flag + // lets the Engine mark the innermost open adhoc call, whose result is + // then wrapped before a model reads it; the untrusted marking keeps + // this call's result event `trusted` false. Every wrap in the run + // shares the run's nonce, so identical content yields a byte-identical + // envelope and KV-cache prefixes stay shared across rounds and fanout + // arms; the `<`-escaping is what actually blocks a forged close tag, so + // the reuse costs nothing. + let (content, trust, listing) = match output.trust() { + OutputTrust::Trusted => (output.text().to_owned(), OutputTrust::Trusted, false), + OutputTrust::Untrusted if script.is_some() && binding.describes_tools() => { + (output.text().to_owned(), OutputTrust::Untrusted, true) + } // `OutputTrust` is `#[non_exhaustive]` in the contract crate: an // unknown future variant takes the safe path and is nonce-wrapped // as untrusted. - _ => (nonce.wrap(output.text()), OutputTrust::Untrusted), + _ => (nonce.wrap(output.text()), OutputTrust::Untrusted, false), }; if let Some(report) = script { emitter.tool_result( @@ -149,11 +168,16 @@ pub fn prepare_dispatch( "", binding.alias(), Some(binding.id()), + binding.vendor(), &content, trust, ); } - Ok(ToolDispatch { content, trust }) + Ok(ToolDispatch { + content, + trust, + listing, + }) } /// Applies the model-issued dispatch rules to one bound tool call's answer: @@ -185,6 +209,7 @@ pub fn prepare_model_dispatch( Err(Error::Tool { message, .. }) => ToolDispatch { content: nonce.wrap(&message), trust: OutputTrust::Untrusted, + listing: false, }, Err(error) => return Err(error), }; @@ -194,6 +219,7 @@ pub fn prepare_model_dispatch( &report.call_id, binding.alias(), Some(binding.id()), + binding.vendor(), &outcome.content, outcome.trust, ); diff --git a/crates/runvil-internal/lua/src/globals.rs b/crates/runvil-internal/lua/src/globals.rs index bb2e3a2d0..b6053fdd7 100644 --- a/crates/runvil-internal/lua/src/globals.rs +++ b/crates/runvil-internal/lua/src/globals.rs @@ -204,7 +204,7 @@ impl fmt::Display for Reserved { /// A global section setup installs must be listed here: the Engine's /// section setup tests compare this list against a set-up VM's globals in /// both directions. -pub const RESERVED_NAMES: [(&str, Reserved); 63] = [ +pub const RESERVED_NAMES: [(&str, Reserved); 62] = [ ("args", Reserved::EngineGlobal), ("argv", Reserved::EngineGlobal), ("call", Reserved::EngineGlobal), @@ -215,7 +215,6 @@ pub const RESERVED_NAMES: [(&str, Reserved); 63] = [ ("log", Reserved::EngineGlobal), ("messages", Reserved::EngineGlobal), ("models", Reserved::EngineGlobal), - ("packs", Reserved::EngineGlobal), ("prompt", Reserved::EngineGlobal), ("prose", Reserved::EngineGlobal), ("require", Reserved::EngineGlobal), diff --git a/crates/runvil-internal/lua/src/handles.rs b/crates/runvil-internal/lua/src/handles.rs index 2038e5f4e..8abdfe9af 100644 --- a/crates/runvil-internal/lua/src/handles.rs +++ b/crates/runvil-internal/lua/src/handles.rs @@ -25,8 +25,8 @@ pub enum ToolOutputKind { } /// One catalog tool bound under the name a model sees, holding the tool's -/// data - its schema, description, and output kind - and never its -/// implementation. +/// data - its schema, description, output kind, whether it lists tools, +/// and its Pack's vendor - and never its implementation. /// /// Run-time execution (schema preparation, script dispatch) reads the /// binding alone; a call is issued as an effect naming the identity, and @@ -46,12 +46,18 @@ pub struct ToolBinding { /// How a script-initiated `tools.call` resumes this binding's output; /// the model tool loop ignores it. output_kind: ToolOutputKind, + /// Whether the tool's output lists tools, which a script's call + /// receives as plain text. + describes_tools: bool, + /// The vendor of the tool's Pack, as the caller reported it. + vendor: Option, } impl ToolBinding { /// Binds `alias` to the tool `info` describes: the description is the - /// tool's own, and the output kind follows the tool info's - /// structured-output flag. + /// tool's own, the output kind follows the tool info's + /// structured-output flag, and the listing flag is the tool info's. + /// The binding names no vendor. #[must_use] pub fn from_info(alias: &str, info: &ToolInfo) -> Self { Self { @@ -64,9 +70,18 @@ impl ToolBinding { } else { ToolOutputKind::Plain }, + describes_tools: info.describes_tools, + vendor: None, } } + /// Sets the vendor of the tool's Pack, as the caller reported it. + #[must_use] + pub fn with_vendor(mut self, vendor: Option) -> Self { + self.vendor = vendor; + self + } + /// Returns the model-facing name. #[must_use] pub fn alias(&self) -> &str { @@ -97,6 +112,19 @@ impl ToolBinding { pub fn output_kind(&self) -> ToolOutputKind { self.output_kind } + + /// Returns whether the tool's output lists tools. + #[must_use] + pub fn describes_tools(&self) -> bool { + self.describes_tools + } + + /// Returns the vendor of the tool's Pack, as the caller reported it + /// for the run, or `None` when it reported none. + #[must_use] + pub fn vendor(&self) -> Option<&str> { + self.vendor.as_deref() + } } /// Outcome of a Lua block that may invoke `jump`. @@ -227,8 +255,8 @@ impl ToolSet { } /// Returns the offered binding whose wire name or canonical id is - /// `name`, which matches at most one way because wire names and local - /// aliases never contain `/` and ids always do. + /// `name`, which matches at most one way because wire names never + /// contain `/` and ids always do. #[must_use] pub fn offered_binding(&self, name: &str) -> Option<&ToolBinding> { if name.contains('/') { diff --git a/crates/runvil-internal/lua/src/json.rs b/crates/runvil-internal/lua/src/json.rs index 571a0e777..0a367aa03 100644 --- a/crates/runvil-internal/lua/src/json.rs +++ b/crates/runvil-internal/lua/src/json.rs @@ -14,6 +14,8 @@ mod decode; mod encode; mod keys; +pub(crate) use encode::to_json; + use mlua::{Lua, LuaSerdeExt, Table, Value}; use crate::error::{Error, Result}; diff --git a/crates/runvil-internal/lua/src/json/encode.rs b/crates/runvil-internal/lua/src/json/encode.rs index efb4d4d15..5179a3b86 100644 --- a/crates/runvil-internal/lua/src/json/encode.rs +++ b/crates/runvil-internal/lua/src/json/encode.rs @@ -32,12 +32,24 @@ const DEPTH_LIMIT: usize = 127; pub(super) fn encode(lua: &Lua, (value, options): (Value, Value)) -> mlua::Result { const FUNCTION: &str = "json.encode"; let debug = debug_option(&options, FUNCTION)?; - let json = Encoder::new(lua, FUNCTION, debug).value(&value, 1)?; + let json = to_json(lua, FUNCTION, debug, &value)?; let text = serde_json::to_string(&json).map_err(|error| refusal(format!("{FUNCTION}: {error}")))?; lua.create_string(text) } +/// The JSON value `value` converts to under the `json.encode` rules, in +/// debug mode when `debug` is set. Every refusal reads `: ` and +/// then the reason, which names the failing path from `value`. +pub(crate) fn to_json( + lua: &Lua, + prefix: &'static str, + debug: bool, + value: &Value, +) -> mlua::Result { + Encoder::new(lua, prefix, debug).value(value, 1) +} + /// `json.encode_lines(list [, options])`: each item of `list` encoded by /// the `json.encode` rules and followed by a newline. pub(super) fn encode_lines(lua: &Lua, (list, options): (Value, Value)) -> mlua::Result { diff --git a/crates/runvil-internal/lua/src/lib.rs b/crates/runvil-internal/lua/src/lib.rs index d7df4460d..86a7afcf1 100644 --- a/crates/runvil-internal/lua/src/lib.rs +++ b/crates/runvil-internal/lua/src/lib.rs @@ -37,15 +37,13 @@ //! raw-set into `_G`. The `input` table that `user-input` defines is one: //! `input.ask()` is an ordinary tool call to that Pack's ask tool. //! -//! The `tools` table names every catalog tool the run can offer by its -//! canonical id: `tools.required()` and `tools.extras()` list the tool -//! objects of the declared Packs and of every other Pack, and -//! `tools.offer` scopes a tool into a section by id or tool object. The -//! `packs` table holds one object per Pack the run knows, whose -//! `tools` lists that Pack's tool objects, and `packs.offer` scopes -//! every tool of a Pack into a section. Offers work only before the -//! section's first loop round, which renders the section's tool list; -//! after that, every offer function raises. +//! The `tools` table names every tool by its id: `tools.get` reads a tool +//! object, `tools.packs` holds one object per Pack the run knows, the +//! adhoc Pack included, whose `tools` lists that Pack's tool objects, and +//! `tools.adhoc` defines a Lua function as a tool of the adhoc Pack. +//! `tools.offer` and `tools.offer_dynamic` remember tools and Packs, by +//! id or object, for the section's rendered and dynamic lists, which the +//! section's first loop round builds; after that, every offer raises. //! //! `messages` is the section's one conversation: a list the VM creates //! empty at each section entry and serves as a global that no assignment @@ -137,13 +135,15 @@ pub use crate::program::LuaProgram; pub use crate::projection::project_messages; pub use crate::prose::ProseState; pub use crate::protocol::{ - Answer, Blocked, ChatOutcome, ChatResult, ContentPart, LocalToolOutcome, MessageContent, + AdhocToolOutcome, Answer, Blocked, ChatOutcome, ChatResult, ContentPart, MessageContent, MessageRecord, NonEmpty, RecordRole, Request, SpawnVia, TaskDelivery, TaskPhase, TaskStatus, ToolCallOutcome, ToolCallRecord, VfsOp, VfsOutcome, YieldParse, }; -pub use crate::scope::{RenderedTools, ToolCallCounts, ToolRuntime}; +pub use crate::scope::{ + Advertised, Offer, OfferMode, OfferTarget, RenderedTools, ToolCallCounts, ToolRuntime, +}; pub use crate::sys::enrich_sys_model; pub use crate::vm::{ - CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_tool_bindings, - resolve_model_binding, + AdhocTools, CoroStep, InjectFailure, Injected, SectionVm, Uninjected, current_dynamic_tools, + current_tool_bindings, resolve_model_binding, }; diff --git a/crates/runvil-internal/lua/src/messages-tests.rs b/crates/runvil-internal/lua/src/messages-tests.rs index fe357065d..13228350a 100644 --- a/crates/runvil-internal/lua/src/messages-tests.rs +++ b/crates/runvil-internal/lua/src/messages-tests.rs @@ -4,13 +4,14 @@ use std::collections::BTreeMap; use mlua::{Lua, LuaSerdeExt, Value}; +use runvil_types::tools::ToolId; use serde_json::json; use super::MessageList; use crate::protocol::{Request, YieldParse}; use crate::test_vm::TestVm; use crate::tests::{system, user}; -use crate::{Error, RenderedTools}; +use crate::{Advertised, Error, RenderedTools}; #[path = "messages-tests-list.rs"] mod list; @@ -45,14 +46,23 @@ fn messages_is_the_vms_one_empty_list_and_lua_reaches_no_other() { assert_eq!(vm.messages().to_records(), vec![user("u")]); } -/// A rendered tool list advertising each of `names` as a local tool. +/// The advertised entry of the adhoc tool named `name`. +fn adhoc(name: &str) -> Advertised { + Advertised { + id: ToolId::parse(&format!("adhoc/{name}")).expect("a fixture adhoc id is a tool id"), + vendor: None, + } +} + +/// A rendered tool list advertising each of `names` as an adhoc tool. fn rendered(names: &[&str]) -> RenderedTools { RenderedTools { schemas: Vec::new(), dispatch: names .iter() - .map(|name| ((*name).to_owned(), None)) + .map(|name| ((*name).to_owned(), adhoc(name))) .collect(), + dynamic: Vec::new(), } } @@ -69,9 +79,15 @@ fn a_list_is_rendered_only_once_a_tool_list_is_attached_and_keeps_the_first() { .expect("the list holds a rendered tool list"); assert_eq!( kept.dispatch, - BTreeMap::from([("first".to_owned(), None)]), + BTreeMap::from([("first".to_owned(), adhoc("first"))]), "the first attached list stays" ); + assert_eq!( + list.rendered_target("first"), + Some(adhoc("first")), + "the attached list's entry reads by wire name" + ); + assert_eq!(list.rendered_target("second"), None); } #[test] diff --git a/crates/runvil-internal/lua/src/messages.rs b/crates/runvil-internal/lua/src/messages.rs index d80e8eb89..6a41c975b 100644 --- a/crates/runvil-internal/lua/src/messages.rs +++ b/crates/runvil-internal/lua/src/messages.rs @@ -41,7 +41,7 @@ use serde_json::{Map, Value as Json}; use crate::error::{Error, Result}; use crate::protocol::{MessageRecord, RecordRole, parse_record}; -use crate::scope::RenderedTools; +use crate::scope::{Advertised, RenderedTools}; #[path = "messages-snapshot.rs"] mod snapshot; @@ -217,6 +217,14 @@ impl MessageList { self.state().rendered.clone() } + /// The attached rendered list's entry for `wire_name`, if a list is + /// attached and advertises that name. + #[must_use] + pub(crate) fn rendered_target(&self, wire_name: &str) -> Option { + let state = self.state(); + state.rendered.as_ref()?.dispatch.get(wire_name).cloned() + } + /// Attaches `tools` as the section's rendered tool list. A list that /// already holds one keeps it. /// diff --git a/crates/runvil-internal/lua/src/models_loop.rs b/crates/runvil-internal/lua/src/models_loop.rs index 2fe543804..545d210b0 100644 --- a/crates/runvil-internal/lua/src/models_loop.rs +++ b/crates/runvil-internal/lua/src/models_loop.rs @@ -11,7 +11,7 @@ //! each step returns to the trampoline with one action, named by its tag: //! `"yield"` a request and //! pass every resume value back; call a `"handler"` between -//! `enter_local_handler()` and `leave_local_handler()`, or the +//! `enter_adhoc_handler()` and `leave_adhoc_handler()`, or the //! `"compactor"`, under the raw `pcall` and pass `ok` and the first //! result back; `"raise"` a value with `error(value, 0)`; or `"return"` //! nil. This adapter reads the values the trampoline passes into the @@ -304,7 +304,7 @@ fn read_input( Phase::Calling => { let handler = next(); if ok && !handler.is_nil() { - return Ok(Input::Local { + return Ok(Input::Adhoc { handler, args: next(), }); @@ -376,7 +376,7 @@ fn act(lua: &Lua, then: Then) -> mlua::Result { Then::Report(returned) => { let ok = Value::Boolean(returned.is_some()); let fields = [("ok", ok), ("value", returned.unwrap_or(Value::Nil))]; - ("yield", vec![request(lua, "local_tool_done", fields)?]) + ("yield", vec![request(lua, "adhoc_tool_done", fields)?]) } Then::Return => ("return", Vec::new()), Then::Raise(value) => ("raise", vec![value]), diff --git a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs index f159b7fac..f48c4fc4d 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine-tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine-tests.rs @@ -311,7 +311,7 @@ fn a_failed_answer_raises_its_value_unchanged() { ); } -/// A machine waiting on a local call's handler, over `list`. +/// A machine waiting on an adhoc call's handler, over `list`. fn handling(list: &MessageList) -> Machine { let mut machine = chatting(2, list); machine.step(batch(&["c1"])); @@ -319,16 +319,16 @@ fn handling(list: &MessageList) -> Machine { show(&machine.step(batched(&[]))), r#"tool_call c1 echo {"id":"c1"}@7"# ); - let local = Input::Local { + let adhoc = Input::Adhoc { handler: "handler", args: "args", }; - assert_eq!(show(&machine.step(local)), "handle handler args"); + assert_eq!(show(&machine.step(adhoc)), "handle handler args"); machine } #[test] -fn a_local_call_reports_its_outcome_then_raises_the_handlers_value_first() { +fn an_adhoc_call_reports_its_outcome_then_raises_the_handlers_value_first() { let list = MessageList::default(); let mut machine = handling(&list); assert_eq!( diff --git a/crates/runvil-internal/lua/src/models_loop/machine.rs b/crates/runvil-internal/lua/src/models_loop/machine.rs index b748cc15b..4d65ba638 100644 --- a/crates/runvil-internal/lua/src/models_loop/machine.rs +++ b/crates/runvil-internal/lua/src/models_loop/machine.rs @@ -1,6 +1,6 @@ //! The rules of `models.loop` over typed inputs, with no Lua VM: the -//! round cap, the order a round's answer is judged in, the batch rule, a -//! local tool's report and re-raise, and compaction after an overflow. +//! round cap, the order a round's answer is judged in, the batch rule, an +//! adhoc tool's report and re-raise, and compaction after an overflow. //! The machine pushes every record onto the list itself, and every Lua //! value it only passes along is an opaque `V`, so the unit tests run over //! a plain `MessageList` with any `V`. @@ -93,7 +93,7 @@ pub(super) enum Phase { Calling, /// The handler's raw `pcall`: `(ok, value)`. Handling, - /// The `local_tool_done` answer: `(ok, text)`, with the handler's own + /// The `adhoc_tool_done` answer: `(ok, text)`, with the handler's own /// raised value when it raised. Reporting(Option), /// The loop returned or raised. @@ -111,15 +111,15 @@ pub(super) enum Input { Batched(std::result::Result, V>), /// A bound tool's text, or the value to raise. Dispatched(std::result::Result), - /// A local tool's handler and its arguments table. - Local { handler: V, args: V }, + /// An adhoc tool's handler and its arguments table. + Adhoc { handler: V, args: V }, /// The compactor's or the handler's raw `pcall`: its first returned /// or raised value, and whether the run's cancel flag was set. Called { outcome: std::result::Result, cancelled: bool, }, - /// The `local_tool_done` answer: the call's text, or the value to + /// The `adhoc_tool_done` answer: the call's text, or the value to /// raise. Reported(std::result::Result), } @@ -137,7 +137,7 @@ pub(super) enum Then { }, /// Yield `{ op = "tool_call", alias = call.name, args = call.arguments, /// call_id = call.id, turn = turn }`. - ToolCall { call: ToolCallEvent, turn: u32 }, + ToolCall { call: Box, turn: u32 }, /// Call the compactor with the reason's tag. Compact { compactor: V, @@ -145,8 +145,8 @@ pub(super) enum Then { }, /// Call the handler with its arguments table. Handle { handler: V, args: V }, - /// Yield `{ op = "local_tool_done", ok = true, value = value }` when - /// the handler returned, or `{ op = "local_tool_done", ok = false }` + /// Yield `{ op = "adhoc_tool_done", ok = true, value = value }` when + /// the handler returned, or `{ op = "adhoc_tool_done", ok = false }` /// when it raised. Report(Option), /// Return nil. @@ -226,7 +226,7 @@ impl Machine { self.results.push(text); self.next_call() } - (Phase::Calling, Input::Local { handler, args }) => { + (Phase::Calling, Input::Adhoc { handler, args }) => { self.phase = Phase::Handling; Then::Handle { handler, args } } @@ -338,7 +338,7 @@ impl Machine { Some(call) if short => { self.phase = Phase::Calling; Then::ToolCall { - call: call.clone(), + call: Box::new(call.clone()), turn: self.turn, } } diff --git a/crates/runvil-internal/lua/src/models_loop/tests.rs b/crates/runvil-internal/lua/src/models_loop/tests.rs index 8c71b320e..d55ab80ff 100644 --- a/crates/runvil-internal/lua/src/models_loop/tests.rs +++ b/crates/runvil-internal/lua/src/models_loop/tests.rs @@ -19,7 +19,7 @@ use crate::error_value::{ErrorField, ErrorKind, Raised, error_table, raised_from use crate::hardening::InstructionBudget; use crate::messages::MessageList; use crate::models::LuaModelHandle; -use crate::protocol::{Answer, ChatOutcome, ChatResult, LocalToolOutcome, Request, YieldParse}; +use crate::protocol::{AdhocToolOutcome, Answer, ChatOutcome, ChatResult, Request, YieldParse}; /// A requested call named `echo`, its arguments naming its id. pub(super) fn echo_call(id: &str) -> ToolCallEvent { @@ -339,7 +339,7 @@ fn parsed(lua: &Lua, then: Then) -> Request { fn act_yields_each_request_as_the_protocol_parses_it() { let lua = Lua::new(); let tool_call = Then::ToolCall { - call: echo_call("c1"), + call: Box::new(echo_call("c1")), turn: 7, }; let Request::ToolCall { @@ -358,14 +358,14 @@ fn act_yields_each_request_as_the_protocol_parses_it() { assert_eq!(args, json!({ "id": "c1" })); let returned = Value::String(lua.create_string("text").unwrap()); let outcome = |then| match parsed(&lua, then) { - Request::LocalToolDone { outcome } => outcome, - other => panic!("a local_tool_done request, got {other:?}"), + Request::AdhocToolDone { outcome } => outcome, + other => panic!("an adhoc_tool_done request, got {other:?}"), }; let done = outcome(Then::Report(Some(returned))); - assert!(matches!(done, LocalToolOutcome::Returned(text) if text == "text")); + assert!(matches!(done, AdhocToolOutcome::Returned(text) if text == "text")); assert!(matches!( outcome(Then::Report(None)), - LocalToolOutcome::Raised + AdhocToolOutcome::Raised )); let list = Value::UserData(lua.create_userdata(MessageList::default()).unwrap()); let receiver = handle(&lua); diff --git a/crates/runvil-internal/lua/src/packs-tests.rs b/crates/runvil-internal/lua/src/packs-tests.rs index 201360b73..7c802cbf9 100644 --- a/crates/runvil-internal/lua/src/packs-tests.rs +++ b/crates/runvil-internal/lua/src/packs-tests.rs @@ -1,68 +1,15 @@ -//! Tests for the `packs` table: the `required` and `extras` lists, -//! `packs.get`, the shared Pack objects and their `tools`, a tool -//! object's `pack` getter, and the roster's usable and unavailable +//! Tests for the Pack objects and `tools.packs`: the walk in name order, +//! `get` and `extras`, the frozen proxy, `required`, shared objects, names +//! that collide with members, and the roster's usable and unavailable //! Packs. use std::collections::BTreeMap; use runvil_types::packs::PackId; +use crate::ToolBinding; use crate::handles::ToolSet; -use crate::test_vm::{lua_over, scoped, tool_at}; -use crate::{SectionVm, ToolBinding}; - -/// A set declaring `web` and then `mail`, which offers no tool, beside the -/// undeclared Packs `alpha` and `gh`, every tool in id order under its -/// wire name. -fn pack_set() -> ToolSet { - let bind = |id: &str| ToolBinding::from_info(&id.replace(['/', '.'], "_"), &tool_at(id)); - ToolSet::new( - vec![ - PackId::parse("web").expect("`web` is a valid Pack id"), - PackId::parse("mail").expect("`mail` is a valid Pack id"), - ], - BTreeMap::new(), - vec![ - bind("alpha/zed"), - bind("gh/issues.list"), - bind("gh/search"), - bind("web/fetch"), - bind("web/search"), - ], - ) -} - -/// [`pack_set`] with a roster: `web` and `gh`, which also offer tools, -/// and `subagents`, which offers none, can serve the run, and `mcp` -/// can't. -fn roster_set() -> ToolSet { - let pack = |name: &str| PackId::parse(name).expect("each roster name is a valid Pack id"); - let base = pack_set(); - ToolSet::new( - base.declared().to_vec(), - [ - (pack("gh"), None), - (pack("mcp"), Some("the mcp server is down".to_owned())), - (pack("subagents"), None), - (pack("web"), None), - ] - .into_iter() - .collect(), - base.offered().to_vec(), - ) -} - -/// The Packs `packs.offer` recorded on `vm`, by name. -#[track_caller] -fn offered_packs(vm: &SectionVm) -> Vec { - vm.tool_runtime() - .lock() - .expect("the runtime mutex is not poisoned") - .packs - .iter() - .map(ToString::to_string) - .collect() -} +use crate::test_vm::{lua_over, offer_set, offers, raised, scoped, tool_at}; /// A Lua helper joining the `name` of each Pack object in a list. const NAMES: &str = "local function names(list)\n\ @@ -71,84 +18,187 @@ const NAMES: &str = "local function names(list)\n\ return table.concat(out, ',')\n\ end\n"; +/// A set declaring `mail`, which offers no tool, beside the undeclared +/// Packs `get`, `extras`, and `tools`, whose names a member of +/// `tools.packs` or a Pack object also has. +fn colliding_set() -> ToolSet { + let bind = |id: &str| ToolBinding::from_info(&id.replace(['/', '.'], "_"), &tool_at(id)); + ToolSet::new( + vec![PackId::parse("mail").expect("`mail` is a valid Pack id")], + BTreeMap::new(), + vec![bind("extras/list"), bind("get/one"), bind("tools/tools")], + ) +} + #[test] -fn required_lists_every_declared_pack_in_name_order_and_extras_the_rest() { - let vm = lua_over(pack_set()); - let result: (String, String, i64, String, bool, bool, String) = vm.eval(&format!( +fn tools_packs_walks_every_pack_object_in_name_order_the_adhoc_pack_included() { + let vm = lua_over(offer_set()); + let walked: (String, String, String, i64, bool) = vm.eval(&format!( "{NAMES}\ - local ok, err = pcall(packs.get, 42)\n\ - assert(not ok, 'a non-string is refused')\n\ - return names(packs.required()), names(packs.extras()), \ - #packs.get('mail').tools, packs.get('gh').name, \ - packs.get('missing') == nil, packs.get('web/fetch') == nil, \ - tostring(err)" + local indexed = {{}}\n\ + for i = 1, #tools.packs do indexed[i] = tools.packs[i] end\n\ + local paired, keys = {{}}, {{}}\n\ + for key, pack in pairs(tools.packs) do\n\ + keys[#keys + 1] = type(key)\n\ + paired[#paired + 1] = pack\n\ + end\n\ + return names(tools.packs), names(indexed), names(paired), #tools.packs, \ + table.concat(keys, ',') == 'number,number,number,number,number'" )); + let order = "adhoc,gh,subagents,user-input,web"; assert_eq!( - result.0, "mail,web", - "every declared Pack is required, in name order" + walked, + ( + order.to_owned(), + order.to_owned(), + order.to_owned(), + 5, + true + ), + "ipairs, integer keys, and pairs walk the Pack objects in name order, and \ + pairs yields only Packs" + ); +} + +#[test] +fn get_returns_a_shared_object_or_nil_and_refuses_anything_but_one_pack_id() { + let vm = lua_over(offer_set()); + let read: (bool, bool, bool, bool, bool) = vm.eval( + "local web = tools.packs.get('web')\n\ + return web == tools.packs.get('web'), web == tools.packs[5], \ + tools.get('web/search').pack == web, tools.packs.get('missing') == nil, \ + tools.packs.get('web/search') == nil", ); assert_eq!( - result.1, "alpha,gh", - "every other Pack with offered tools is an extra, in name order" + read, + (true, true, true, true, true), + "one object per Pack across get, integer keys, and tool.pack; nil for an \ + unknown name and a tool id" ); - assert_eq!(result.2, 0, "a declared Pack with no tool has an object"); - assert_eq!(result.3, "gh", "packs.get reads an extra by name"); - assert!( - result.4 && result.5, - "an unknown name and a tool id read nil: {result:?}" + for (call, kind) in [ + ("tools.packs.get(42)", "integer"), + ("tools.packs.get({ 'web', 'subagents' })", "table"), + ("tools.packs.get()", "nil"), + ] { + let error = raised(&vm, call); + assert!( + error.contains(&format!("tools.packs.get takes one Pack id, got {kind}")), + "{call}: {error}" + ); + } +} + +#[test] +fn tools_packs_is_a_frozen_proxy_whose_other_keys_read_nil() { + let vm = lua_over(offer_set()); + let reads: (bool, bool, bool, String) = vm.eval( + "return tools.packs['web'] == nil, tools.packs['name'] == nil, tools.packs[99] == nil, \ + tostring(getmetatable(tools.packs))", ); + assert_eq!( + reads, + (true, true, true, "tools.packs is read-only".to_owned()) + ); + for write in [ + "tools.packs.get = nil", + "tools.packs[1] = 'web'", + "tools.packs['web'] = 1", + ] { + let error = raised(&vm, write); + assert!( + error.contains("tools.packs is read-only"), + "{write}: {error}" + ); + } + let error = raised(&vm, "setmetatable(tools.packs, nil)"); assert!( - result.6.contains("packs.get takes a Pack id, got integer"), - "the refusal names the type: {}", - result.6 + error.contains("cannot change a protected metatable"), + "the metatable is protected: {error}" ); + let still: String = vm.eval("return tools.packs.get('web').name"); + assert_eq!(still, "web"); } #[test] -fn packs_get_returns_the_one_object_required_and_extras_hand_out() { - let vm = lua_over(pack_set()); - let result: (bool, bool, bool, bool, bool) = vm.eval( - "local required = packs.required()\n\ - return packs.get('web') == packs.get('web'), \ - required[2] == packs.get('web'), \ - packs.extras()[2] == packs.get('gh'), \ - packs.required() ~= required, \ - packs.extras() ~= packs.extras()", - ); +fn extras_are_the_usable_undeclared_packs_in_a_fresh_array() { + let vm = lua_over(offer_set()); + let result: (String, bool, bool) = vm.eval(&format!( + "{NAMES}\ + local extras = tools.packs.extras()\n\ + return names(extras), extras ~= tools.packs.extras(), \ + extras[1] == tools.packs.get('gh')" + )); assert_eq!( result, - (true, true, true, true, true), - "one object per Pack, handed out in a fresh array per list call" + ("gh,subagents".to_owned(), true, true), + "never a declared Pack, the adhoc Pack, or a Pack that cannot serve" ); } #[test] -fn a_packs_tools_are_the_shared_tool_objects_in_a_fresh_array_that_offer_takes() { - let vm = lua_over(pack_set()); - let result: (i64, bool, bool, bool) = vm.eval( - "local web = packs.get('web')\n\ - local tools_list = web.tools\n\ - tools.offer(web.tools)\n\ - return #tools_list, tools_list[1] == tools.get('web/fetch'), \ - tools_list[2] == tools.get('web/search'), web.tools ~= tools_list", +fn required_is_true_only_for_a_declared_pack() { + let vm = lua_over(colliding_set()); + let required: (bool, bool, bool, bool, bool) = vm.eval( + "return tools.packs.get('mail').required, tools.packs.get('tools').required, \ + tools.packs.get('adhoc').required, #tools.packs.get('tools').tools > 0, \ + #tools.packs.get('mail').tools > 0", ); assert_eq!( - result, - (2, true, true, true), - "a Pack's tools are its shared tool objects, in id order, in a new array per read" + required, + (true, false, false, true, false), + "a declared Pack is required even with no tools, and #pack.tools tells \ + whether it has tools" + ); +} + +#[test] +fn packs_whose_names_collide_with_members_are_reached_through_get_and_offered_by_id() { + let vm = lua_over(colliding_set()); + let reads: (String, String, String, String, bool, bool) = vm.eval( + "return tools.packs.get('get').name, tools.packs.get('extras').name, \ + tools.packs.get('tools').name, tools.packs.get('tools').tools[1].id, \ + type(tools.packs.get) == 'function', type(tools.packs.extras) == 'function'", ); assert_eq!( - scoped(&vm), - ["web_fetch", "web_search"], - "tools.offer takes a Pack's tools array" + reads, + ( + "get".to_owned(), + "extras".to_owned(), + "tools".to_owned(), + "tools/tools".to_owned(), + true, + true + ) + ); + vm.exec("tools.offer({ 'get', 'extras', 'tools' })"); + assert_eq!(offers(&vm), ["static:get", "static:extras", "static:tools"]); + assert_eq!(scoped(&vm), ["get_one", "extras_list", "tools_tools"]); +} + +#[test] +fn pack_and_tool_objects_hold_no_field_beyond_their_own() { + let vm = lua_over(colliding_set()); + let extra: Option = vm.eval( + "local probes = {\n\ + { tools.packs.get('tools'), { 'get', 'extras', 'id', 'vendor', 'pack' } },\n\ + { tools.get('tools/tools'), { 'tools', 'name', 'required', 'vendor', 'alias' } },\n\ + }\n\ + for _, probe in ipairs(probes) do\n\ + for _, field in ipairs(probe[2]) do\n\ + local ok, value = pcall(function() return probe[1][field] end)\n\ + if ok and value ~= nil then return field end\n\ + end\n\ + end\n\ + return nil", ); + assert_eq!(extra, None); } #[test] fn assigning_a_field_on_a_pack_object_is_refused() { - let vm = lua_over(pack_set()); + let vm = lua_over(offer_set()); let (name, extra, read): (String, String, String) = vm.eval( - "local web = packs.get('web')\n\ + "local web = tools.packs.get('web')\n\ local _, name = pcall(function() web.name = 'x' end)\n\ local _, extra = pcall(function() web.extra = 1 end)\n\ return tostring(name), tostring(extra), web.name", @@ -165,136 +215,55 @@ fn assigning_a_field_on_a_pack_object_is_refused() { } #[test] -fn every_tool_objects_pack_is_its_packs_object() { - let vm = lua_over(pack_set()); - let mismatched: String = vm.eval( - "local bad = {}\n\ - for _, list in ipairs({ tools.required(), tools.extras() }) do\n\ - for _, tool in ipairs(list) do\n\ - local name = tool.id:match('^[^/]+')\n\ - if tool.pack ~= packs.get(name) or tool.pack.name ~= name then\n\ - bad[#bad + 1] = tool.id\n\ - end\n\ - end\n\ - end\n\ - assert(#tools.required() + #tools.extras() == 5, 'every tool is walked')\n\ - return table.concat(bad, ',')", +fn a_packs_tools_are_the_shared_tool_objects_in_a_fresh_array_that_offer_takes() { + let vm = lua_over(offer_set()); + let result: (i64, bool, bool, bool) = vm.eval( + "local web = tools.packs.get('web')\n\ + local tools_list = web.tools\n\ + tools.offer(web.tools)\n\ + return #tools_list, tools_list[1] == tools.get('web/fetch'), \ + tools_list[2] == tools.get('web/search'), web.tools ~= tools_list", ); assert_eq!( - mismatched, "", - "each tool object's pack is == to its Pack's object" - ); -} - -#[test] -fn packs_is_the_engine_global_and_the_old_plugins_global_is_nil() { - let vm = lua_over(pack_set()); - let (installed, gone, error): (bool, bool, String) = vm.eval( - "local ok, err = pcall(function() return plugins['get']('web') end)\n\ - assert(not ok, 'the old global is not installed')\n\ - return type(packs) == 'table', plugins == nil, tostring(err)", - ); - assert!(installed, "the packs table is installed"); - assert!(gone, "no plugins global is installed"); - assert!( - error.contains("attempt to index a nil value"), - "a script still calling plugins.* fails on the nil global: {error}" - ); -} - -#[test] -fn a_tool_objects_pack_is_its_packs_object_and_offering_the_pack_scopes_its_tools() { - let vm = lua_over(pack_set()); - let same: bool = vm.eval( - "packs.offer('web')\n\ - return tools.get('web/search').pack == packs.get('web')", + result, + (2, true, true, true), + "a Pack's tools are its shared tool objects, in id order, in a new array per read" ); - assert!(same, "a tool object's pack is its Pack's object"); assert_eq!( scoped(&vm), ["web_fetch", "web_search"], - "offering a Pack scopes each of its tools" + "tools.offer takes a Pack's tools array" ); } #[test] -fn offer_records_each_pack_once_from_a_name_an_object_or_a_list() { - let vm = lua_over(pack_set()); - vm.exec( - "packs.offer('gh')\n\ - packs.offer(packs.get('alpha'))\n\ - packs.offer({ 'gh', packs.get('web') })\n\ - packs.offer(packs.required())\n\ - packs.offer({})\n\ - assert(select('#', packs.offer('gh')) == 0, 'the verb returns nothing')", +fn every_tool_objects_pack_is_its_packs_object() { + let vm = lua_over(offer_set()); + let mismatched: String = vm.eval( + "local bad, walked = {}, 0\n\ + for _, pack in ipairs(tools.packs) do\n\ + for _, tool in ipairs(pack.tools) do\n\ + walked = walked + 1\n\ + if tool.pack ~= pack or tool.pack.name ~= tool.id:match('^[^/]+') then\n\ + bad[#bad + 1] = tool.id\n\ + end\n\ + end\n\ + end\n\ + assert(walked == 5, 'every tool is walked')\n\ + return table.concat(bad, ',')", ); assert_eq!( - offered_packs(&vm), - ["gh", "alpha", "web", "mail"], - "each Pack is recorded once, in first-offer order" - ); - assert!( - vm.tool_runtime() - .lock() - .expect("the runtime mutex is not poisoned") - .added - .is_empty(), - "a Pack offer never records a wire name" - ); -} - -#[test] -fn offer_refuses_a_wrong_or_extra_argument_and_an_absent_pack_and_records_nothing() { - let vm = lua_over(pack_set()); - let shape = "packs.offer takes a Pack id, a Pack object, or a list of them, got"; - let absent = "packs.offer: no Pack named \"mcp\" in this run; declare it under \ - uses: to require it, or offer it only when packs.get(\"mcp\") returns it"; - for (call, expected) in [ - ("packs.offer(nil)", format!("{shape} nil")), - ("packs.offer()", format!("{shape} nil")), - ("packs.offer(42)", format!("{shape} integer")), - ( - "packs.offer(tools.get('web/fetch'))", - format!("{shape} userdata"), - ), - ("packs.offer({ 'web', 42 })", format!("{shape} integer")), - ( - "packs.offer('web/fetch')", - format!( - "{shape} \"web/fetch\": {}", - PackId::parse("web/fetch").expect_err("a tool id is not a Pack id") - ), - ), - ( - "packs.offer('web', 'gh')", - "packs.offer takes one Pack or a list of them, got extra string".to_owned(), - ), - ("packs.offer('mcp')", absent.to_owned()), - ("packs.offer({ 'web', 'mcp' })", absent.to_owned()), - ] { - let error: String = vm - .lua() - .load(format!( - "local ok, err = pcall(function() {call} end)\n\ - assert(not ok, 'the call is refused')\n\ - return tostring(err)" - )) - .eval() - .unwrap_or_else(|error| panic!("{call}: {error}")); - assert!(error.contains(&expected), "{call}: {error}"); - } - assert!( - offered_packs(&vm).is_empty(), - "a refused call records no Pack" + mismatched, "", + "each tool object's pack is == to its Pack's object" ); } #[test] -fn the_scope_lists_section_tools_then_each_offered_packs_tools_each_name_once() { - let vm = lua_over(pack_set()); +fn the_render_lists_each_offer_in_call_order_each_tool_once() { + let vm = lua_over(offer_set()); vm.exec( "tools.offer('web/search')\n\ - packs.offer({ 'gh', 'web', 'alpha' })", + tools.offer({ 'gh', 'web', 'user-input' })", ); let before = scoped(&vm); assert_eq!( @@ -304,35 +273,24 @@ fn the_scope_lists_section_tools_then_each_offered_packs_tools_each_name_once() "gh_issues_list", "gh_search", "web_fetch", - "alpha_zed" + "user-input_ask" ], - "section tools first, then Packs in offer order with tools in id order, \ - each name at its first place" + "each offer in call order, a Pack's tools in id order, each tool at its first place" ); - vm.exec("packs.offer('mail')"); + vm.exec("tools.offer('subagents')"); assert_eq!(scoped(&vm), before, "a Pack with no tools adds nothing"); } #[test] -fn a_usable_roster_pack_with_no_tools_has_an_object_and_is_an_extra_in_name_order() { - let vm = lua_over(roster_set()); - let result: (String, String, String, i64) = vm.eval(&format!( - "{NAMES}\ - packs.offer('subagents')\n\ - local stub = packs.get('subagents')\n\ - return names(packs.required()), names(packs.extras()), stub.name, #stub.tools" - )); - assert_eq!( - result, - ( - "mail,web".to_owned(), - "alpha,gh,subagents".to_owned(), - "subagents".to_owned(), - 0 - ), - "a usable Pack with no tools is an extra once, beside the ones with tools" +fn a_usable_roster_pack_with_no_tools_has_an_object_and_is_an_extra() { + let vm = lua_over(offer_set()); + let result: (String, i64, bool) = vm.eval( + "tools.offer('subagents')\n\ + local stub = tools.packs.get('subagents')\n\ + return stub.name, #stub.tools, stub.required", ); - assert_eq!(offered_packs(&vm), ["subagents"]); + assert_eq!(result, ("subagents".to_owned(), 0, false)); + assert_eq!(offers(&vm), ["static:subagents"]); assert!( scoped(&vm).is_empty(), "offering a Pack with no tools adds nothing to a round" @@ -340,34 +298,15 @@ fn a_usable_roster_pack_with_no_tools_has_an_object_and_is_an_extra_in_name_orde } #[test] -fn an_unavailable_roster_pack_reads_nil_stays_out_of_the_lists_and_offer_raises_its_reason() { - let vm = lua_over(roster_set()); - let listed: (bool, String, String) = vm.eval(&format!( - "{NAMES}\ - return packs.get('mcp') == nil, names(packs.required()), \ - names(packs.extras())" - )); - assert_eq!( - listed, - (true, "mail,web".to_owned(), "alpha,gh,subagents".to_owned()), - "an unavailable Pack has no object and is in neither list" - ); - let unavailable = "packs.offer: the mcp Pack is unavailable in this run: \ +fn an_unavailable_roster_pack_has_no_object_and_offering_it_raises_its_reason() { + let vm = lua_over(offer_set()); + let missing: bool = vm.eval("return tools.packs.get('mcp') == nil"); + assert!(missing, "an unavailable Pack has no object"); + let unavailable = "tools.offer: the mcp Pack is unavailable in this run: \ the mcp server is down"; - for call in ["packs.offer('mcp')", "packs.offer({ 'web', 'mcp' })"] { - let error: String = vm - .lua() - .load(format!( - "local ok, err = pcall(function() {call} end)\n\ - assert(not ok, 'the call is refused')\n\ - return tostring(err)" - )) - .eval() - .unwrap_or_else(|error| panic!("{call}: {error}")); + for call in ["tools.offer('mcp')", "tools.offer({ 'web', 'mcp' })"] { + let error = raised(&vm, call); assert!(error.contains(unavailable), "{call}: {error}"); } - assert!( - offered_packs(&vm).is_empty(), - "a refused call records no Pack" - ); + assert!(offers(&vm).is_empty(), "a refused call records no Pack"); } diff --git a/crates/runvil-internal/lua/src/packs.rs b/crates/runvil-internal/lua/src/packs.rs index 587a1ffcc..b215f1e1f 100644 --- a/crates/runvil-internal/lua/src/packs.rs +++ b/crates/runvil-internal/lua/src/packs.rs @@ -1,50 +1,93 @@ -//! The `packs` table: one read-only object per Pack the run knows. +//! Pack objects and `tools.packs`: one read-only object per Pack the run +//! knows, and the adhoc Pack every section VM holds. //! //! A Pack the frontmatter declares and a Pack the roster lists as able //! to serve have an object even when they offer no tool, and every other //! Pack has one when it offers a tool. A Pack the roster lists as -//! unable to serve has none, and offering it raises its reason. The objects -//! are built once per VM, after the tool objects, so `packs.required()`, -//! `packs.extras()`, `packs.get`, and a tool object's `pack` getter -//! hand out the same userdata for a Pack, while each list call builds a -//! fresh array. A Pack object's `tools` reads the shared tool objects, -//! so it passes straight to `tools.offer`. `packs.offer` puts every tool -//! of a Pack in the section's scope, recording the Pack's name rather -//! than its tools' wire names, until the section's first loop round -//! renders its tool list. +//! unable to serve has none, and offering it raises its reason. The adhoc +//! Pack, `adhoc`, has an object in every VM; its `tools` lists the adhoc +//! tools defined so far. The objects are built once per VM, after the tool +//! objects, so `tools.packs.get`, the integer keys of `tools.packs`, +//! `tools.packs.extras()`, and a tool object's `pack` getter hand out the +//! same userdata for a Pack, while each list read builds a fresh array. A +//! Pack object's `tools` reads the shared tool objects, so it passes +//! straight to `tools.offer`. +//! +//! `tools.packs` is an empty proxy with a protected metatable: integer +//! keys, `#`, and `pairs` walk the Pack objects in name order, `get` and +//! `extras` are its only other members, and every write raises. use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; use mlua::{ - AnyUserData, Lua, MetaMethod, Table, UserData, UserDataFields, UserDataMethods, Value, Variadic, + AnyUserData, Lua, MetaMethod, MultiValue, Table, UserData, UserDataFields, UserDataMethods, + Value, }; use runvil_types::packs::PackId; use crate::error::{Error, Result}; use crate::handles::ToolSet; -use crate::messages::MessageList; -use crate::scope::ToolRuntime; -use crate::tools::{refuse_after_render, tool_object}; +use crate::tools::tool_object; +use crate::vm::AdhocTools; + +/// The id of the adhoc Pack, which holds every tool `tools.adhoc` defines. +pub(crate) const ADHOC_PACK: &str = "adhoc"; /// The registry key of the VM's Pack-object table, keyed by name. const PACK_OBJECTS_REGISTRY: &str = "runvil.packs.objects"; -/// One Pack as Lua sees it: `name`, and `tools`, which reads a fresh -/// array of the Pack's tool objects in id order. -#[derive(Debug, Clone, PartialEq, Eq)] +/// The label `getmetatable(tools.packs)` returns, which also protects the +/// metatable from `setmetatable`. +const PACKS_LABEL: &str = "tools.packs is read-only"; + +/// Whether `pack` is the adhoc Pack. +pub(crate) fn is_adhoc(pack: &PackId) -> bool { + pack.to_string() == ADHOC_PACK +} + +/// Where a Pack object's `tools` come from. +#[derive(Debug, Clone)] +enum PackTools { + /// A catalog Pack's offered tools, by id, in id order. + Catalog(Vec), + /// The VM's adhoc tools, read on each `tools` read. + Adhoc(AdhocTools), +} + +/// One Pack as Lua sees it: `name`; `tools`, which reads a fresh array of +/// the Pack's tool objects in id order; and `required`, whether the +/// frontmatter declares it under `uses:`. +#[derive(Debug, Clone)] pub(crate) struct LuaPackHandle { name: PackId, - /// The ids of the Pack's offered tools, in id order. - tools: Vec, + required: bool, + tools: PackTools, +} + +impl LuaPackHandle { + /// The Pack's id, which the object stands for. + #[must_use] + pub(crate) fn name(&self) -> &PackId { + &self.name + } } impl UserData for LuaPackHandle { fn add_fields>(fields: &mut F) { fields.add_field_method_get("name", |_, this| Ok(this.name.to_string())); + fields.add_field_method_get("required", |_, this| Ok(this.required)); fields.add_field_method_get("tools", |lua, this| { + let ids = match &this.tools { + PackTools::Catalog(ids) => ids.clone(), + PackTools::Adhoc(adhoc) => adhoc + .bindings() + .map_err(mlua::Error::external)? + .iter() + .map(|binding| binding.id().to_string()) + .collect(), + }; let objects = lua.create_table()?; - for id in &this.tools { + for id in &ids { if let Some(object) = tool_object(lua, id)? { objects.raw_push(object)?; } @@ -65,7 +108,7 @@ impl UserData for LuaPackHandle { } } -/// The VM's Pack object for `name`, read by `packs.get` and by a tool +/// The VM's Pack object for `name`, read by `tools.packs` and by a tool /// object's `pack` getter, or `None` for a Pack the run does not know. /// /// # Errors @@ -81,161 +124,146 @@ pub(crate) fn pack_object(lua: &Lua, name: &str) -> mlua::Result, + extras: Vec, +} + +/// Builds one Pack object per declared Pack, per usable roster Pack, per +/// other Pack with offered tools, and the adhoc Pack over `adhoc`, and +/// returns their names. /// /// # Errors -/// Returns [`Error::LuaRuntime`] if an object or function cannot be created or installed. -pub(crate) fn install_packs( +/// Returns [`Error::LuaRuntime`] if an object cannot be created or stored. +pub(crate) fn install_pack_objects( lua: &Lua, - globals: &Table, - set: &Arc, - runtime: &Arc>, - list: &MessageList, -) -> Result<()> { + set: &ToolSet, + adhoc: &AdhocTools, +) -> Result { let objects = lua.create_table().map_err(Error::lua)?; - let (mut required, mut extras) = (Vec::new(), Vec::new()); - for (name, tools) in pack_tools(set) { + let mut names = PackNames { + all: Vec::new(), + extras: Vec::new(), + }; + for (name, tools) in pack_tools(set, adhoc) { let key = name.to_string(); - if set.declared().contains(&name) { - required.push(key.clone()); - } else { - extras.push(key.clone()); + let required = set.declared().contains(&name); + if !required && !matches!(tools, PackTools::Adhoc(_)) { + names.extras.push(key.clone()); } + names.all.push(key.clone()); let object = lua - .create_userdata(LuaPackHandle { name, tools }) + .create_userdata(LuaPackHandle { + name, + required, + tools, + }) .map_err(Error::lua)?; objects.raw_set(key, object).map_err(Error::lua)?; } lua.set_named_registry_value(PACK_OBJECTS_REGISTRY, objects) .map_err(Error::lua)?; + Ok(names) +} - let table = lua.create_table().map_err(Error::lua)?; - for (function, names) in [("required", required), ("extras", extras)] { - let list = lua - .create_function(move |lua, ()| { - let objects = lua.create_table()?; - for name in &names { - if let Some(object) = pack_object(lua, name)? { - objects.raw_push(object)?; - } - } - Ok(objects) - }) - .map_err(Error::lua)?; - table.set(function, list).map_err(Error::lua)?; - } +/// Installs `tools.packs` on `tools` over the Pack objects `names` lists. +/// +/// # Errors +/// Returns [`Error::LuaRuntime`] if a function or table cannot be created +/// or installed. +pub(crate) fn install_pack_list(lua: &Lua, tools: &Table, names: PackNames) -> Result<()> { + let PackNames { all, extras } = names; let get = lua .create_function(|lua, name: Value| match name { Value::String(name) => pack_object(lua, &name.to_str()?), other => Err(mlua::Error::external(format!( - "packs.get takes a Pack id, got {}", + "tools.packs.get takes one Pack id, got {}", other.type_name() ))), }) .map_err(Error::lua)?; - table.set("get", get).map_err(Error::lua)?; - install_offer(lua, &table, set, runtime, list)?; - globals.raw_set("packs", table).map_err(Error::lua) -} + let extras = lua + .create_function(move |lua, ()| pack_array(lua, &extras)) + .map_err(Error::lua)?; -/// Installs `packs.offer(pack)` and its array form: each Pack enters -/// the section's scope once, by name, in first-offer order. -fn install_offer( - lua: &Lua, - table: &Table, - set: &Arc, - runtime: &Arc>, - list: &MessageList, -) -> Result<()> { - let set = Arc::clone(set); - let state = Arc::clone(runtime); - let list = list.clone(); - let offer = lua - .create_function(move |lua, args: Variadic| { - refuse_after_render("packs.offer", &list)?; - let names = pack_names(lua, &set, args)?; - let mut state = state - .lock() - .map_err(|_| mlua::Error::external("tool declaration runtime was poisoned"))?; - for name in names { - if !state.packs.contains(&name) { - state.packs.push(name); - } + let meta = lua.create_table().map_err(Error::lua)?; + let indexed = all.clone(); + let index = lua + .create_function(move |lua, (_, key): (Table, Value)| match key { + Value::String(name) if name == "get" => Ok(Value::Function(get.clone())), + Value::String(name) if name == "extras" => Ok(Value::Function(extras.clone())), + key => match position(&key).and_then(|index| indexed.get(index)) { + Some(name) => Ok(pack_object(lua, name)?.map_or(Value::Nil, Value::UserData)), + None => Ok(Value::Nil), + }, + }) + .map_err(Error::lua)?; + meta.raw_set("__index", index).map_err(Error::lua)?; + let count = all.len(); + let len = lua + .create_function(move |_, _: MultiValue| Ok(count)) + .map_err(Error::lua)?; + meta.raw_set("__len", len).map_err(Error::lua)?; + let step = lua + .create_function(move |lua, (_, key): (Value, Value)| { + let next = match key { + Value::Nil => 0, + key => position(&key).map_or(all.len(), |index| index + 1), + }; + match all.get(next) { + Some(name) => Ok(( + Value::Integer(i64::try_from(next + 1).unwrap_or(i64::MAX)), + pack_object(lua, name)?.map_or(Value::Nil, Value::UserData), + )), + None => Ok((Value::Nil, Value::Nil)), } - Ok(()) }) .map_err(Error::lua)?; - table.set("offer", offer).map_err(Error::lua) + let pairs = lua + .create_function(move |_, proxy: Table| Ok((step.clone(), proxy, Value::Nil))) + .map_err(Error::lua)?; + meta.raw_set("__pairs", pairs).map_err(Error::lua)?; + let newindex = lua + .create_function(|_, _: MultiValue| -> mlua::Result<()> { + Err(mlua::Error::external(PACKS_LABEL)) + }) + .map_err(Error::lua)?; + meta.raw_set("__newindex", newindex).map_err(Error::lua)?; + meta.raw_set("__metatable", PACKS_LABEL) + .map_err(Error::lua)?; + + let proxy = lua.create_table().map_err(Error::lua)?; + proxy.set_metatable(Some(meta)).map_err(Error::lua)?; + tools.raw_set("packs", proxy).map_err(Error::lua) } -/// Reads `packs.offer`'s one argument, a Pack id, a Pack object, -/// or an array of either, as the names of Packs the run knows, checking -/// every entry against `set`'s roster before returning any. -fn pack_names(lua: &Lua, set: &ToolSet, args: Variadic) -> mlua::Result> { - let mut args = args.into_iter(); - let target = args.next().unwrap_or(Value::Nil); - if let Some(extra) = args.next() { - return Err(mlua::Error::external(format!( - "packs.offer takes one Pack or a list of them, got extra {}", - extra.type_name() - ))); - } - match target { - Value::Table(list) => list - .sequence_values::() - .map(|entry| pack_name(lua, set, &entry?)) - .collect(), - single => Ok(vec![pack_name(lua, set, &single)?]), +/// The 0-based position an integer key of `tools.packs` names, or `None` +/// for any other key. +fn position(key: &Value) -> Option { + match key { + Value::Integer(index) => usize::try_from(*index).ok()?.checked_sub(1), + _ => None, } } -/// Reads one name or Pack object as the name of a Pack the run knows, -/// raising the roster's reason for a Pack that can't serve the run. -fn pack_name(lua: &Lua, set: &ToolSet, value: &Value) -> mlua::Result { - let rejected = |found: String| { - mlua::Error::external(format!( - "packs.offer takes a Pack id, a Pack object, or a list of them, got {found}" - )) - }; - match value { - Value::String(text) => { - let text = text.to_string_lossy(); - let name = match PackId::parse(&text) { - Ok(name) => name, - Err(error) => return Err(rejected(format!("{text:?}: {error}"))), - }; - if let Some(Some(reason)) = set.roster().get(&name) { - return Err(mlua::Error::external(format!( - "packs.offer: the {name} Pack is unavailable in this run: {reason}" - ))); - } - if pack_object(lua, &text)?.is_none() { - return Err(mlua::Error::external(format!( - "packs.offer: no Pack named {text:?} in this run; declare it under \ - uses: to require it, or offer it only when packs.get({text:?}) \ - returns it" - ))); - } - Ok(name) +/// A fresh array of the Pack objects `names` lists, in order. +fn pack_array(lua: &Lua, names: &[String]) -> mlua::Result { + let objects = lua.create_table()?; + for name in names { + if let Some(object) = pack_object(lua, name)? { + objects.raw_push(object)?; } - Value::UserData(object) => object - .borrow::() - .map(|handle| handle.name.clone()) - .map_err(|_| rejected(value.type_name().to_owned())), - other => Err(rejected(other.type_name().to_owned())), } + Ok(objects) } -/// Each Pack the run knows, in id order, with the ids of its offered -/// tools in the set's id order: every declared Pack, each roster Pack -/// that can serve, with or without tools, and every other Pack that -/// offers one. -fn pack_tools(set: &ToolSet) -> BTreeMap> { +/// Each Pack the run knows, in id order, with its tools: every declared +/// Pack, each roster Pack that can serve, with or without tools, and every +/// other Pack that offers one, each with its offered tools' ids in the +/// set's id order, and the adhoc Pack over `adhoc`. +fn pack_tools(set: &ToolSet, adhoc: &AdhocTools) -> BTreeMap { let usable = set .roster() .iter() @@ -253,6 +281,13 @@ fn pack_tools(set: &ToolSet) -> BTreeMap> { .or_default() .push(binding.id().to_string()); } + let mut packs: BTreeMap = packs + .into_iter() + .map(|(name, ids)| (name, PackTools::Catalog(ids))) + .collect(); + if let Ok(name) = PackId::parse(ADHOC_PACK) { + packs.insert(name, PackTools::Adhoc(adhoc.clone())); + } packs } diff --git a/crates/runvil-internal/lua/src/protocol.rs b/crates/runvil-internal/lua/src/protocol.rs index c6c31806b..7eaacd760 100644 --- a/crates/runvil-internal/lua/src/protocol.rs +++ b/crates/runvil-internal/lua/src/protocol.rs @@ -36,4 +36,4 @@ pub use parse::YieldParse; pub(crate) use parse::parse_record; pub use record::{ContentPart, MessageContent, MessageRecord, RecordRole, ToolCallRecord}; pub(crate) use render::Retained; -pub use request::{LocalToolOutcome, Request, SpawnVia, VfsOp}; +pub use request::{AdhocToolOutcome, Request, SpawnVia, VfsOp}; diff --git a/crates/runvil-internal/lua/src/protocol/answer.rs b/crates/runvil-internal/lua/src/protocol/answer.rs index 1c16ec96a..036c0793c 100644 --- a/crates/runvil-internal/lua/src/protocol/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/answer.rs @@ -1,10 +1,10 @@ //! The answer vocabulary: one dispatched request's outcome and the payload //! types its variants hold. //! -//! A local tool call is answered in two steps. The `tool_call` request's -//! answer is [`ToolCallOutcome::Local`], which resumes the shim with the +//! An adhoc tool call is answered in two steps. The `tool_call` request's +//! answer is [`ToolCallOutcome::Adhoc`], which resumes the shim with the //! handler itself so the shim runs it inside the calling coroutine. The -//! shim then yields `local_tool_done` with the handler's result, and that +//! shim then yields `adhoc_tool_done` with the handler's result, and that //! request's answer is an ordinary [`Answer::ToolCallResult`]: the text //! on success, the error for a rejected return. @@ -53,12 +53,13 @@ pub enum ToolCallOutcome { Plain(String), /// A structured binding's parsed JSON output, resumed as a Lua table. Structured(serde_json::Value), - /// A local Lua tool's call, resumed as `(true, nil, handler, args)`: - /// the handler registered under `alias` and a fresh table converted - /// from `args`, so the shim runs the handler inside the calling - /// coroutine and reports how it ended with a `local_tool_done` yield. - Local { - /// The local tool's alias, naming its handler. + /// An adhoc tool's call, resumed as `(true, nil, handler, args)`: + /// the handler `tools.adhoc` registered under the wire name `alias` + /// and a fresh table converted from `args`, so the shim runs the + /// handler inside the calling coroutine and reports how it ended with + /// an `adhoc_tool_done` yield. + Adhoc { + /// The adhoc tool's wire name, naming its handler. alias: String, /// The call's JSON arguments. args: serde_json::Value, diff --git a/crates/runvil-internal/lua/src/protocol/parse.rs b/crates/runvil-internal/lua/src/protocol/parse.rs index 3baead3eb..9f8daf033 100644 --- a/crates/runvil-internal/lua/src/protocol/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/parse.rs @@ -20,7 +20,7 @@ use tasks::{ parse_cancel, parse_concurrency, parse_join_any, parse_note, parse_ready, parse_spawn, parse_status, parse_timer, }; -use tools::{parse_local_tool_done, parse_tool_call, parse_tool_calls}; +use tools::{parse_adhoc_tool_done, parse_tool_call, parse_tool_calls}; use crate::error::{Error, Result}; use crate::handles::resolve_section_target; @@ -233,7 +233,7 @@ impl Request { "tool_calls" => classify(parse_tool_calls(lua, table), |error| { Answer::ToolCallsResult(Err(error)) }), - "local_tool_done" => match parse_local_tool_done(table) { + "adhoc_tool_done" => match parse_adhoc_tool_done(table) { Some(request) => YieldParse::Request(request), None => YieldParse::Malformed(direct_yield_error()), }, diff --git a/crates/runvil-internal/lua/src/protocol/parse/tools.rs b/crates/runvil-internal/lua/src/protocol/parse/tools.rs index f5d1048b1..c889d3db0 100644 --- a/crates/runvil-internal/lua/src/protocol/parse/tools.rs +++ b/crates/runvil-internal/lua/src/protocol/parse/tools.rs @@ -1,10 +1,10 @@ //! The tool request parsers: a `tools.call`, the loop's batched -//! `tool_calls`, and a local tool's `local_tool_done`. +//! `tool_calls`, and an adhoc tool's `adhoc_tool_done`. use mlua::{Lua, LuaSerdeExt, MultiValue, Value}; -use crate::protocol::{LocalToolOutcome, Request, ToolCallRecord}; -use crate::tools::tool_name; +use crate::protocol::{AdhocToolOutcome, Request, ToolCallRecord}; +use crate::tools::{model_tool_name, tool_name}; use crate::vm::scalar_return; use super::{FieldFailure, call_error, shim_optional_string, shim_optional_u32}; @@ -37,26 +37,36 @@ pub(super) fn parse_tool_call( }) } -/// Reads a `tool_call` table: the author-supplied `alias` (a string or a -/// Tool object, decoded through the one alias-or-Tool polymorphism) and -/// `args`, plus the shim-produced optional `call_id` and `turn`. +/// Reads a `tool_call` table: the `alias` naming the tool and the +/// author-supplied `args`, plus the shim-produced optional `call_id` and +/// `turn`. /// -/// An absent or nil `args` parses as the empty object (the empty-argument -/// call every tool accepts). A non-table or JSON-unrepresentable `args` is -/// the call's error, framed as the other author-argument failures are, -/// so an author `pcall` catches it at the call site. `call_id` is set only -/// by the loop shim for a model-issued call, so a present non-string is a -/// malformed yield rather than a call error; `turn`, set by the same shim, -/// is likewise malformed unless it is an integer within `u32` range. +/// A script call, with no `call_id`, names its tool by id or tool object, +/// decoded through the one id-or-tool polymorphism; a model-issued call +/// names it by the wire name the model sent. An absent or nil `args` +/// parses as the empty object (the empty-argument call every tool +/// accepts). A wrong `alias` for a script call, or a non-table or +/// JSON-unrepresentable `args`, is the call's error, framed as the other +/// author-argument failures are, so an author `pcall` catches it at the +/// call site. `call_id` is set only by the loop shim for a model-issued +/// call, so a present non-string is a malformed yield rather than a call +/// error; `turn`, set by the same shim, is likewise malformed unless it is +/// an integer within `u32` range. fn read_tool_call( lua: &Lua, table: &mlua::Table, ) -> std::result::Result { + let call_id = shim_optional_string(table, "call_id")?; + let decode = if call_id.is_some() { + model_tool_name + } else { + tool_name + }; let alias = match table.raw_get::("alias") { // Flatten to the call-error string so the answer frames exactly as // the other author-argument failures (`Error::Lua`, not a runtime // wrapper). - Ok(value) => tool_name(&value).map_err(|error| call_error(error.to_string()))?, + Ok(value) => decode(&value).map_err(|error| call_error(error.to_string()))?, Err(_) => return Err(FieldFailure::Malformed), }; let args = match table.raw_get::("args") { @@ -77,7 +87,6 @@ fn read_tool_call( } Err(_) => return Err(FieldFailure::Malformed), }; - let call_id = shim_optional_string(table, "call_id")?; let turn = shim_optional_u32(table, "turn")?; Ok(ToolCallFields { alias, @@ -106,6 +115,9 @@ pub(super) fn parse_tool_calls( let Ok(Value::Table(entry)) = entries.raw_get::(index) else { return Err(FieldFailure::Malformed); }; + if shim_optional_string(&entry, "call_id")?.is_none() { + return Err(FieldFailure::Malformed); + } let ToolCallFields { alias, args, @@ -125,22 +137,22 @@ pub(super) fn parse_tool_calls( Ok(Request::ToolCalls { calls, turn }) } -/// Parses a `local_tool_done` request: the shim-produced `ok` flag and, +/// Parses an `adhoc_tool_done` request: the shim-produced `ok` flag and, /// when the handler returned, its first return value as `value`. A /// returned value takes the scalar-return rule a block's return takes, so -/// nil reads as `""` and a table is a [`LocalToolOutcome::BadReturn`]. +/// nil reads as `""` and a table is an [`AdhocToolOutcome::BadReturn`]. /// `None` for a missing or non-boolean `ok`. -pub(super) fn parse_local_tool_done(table: &mlua::Table) -> Option { +pub(super) fn parse_adhoc_tool_done(table: &mlua::Table) -> Option { let outcome = match table.raw_get::("ok").ok()? { Value::Boolean(true) => { let value = table.raw_get::("value").ok()?; match scalar_return(MultiValue::from_vec(vec![value])) { - Ok(text) => LocalToolOutcome::Returned(text.unwrap_or_default()), - Err(error) => LocalToolOutcome::BadReturn(error), + Ok(text) => AdhocToolOutcome::Returned(text.unwrap_or_default()), + Err(error) => AdhocToolOutcome::BadReturn(error), } } - Value::Boolean(false) => LocalToolOutcome::Raised, + Value::Boolean(false) => AdhocToolOutcome::Raised, _ => return None, }; - Some(Request::LocalToolDone { outcome }) + Some(Request::AdhocToolDone { outcome }) } diff --git a/crates/runvil-internal/lua/src/protocol/render.rs b/crates/runvil-internal/lua/src/protocol/render.rs index 79eb2b5e6..9f8934f4a 100644 --- a/crates/runvil-internal/lua/src/protocol/render.rs +++ b/crates/runvil-internal/lua/src/protocol/render.rs @@ -4,7 +4,7 @@ use mlua::{Lua, LuaSerdeExt, MultiValue, Table, Value}; use crate::error_value::{ErrorValue, error_table}; -use crate::tools::local_handler; +use crate::tools::adhoc_handler; use super::answer::{Answer, TaskDelivery, TaskStatus, ToolCallOutcome, VfsOutcome}; @@ -141,11 +141,11 @@ impl Answer { vec![lua.to_value(&json)?] } // The result slot stays nil: the handler and its arguments ride - // after it, so the shim tells a local answer from a bound + // after it, so the shim tells an adhoc answer from a bound // result by the handler's presence. - Answer::ToolCallResult(Ok(ToolCallOutcome::Local { alias, args })) => vec![ + Answer::ToolCallResult(Ok(ToolCallOutcome::Adhoc { alias, args })) => vec![ Value::Nil, - Value::Function(local_handler(lua, &alias)?), + Value::Function(adhoc_handler(lua, &alias)?), lua.to_value(&args)?, ], Answer::ToolCallsResult(Ok(texts)) => { diff --git a/crates/runvil-internal/lua/src/protocol/request.rs b/crates/runvil-internal/lua/src/protocol/request.rs index e0a3ab0c4..d856b457e 100644 --- a/crates/runvil-internal/lua/src/protocol/request.rs +++ b/crates/runvil-internal/lua/src/protocol/request.rs @@ -2,11 +2,11 @@ //! yield and the store operations they hold. The message-record types the //! chat request is built from are in `record`. //! -//! A local tool call spans two requests. The first is the ordinary +//! An adhoc tool call spans two requests. The first is the ordinary //! [`Request::ToolCall`], which the scheduler answers with the handler. //! The shim runs the handler inside the calling coroutine, so any //! suspending call the handler makes is a request of its own, and then -//! yields [`Request::LocalToolDone`] with how the handler ended, so the +//! yields [`Request::AdhocToolDone`] with how the handler ended, so the //! scheduler can report the call and answer it. use std::num::NonZeroU64; @@ -136,10 +136,11 @@ pub enum Request { /// The task to cancel. task: TaskId, }, - /// `tools.call(alias_or_tool, args)`: suspending dispatch of a bound - /// tool through the shared dispatch function. + /// `tools.call(id_or_tool, args)`: suspending dispatch of a tool + /// through the shared dispatch function. ToolCall { - /// The author-supplied prompt-local tool alias. + /// The tool a script names by id, or the wire name a model-issued + /// call names. alias: String, /// The author-supplied JSON arguments; an absent or nil `args` /// parses as the empty object. @@ -161,7 +162,7 @@ pub enum Request { }, /// The unanswered calls of one model round, yielded by the loop's /// step: the scheduler issues every catalog call up to the first call - /// that must run alone (a local tool) and answers + /// that must run alone (an adhoc tool) and answers /// once each issued call has landed, with one text per issued call in /// call order. The loop sends the call it stopped at as an ordinary /// [`Request::ToolCall`]. Shim-produced in every field, so a wrong @@ -172,20 +173,20 @@ pub enum Request { /// The turn of the chat round that requested the calls. turn: u32, }, - /// The second yield of a local tool call: the shim ran the handler the + /// The second yield of an adhoc tool call: the shim ran the handler the /// `tool_call` answer handed it and reports how the handler ended. /// Shim-produced in every field, so a wrong shape is a malformed /// yield, never an error at the author's call site. - LocalToolDone { + AdhocToolDone { /// How the handler ended. - outcome: LocalToolOutcome, + outcome: AdhocToolOutcome, }, /// One stateless tool-capable model round over the section's list, /// yielded by the `models.loop` shim once per round. The dispatch arm /// first refuses an empty list; the round sends the section's /// rendered tool list, which the dispatch arm renders from the - /// section's tool scope, local Lua tools included, at the first round - /// to reach the build and attaches to the list. + /// section's offers at the first round to reach the build and + /// attaches to the list. Chat { /// The list the round sends, which may be empty, since the /// dispatch, not the parse, refuses an empty list. Its records @@ -208,10 +209,10 @@ pub enum Request { }, } -/// How a local tool's handler ended, as its `local_tool_done` yield +/// How an adhoc tool's handler ended, as its `adhoc_tool_done` yield /// reports it. #[derive(Debug)] -pub enum LocalToolOutcome { +pub enum AdhocToolOutcome { /// The handler returned: its first return value as text under the /// scalar-return rule, `""` for nil. Returned(String), diff --git a/crates/runvil-internal/lua/src/protocol/tests/answer.rs b/crates/runvil-internal/lua/src/protocol/tests/answer.rs index 0363696e0..a6a435c12 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/answer.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/answer.rs @@ -160,32 +160,32 @@ fn an_ok_structured_tool_call_answer_round_trips_as_a_table() { assert_eq!(images_len, 0); } -/// A fresh VM with the `tools` namespace installed and one local tool, -/// `grab`, registered through `tools.offer_local`. -fn lua_with_local_grab() -> Lua { +/// A fresh VM with the `tools` namespace installed and one adhoc tool, +/// `grab`, defined through `tools.adhoc`. +fn lua_with_adhoc_grab() -> Lua { let lua = Lua::new(); crate::tools::install_tools( &lua, &lua.globals(), &std::sync::Arc::new(crate::ToolSet::default()), &std::sync::Arc::new(std::sync::Mutex::new(crate::ToolRuntime::default())), - &crate::vm::LocalTools::default(), + &crate::vm::AdhocTools::default(), &crate::MessageList::default(), ) .expect("the tools install cannot fail on a fresh VM"); lua.load( - "tools.offer_local('grab', 'Grab a value', { value = 'string' }, \ - function(args) return 'got ' .. args.value end)", + "tools.adhoc({ name = 'grab', description = 'Grab a value', \ + parameters = { value = 'string' } }, function(args) return 'got ' .. args.value end)", ) .exec() - .expect("the local tool registers"); + .expect("the adhoc tool registers"); lua } #[test] -fn a_local_tool_call_answer_resumes_with_its_handler_and_args() { - let lua = lua_with_local_grab(); - let (envelope, retained) = Answer::::ToolCallResult(Ok(ToolCallOutcome::Local { +fn an_adhoc_tool_call_answer_resumes_with_its_handler_and_args() { + let lua = lua_with_adhoc_grab(); + let (envelope, retained) = Answer::::ToolCallResult(Ok(ToolCallOutcome::Adhoc { alias: "grab".to_owned(), args: json!({ "value": "hi" }), })) @@ -202,16 +202,16 @@ fn a_local_tool_call_answer_resumes_with_its_handler_and_args() { assert!(ok); assert!( result_is_nil, - "the result slot stays nil for a local answer" + "the result slot stays nil for an adhoc answer" ); assert!(handler_is_fn, "the handler resumes as a function"); assert_eq!(called, "got hi", "the registered handler runs on the args"); } #[test] -fn a_local_tool_call_answer_for_an_unregistered_alias_is_an_error() { - let lua = lua_with_local_grab(); - let error = Answer::::ToolCallResult(Ok(ToolCallOutcome::Local { +fn an_adhoc_tool_call_answer_for_an_unregistered_alias_is_an_error() { + let lua = lua_with_adhoc_grab(); + let error = Answer::::ToolCallResult(Ok(ToolCallOutcome::Adhoc { alias: "missing".to_owned(), args: json!({}), })) @@ -220,7 +220,7 @@ fn a_local_tool_call_answer_for_an_unregistered_alias_is_an_error() { assert!( error .to_string() - .contains("a local tool answer names a registered handler"), + .contains("an adhoc tool answer names a registered handler"), "the error names the broken invariant: {error}" ); } diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse.rs b/crates/runvil-internal/lua/src/protocol/tests/parse.rs index d76e23a81..b33d12a2f 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse.rs @@ -1,5 +1,5 @@ //! Yield-to-request parsing for the leaf and structural requests (`infer`, -//! `call`, `tool_calls`, `local_tool_done`), and the malformed-yield +//! `call`, `tool_calls`, `adhoc_tool_done`), and the malformed-yield //! rejections shared by every op. The `tool_call` request is in //! `parse_tool_call` and the task-operation requests (`spawn`, `timer`, //! `concurrency`) are in `parse_tasks`. @@ -117,35 +117,35 @@ fn a_tool_calls_yield_missing_its_turn_a_table_per_call_or_a_call_id_is_a_malfor } } -/// Parses a `local_tool_done` yield with `ok = true` and `value` set to +/// Parses an `adhoc_tool_done` yield with `ok = true` and `value` set to /// what `source` evaluates to, returning the outcome. -fn local_tool_done_returning(lua: &Lua, source: &str) -> LocalToolOutcome { +fn adhoc_tool_done_returning(lua: &Lua, source: &str) -> AdhocToolOutcome { let table = lua_table( lua, - &format!("{{ op = 'local_tool_done', ok = true, value = {source} }}"), + &format!("{{ op = 'adhoc_tool_done', ok = true, value = {source} }}"), ); match expect_request(Request::from_yield(lua, &Value::Table(table))) { - Request::LocalToolDone { outcome } => outcome, - other => panic!("expected a local_tool_done request, got {other:?}"), + Request::AdhocToolDone { outcome } => outcome, + other => panic!("expected an adhoc_tool_done request, got {other:?}"), } } #[test] -fn a_local_tool_done_return_becomes_its_scalar_text() { +fn an_adhoc_tool_done_return_becomes_its_scalar_text() { let lua = Lua::new(); for (source, expected) in [("'hi'", "hi"), ("42", "42"), ("true", "true"), ("nil", "")] { - match local_tool_done_returning(&lua, source) { - LocalToolOutcome::Returned(text) => assert_eq!(text, expected, "{source}"), + match adhoc_tool_done_returning(&lua, source) { + AdhocToolOutcome::Returned(text) => assert_eq!(text, expected, "{source}"), other => panic!("{source} must return its text, got {other:?}"), } } } #[test] -fn a_local_tool_done_table_return_is_a_bad_return() { +fn an_adhoc_tool_done_table_return_is_a_bad_return() { let lua = Lua::new(); - match local_tool_done_returning(&lua, "{ 1, 2 }") { - LocalToolOutcome::BadReturn(Error::Lua(message)) => { + match adhoc_tool_done_returning(&lua, "{ 1, 2 }") { + AdhocToolOutcome::BadReturn(Error::Lua(message)) => { assert_eq!(message, "cannot return a table as a result"); } other => panic!("a table return must be rejected, got {other:?}"), @@ -153,26 +153,26 @@ fn a_local_tool_done_table_return_is_a_bad_return() { } #[test] -fn a_local_tool_done_with_ok_false_is_raised() { +fn an_adhoc_tool_done_with_ok_false_is_raised() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'local_tool_done', ok = false, value = 'ignored' }", + "{ op = 'adhoc_tool_done', ok = false, value = 'ignored' }", ); match expect_request(Request::from_yield(&lua, &Value::Table(table))) { - Request::LocalToolDone { - outcome: LocalToolOutcome::Raised, + Request::AdhocToolDone { + outcome: AdhocToolOutcome::Raised, } => {} other => panic!("expected a raised outcome, got {other:?}"), } } #[test] -fn a_local_tool_done_without_a_boolean_ok_is_a_malformed_yield() { +fn an_adhoc_tool_done_without_a_boolean_ok_is_a_malformed_yield() { let lua = Lua::new(); for source in [ - "{ op = 'local_tool_done', value = 'hi' }", - "{ op = 'local_tool_done', ok = 1, value = 'hi' }", + "{ op = 'adhoc_tool_done', value = 'hi' }", + "{ op = 'adhoc_tool_done', ok = 1, value = 'hi' }", ] { let table = lua_table(&lua, source); assert_direct_yield(Request::from_yield(&lua, &Value::Table(table))); diff --git a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs index f63695ee8..45e522522 100644 --- a/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs +++ b/crates/runvil-internal/lua/src/protocol/tests/parse_tool_call.rs @@ -10,7 +10,7 @@ fn tool_call_parses_alias_and_args() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'tool_call', alias = 'echo', args = { value = 'hi' } }", + "{ op = 'tool_call', alias = 'tools/echo', args = { value = 'hi' } }", ); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { @@ -20,7 +20,7 @@ fn tool_call_parses_alias_and_args() { call_id, turn, } => { - assert_eq!(alias, "echo"); + assert_eq!(alias, "tools/echo"); assert_eq!(args, json!({ "value": "hi" })); assert_eq!(call_id, None, "a script call omits the call id"); assert_eq!(turn, None, "a script call omits the turn"); @@ -111,7 +111,7 @@ fn a_turn_outside_u32_or_not_an_integer_is_a_malformed_yield() { #[test] fn tool_call_without_args_parses_the_empty_object() { let lua = Lua::new(); - let table = lua_table(&lua, "{ op = 'tool_call', alias = 'echo' }"); + let table = lua_table(&lua, "{ op = 'tool_call', alias = 'tools/echo' }"); let request = expect_request(Request::from_yield(&lua, &Value::Table(table))); match request { Request::ToolCall { args, .. } => assert_eq!(args, json!({})), @@ -122,8 +122,8 @@ fn tool_call_without_args_parses_the_empty_object() { #[test] fn a_marked_empty_list_in_the_args_parses_to_an_empty_array() { let vm = crate::test_vm::TestVm::new().build(); - let table: mlua::Table = - vm.eval("return { op = 'tool_call', alias = 'echo', args = { items = json.array() } }"); + let table: mlua::Table = vm + .eval("return { op = 'tool_call', alias = 'tools/echo', args = { items = json.array() } }"); match expect_request(Request::from_yield(vm.lua(), &Value::Table(table))) { Request::ToolCall { args, .. } => assert_eq!(args, json!({ "items": [] })), other => panic!("expected a tool_call request, got {other:?}"), @@ -159,17 +159,34 @@ fn a_tool_call_with_a_non_alias_alias_is_the_calls_error() { YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { assert_eq!( message, - "tools.call takes a tool id, a local alias, or a tool object, got integer" + "tools.call takes a tool id or a tool object, got integer" ); } other => panic!("expected the alias call error, got {other:?}"), } } +#[test] +fn a_script_call_naming_a_string_without_a_slash_is_the_calls_error() { + // A model-issued call names its tool by wire name; a script names it + // by id, so a bare name is refused before any resolution. + let lua = Lua::new(); + let table = lua_table(&lua, "{ op = 'tool_call', alias = 'grab' }"); + match Request::from_yield(&lua, &Value::Table(table)) { + YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { + assert_eq!(message, "tools.call: \"grab\" is not a tool id"); + } + other => panic!("expected the id call error, got {other:?}"), + } +} + #[test] fn a_tool_call_with_a_non_table_args_is_the_calls_error() { let lua = Lua::new(); - let table = lua_table(&lua, "{ op = 'tool_call', alias = 'echo', args = 42 }"); + let table = lua_table( + &lua, + "{ op = 'tool_call', alias = 'tools/echo', args = 42 }", + ); match Request::from_yield(&lua, &Value::Table(table)) { YieldParse::Call(Answer::ToolCallResult(Err(Error::Lua(message)))) => { assert_eq!(message, "args must be a table, got integer"); @@ -183,7 +200,7 @@ fn a_tool_call_with_an_unrepresentable_args_table_is_the_calls_error() { let lua = Lua::new(); let table = lua_table( &lua, - "{ op = 'tool_call', alias = 'echo', args = { f = function() end } }", + "{ op = 'tool_call', alias = 'tools/echo', args = { f = function() end } }", ); let args: Value = table .raw_get("args") diff --git a/crates/runvil-internal/lua/src/scope.rs b/crates/runvil-internal/lua/src/scope.rs index d33376ce8..442a4be6c 100644 --- a/crates/runvil-internal/lua/src/scope.rs +++ b/crates/runvil-internal/lua/src/scope.rs @@ -1,5 +1,5 @@ -//! Per-VM tool runtime state: call counts, the scoped tool set a section -//! sees, and the tool list its first loop round rendered from that set. +//! Per-VM tool runtime state: call counts, the offers a section made, and +//! the tool list its first loop round rendered from them. use std::borrow::Cow; use std::collections::BTreeMap; @@ -11,9 +11,9 @@ use runvil_types::tools::ToolId; use crate::error::{Error, Result}; -/// Shared per-VM tool-call counts, seeded at 0 for every key the installer -/// was given: a catalog tool's id, or a local tool's alias. A dispatch -/// seeds a missing key on demand through [`ensure`](Self::ensure). +/// Shared per-VM tool-call counts, keyed by tool id and seeded at 0 for +/// every id the installer was given. A dispatch seeds a missing key on +/// demand through [`ensure`](Self::ensure). /// /// The executor increments a count when dispatch is attempted (even if the tool /// later errors). Lua reads the snapshot through the `tools.calls` table. @@ -82,15 +82,70 @@ impl ToolCallCounts { } } -/// Tracks the tools and Packs one section VM offered. +/// What one offer names: a tool by id, or a Pack, whose tools the render +/// expands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OfferTarget { + /// One tool, by id. + Tool(ToolId), + /// Every tool of a Pack, read when the section renders. + Pack(PackId), +} + +/// Which list an offer feeds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OfferMode { + /// The rendered list, which every round of the section sends. + Static, + /// The dynamic list, stored beside the rendered list. + Dynamic, +} + +/// One remembered offer: what it names and which list it feeds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Offer { + /// What the offer names. + pub target: OfferTarget, + /// Which list the offer feeds. + pub mode: OfferMode, +} + +/// The offers one section VM made, in call order. #[derive(Debug, Default)] pub struct ToolRuntime { - /// The wire names `tools.offer` put in the section's scope, in - /// first-offer order. - pub added: Vec, - /// The Packs `packs.offer` put in the section's scope, in - /// first-offer order. - pub packs: Vec, + /// Every offer, in call order, each held once. + pub offers: Vec, +} + +impl ToolRuntime { + /// Remembers an offer of the tool `id` under `mode`, unless an equal + /// offer is already held. + pub fn add_tool(&mut self, id: ToolId, mode: OfferMode) { + self.add(OfferTarget::Tool(id), mode); + } + + /// Remembers an offer of the Pack `pack` under `mode`, unless an equal + /// offer is already held. + pub fn add_pack(&mut self, pack: PackId, mode: OfferMode) { + self.add(OfferTarget::Pack(pack), mode); + } + + fn add(&mut self, target: OfferTarget, mode: OfferMode) { + let offer = Offer { target, mode }; + if !self.offers.contains(&offer) { + self.offers.push(offer); + } + } +} + +/// One advertised tool: its id and its Pack's vendor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Advertised { + /// The tool's id. + pub id: ToolId, + /// The vendor of the tool's Pack, as the caller reported it; `None` + /// when it reported none, and for an adhoc tool. + pub vendor: Option, } /// The tool list a section's first loop round rendered. @@ -98,12 +153,14 @@ pub struct ToolRuntime { pub struct RenderedTools { /// The schemas every round of the section sends. pub schemas: Vec, - /// Each advertised name's catalog tool, `None` for a local tool. + /// Each advertised wire name's tool. /// /// Every round copies this map onto its chain: the round's answer is - /// gated against its keys, and the reported batch names a catalog - /// tool's id. The `tool_call` the loop then yields resolves each name - /// itself, a wire name against the run's offering and a local alias - /// against the section VM's handlers. - pub dispatch: BTreeMap>, + /// gated against its keys, and the reported batch names each tool's + /// id and vendor. A model's call is routed by the entry under its wire + /// name. + pub dispatch: BTreeMap, + /// The tools the section's dynamic offers named that the rendered + /// list leaves out, in offer order. + pub dynamic: Vec, } diff --git a/crates/runvil-internal/lua/src/test_vm.rs b/crates/runvil-internal/lua/src/test_vm.rs index cfea5a455..88803d615 100644 --- a/crates/runvil-internal/lua/src/test_vm.rs +++ b/crates/runvil-internal/lua/src/test_vm.rs @@ -109,7 +109,75 @@ pub(crate) fn echo_tool() -> ToolInfo { tool_at("tools/echo") } -/// A test VM whose `tools` and `packs` tables are installed over `set`. +/// The run the offer tests share: `web` (`web/fetch`, `web/search`) and +/// `user-input` (`user-input/ask`) declared; `gh` (`gh/issues.list`, +/// `gh/search`) offered but undeclared; `subagents`, with no tool, able to +/// serve; and `mcp` unable to, because "the mcp server is down". Every +/// tool is bound under its wire name. +#[track_caller] +pub(crate) fn offer_set() -> ToolSet { + let pack = |name: &str| { + runvil_types::packs::PackId::parse(name).expect("a fixture Pack id is a valid Pack id") + }; + let bind = |id: &str| crate::ToolBinding::from_info(&id.replace(['/', '.'], "_"), &tool_at(id)); + ToolSet::new( + vec![pack("web"), pack("user-input")], + [ + (pack("gh"), None), + (pack("mcp"), Some("the mcp server is down".to_owned())), + (pack("subagents"), None), + (pack("user-input"), None), + (pack("web"), None), + ] + .into_iter() + .collect(), + vec![ + bind("gh/issues.list"), + bind("gh/search"), + bind("user-input/ask"), + bind("web/fetch"), + bind("web/search"), + ], + ) +} + +/// The offers `vm` recorded, each as `:`, the mode +/// `static` or `dynamic`, in call order. +#[track_caller] +pub(crate) fn offers(vm: &SectionVm) -> Vec { + vm.tool_runtime() + .lock() + .expect("the runtime mutex is not poisoned") + .offers + .iter() + .map(|offer| { + let mode = match offer.mode { + crate::OfferMode::Static => "static", + crate::OfferMode::Dynamic => "dynamic", + }; + match &offer.target { + crate::OfferTarget::Tool(id) => format!("{mode}:{id}"), + crate::OfferTarget::Pack(pack) => format!("{mode}:{pack}"), + } + }) + .collect() +} + +/// What `source` raises on `vm`, as text, failing the test when it runs +/// clean. +#[track_caller] +pub(crate) fn raised(vm: &SectionVm, source: &str) -> String { + vm.lua() + .load(format!( + "local ok, err = pcall(function() {source} end)\n\ + assert(not ok, 'the chunk must raise')\n\ + return tostring(err)" + )) + .eval() + .unwrap_or_else(|error| panic!("{source}: {error}")) +} + +/// A test VM whose `tools` table is installed over `set`. #[track_caller] pub(crate) fn lua_over(set: ToolSet) -> BuiltVm { TestVm::new().tools(set).build() @@ -119,13 +187,26 @@ pub(crate) fn lua_over(set: ToolSet) -> BuiltVm { #[track_caller] pub(crate) fn scoped(vm: &SectionVm) -> Vec { let (set, runtime) = vm.tool_bag_handles(); - crate::current_tool_bindings(&set, &runtime) + crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()) .expect("the scope resolves") .iter() .map(|binding| binding.alias().to_owned()) .collect() } +/// The ids of the dynamic list a round on `vm` would store, in order. +#[track_caller] +pub(crate) fn dynamic(vm: &SectionVm) -> Vec { + let (set, runtime) = vm.tool_bag_handles(); + let adhoc = vm.adhoc_tools(); + let bound = crate::current_tool_bindings(&set, &runtime, adhoc).expect("the scope resolves"); + crate::current_dynamic_tools(&set, &runtime, adhoc, &bound) + .expect("the dynamic list resolves") + .iter() + .map(ToString::to_string) + .collect() +} + /// A section VM's setup as data. Unset, the VM shares an empty tool and /// model set, reports nowhere under [`TEST_SECTION`], gets a fresh store, /// empty `args` and `sys`, a frozen nil `argv`, and an empty `var`. diff --git a/crates/runvil-internal/lua/src/tests/diagnostics.rs b/crates/runvil-internal/lua/src/tests/diagnostics.rs index 12309bf5a..1291221d7 100644 --- a/crates/runvil-internal/lua/src/tests/diagnostics.rs +++ b/crates/runvil-internal/lua/src/tests/diagnostics.rs @@ -4,7 +4,7 @@ use super::*; use crate::program::map_chunk_line_to_absolute; -use crate::vm::LocalTools; +use crate::vm::AdhocTools; #[test] fn lua_program_retains_source_and_round_trips_bytecode() { @@ -62,38 +62,34 @@ fn runtime_assert_failure_reports_chunk_name_and_line() { } #[test] -fn local_tools_schema_and_membership_reads_fail_closed_on_poison() { - let local = LocalTools::default(); - let schema = runvil_model_client::detail::tool_schema_new( - "grab".to_owned(), - "Grab a value".to_owned(), +fn adhoc_tools_list_and_lookup_reads_fail_closed_on_poison() { + let adhoc = AdhocTools::default(); + let info = runvil_types::tools::ToolInfo::new( + runvil_types::tools::ToolId::parse("adhoc/grab").expect("a valid adhoc id"), + "Grab a value", serde_json::json!({ "type": "object", "properties": {} }), - ) - .expect("the schema builds"); - local - .register( - crate::alias::Alias::parse("grab".to_owned()).expect("a valid alias"), - schema, - ) + ); + adhoc + .register(crate::ToolBinding::from_info("grab", &info)) .expect("registration succeeds before the poison"); - let handle = local.entries_handle(); + let handle = adhoc.entries_handle(); let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _guard = handle.lock().expect("first lock is not poisoned"); - panic!("poison the local tools registry"); + panic!("poison the adhoc tools registry"); })); assert!(poisoned.is_err(), "the panic must poison the registry"); - let schemas = local - .schemas() - .expect_err("schema reads must fail on a poisoned registry"); - let contains = local - .contains("anything") - .expect_err("membership reads must fail on a poisoned registry"); - for error in [schemas, contains] { + let listed = adhoc + .bindings() + .expect_err("list reads must fail on a poisoned registry"); + let looked_up = adhoc + .binding("adhoc/grab") + .expect_err("lookups must fail on a poisoned registry"); + for error in [listed, looked_up] { assert!( error .to_string() - .contains("local tools registry was poisoned"), + .contains("adhoc tools registry was poisoned"), "the concrete poison error must surface: {error}" ); } diff --git a/crates/runvil-internal/lua/src/tests/section_vm.rs b/crates/runvil-internal/lua/src/tests/section_vm.rs index 21a8eb559..7c37af060 100644 --- a/crates/runvil-internal/lua/src/tests/section_vm.rs +++ b/crates/runvil-internal/lua/src/tests/section_vm.rs @@ -425,7 +425,8 @@ fn a_chunk_sees_the_tools_table_and_no_tool_global_ever_installs() { Some("nil") ); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), ["fixtures_search"], @@ -460,7 +461,8 @@ fn functions_resolve_engine_globals_when_called_from_a_later_chunk() { Some("fixtures/search") ); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), ["fixtures_search"] diff --git a/crates/runvil-internal/lua/src/tests/tool_scoping.rs b/crates/runvil-internal/lua/src/tests/tool_scoping.rs index 420e29c4c..d20b3bb37 100644 --- a/crates/runvil-internal/lua/src/tests/tool_scoping.rs +++ b/crates/runvil-internal/lua/src/tests/tool_scoping.rs @@ -54,7 +54,8 @@ fn section_scope_keeps_each_tool_at_its_first_offer() { let vm = TestVm::new().tools(bindings).build(); run_scalar(&vm, &prologue, &null_emitter(), "Section").expect("section offers must record"); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -75,7 +76,8 @@ fn tools_offer_accepts_tool_objects_and_arrays() { run_scalar(&vm, &prologue, &null_emitter(), "Section") .expect("tools.offer must accept tool objects, ids, and arrays"); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -97,7 +99,8 @@ fn empty_offer_is_a_no_op_and_failed_bulk_offer_is_atomic() { run_scalar(&vm, &prologue, &null_emitter(), "Section") .expect("caught failed offer must not poison recording"); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -110,7 +113,7 @@ fn empty_offer_is_a_no_op_and_failed_bulk_offer_is_atomic() { fn offer_rejects_a_second_argument_and_records_nothing() { let bindings = fixture_set(&[("search", "search the web")]); let prologue = program( - "local extra = 'tools.offer takes one tool or a list of them, got extra string'; \ + "local extra = 'tools.offer takes one tool or Pack, or an array of them, got extra string'; \ local ok, err = pcall(tools.offer, 'fixtures/search', 'a description'); \ if ok or not string.find(tostring(err), extra, 1, true) then \ error('a second argument to a single offer must fail loudly: ' .. tostring(err)) \ @@ -125,7 +128,8 @@ fn offer_rejects_a_second_argument_and_records_nothing() { run_scalar(&vm, &prologue, &null_emitter(), "Section") .expect("refused offers must not poison recording"); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("tool scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("tool scope must snapshot"); assert_eq!( scope.iter().map(ToolBinding::alias).collect::>(), @@ -150,7 +154,7 @@ fn an_unknown_id_fails_before_scope_closure() { assert!( error .to_string() - .contains("tools.offer: \"fixtures/missing\" is not a catalog tool in this run"), + .contains("tools.offer: \"fixtures/missing\" is not a tool in this run"), "the error names the id: {error}" ); vm.teardown(); @@ -189,7 +193,7 @@ fn offer_over_an_empty_offering_fails_in_a_chunk() { assert!( error .to_string() - .contains("tools.offer: \"web/search\" is not a catalog tool in this run"), + .contains("tools.offer: \"web/search\" is not a tool in this run"), "the error must name the id: {error}" ); } @@ -205,9 +209,7 @@ fn offer_over_an_empty_offering_fails_in_a_prologue() { ) .expect_err("an unoffered id must fail loudly"); assert!( - error - .to_string() - .contains("is not a catalog tool in this run"), + error.to_string().contains("is not a tool in this run"), "the error must report the missing tool: {error}" ); vm.teardown(); @@ -217,7 +219,8 @@ fn offer_over_an_empty_offering_fails_in_a_prologue() { fn a_section_vm_over_an_empty_offering_snapshots_to_an_empty_scope() { let vm = TestVm::new().build(); let (bindings, runtime) = vm.tool_bag_handles(); - let scope = current_tool_bindings(&bindings, &runtime).expect("an empty scope must snapshot"); + let scope = current_tool_bindings(&bindings, &runtime, vm.adhoc_tools()) + .expect("an empty scope must snapshot"); assert!(scope.is_empty()); vm.teardown(); } diff --git a/crates/runvil-internal/lua/src/tools.rs b/crates/runvil-internal/lua/src/tools.rs index 9e1f77902..b02c15eab 100644 --- a/crates/runvil-internal/lua/src/tools.rs +++ b/crates/runvil-internal/lua/src/tools.rs @@ -1,50 +1,55 @@ -//! The `tools` namespace: scoping, invocation, and counts. +//! The `tools` namespace: offering, adhoc tools, invocation, and counts. //! //! One Lua table holds every tool operation, mirroring the `models.*` //! namespacing of model operations. The shared [`ToolSet`] holds every //! catalog tool the run can offer, each bound under its wire name: its id //! with `/` and `.` replaced by `_`, such as `web_fetch`. The model sees -//! only wire names; Lua names a tool by its canonical id (`web/fetch`) or -//! by its tool object, and never by a global. `required` and `extras` list -//! the tool objects of the declared Packs and of every other Pack, -//! `get` reads one by id, `offer` scopes tools into the section, -//! `offer_local` registers a prompt-author Lua function as a tool, `call` -//! dispatches a catalog or local tool (installed by the coroutine shim -//! prelude, since dispatch suspends), and `calls` is the read-only -//! dispatch counter surface, keyed by id for a catalog tool and by alias -//! for a local one. Offering an id the run cannot offer is a -//! hard error, and so is every offer once the section's first loop round -//! rendered its tool list. The installation logic sits here, out of the -//! VM driver; the VM only calls the installers in setup order. +//! only wire names; Lua names a tool by its id (`web/fetch`) or by its +//! tool object, and a Pack by its id (`web`) or its Pack object, never by +//! a global. `adhoc` defines a prompt-author Lua function as a tool of the +//! adhoc Pack, `get` reads one tool object by id, `packs` reads the Pack +//! objects, `offer` and `offer_dynamic` remember tools and Packs for the +//! section's rendered and dynamic lists, `call` dispatches a tool by id +//! or object (installed by the coroutine shim prelude, since dispatch +//! suspends), and `calls` is the read-only dispatch counter surface, keyed +//! by id. Offering what the run cannot offer is a hard error, and so is +//! every offer once the section's first loop round rendered its tool +//! list. The installation logic sits here, out of the VM driver; the VM +//! only calls the installers in setup order. use std::sync::{Arc, Mutex}; -use mlua::{FromLuaMulti, Function, Lua, MultiValue, Table, Value, Variadic}; +use mlua::{Function, Lua, MultiValue, Table, Value}; use runvil_model_client::detail::tool_schema_new; -use runvil_types::tools::ToolId; +use runvil_types::tools::{ToolId, ToolInfo}; +use serde_json::{Value as Json, json}; use crate::alias::Alias; use crate::error::{Error, Result}; use crate::handles::{ToolBinding, ToolSet}; +use crate::json::to_json; use crate::messages::MessageList; -use crate::scope::{ToolCallCounts, ToolRuntime}; -use crate::vm::LocalTools; +use crate::packs::{ADHOC_PACK, install_pack_list, install_pack_objects}; +use crate::proxy::key_text; +use crate::scope::{OfferMode, ToolCallCounts, ToolRuntime}; +use crate::vm::AdhocTools; mod decode; mod objects; +mod offer; mod userdata; -pub(crate) use decode::tool_name; +pub(crate) use decode::{model_tool_name, tool_name}; -use decode::{collect_offer_entries, local_params_schema}; +use decode::adhoc_params_schema; pub(crate) use objects::tool_object; -use objects::{install_tool_lists, install_tool_objects}; +use objects::{insert_tool_object, install_tool_get, install_tool_objects, remove_tool_object}; +use offer::{Offering, install_offer}; -/// The registry key of the VM's local-tool handler table: -/// `tools.offer_local` writes each handler under its alias, and the -/// scheduler's `Local` answer reads it back to hand the function to the -/// shim. -const LOCAL_HANDLERS_REGISTRY: &str = "runvil.tools.local_handlers"; +/// The registry key of the VM's adhoc-tool handler table: `tools.adhoc` +/// writes each handler under its tool's wire name, and the scheduler's +/// `Adhoc` answer reads it back to hand the function to the shim. +const ADHOC_HANDLERS_REGISTRY: &str = "runvil.tools.adhoc_handlers"; /// The refusal of an offer function once the section's tool list is /// rendered, after the calling function's name. @@ -52,6 +57,10 @@ const TOOL_LIST_FIXED: &str = "the section's tool list is fixed at its first mod make every offer before the first loop, or offer in another \ section"; +/// The prefix of a `tools.adhoc` JSON Schema `parameters` table that has no +/// JSON form. +const PARAMETERS_NOT_JSON: &str = "tools.adhoc: parameters schema cannot be encoded as JSON"; + /// Refuses `call`, an offer function, once `list` holds the section's /// rendered tool list. pub(crate) fn refuse_after_render(call: &str, list: &MessageList) -> mlua::Result<()> { @@ -61,35 +70,40 @@ pub(crate) fn refuse_after_render(call: &str, list: &MessageList) -> mlua::Resul Ok(()) } -/// Reads the handler `tools.offer_local` registered under `alias` from the -/// VM's handler table. +/// Reads the handler `tools.adhoc` registered under the wire name `name` +/// from the VM's handler table. /// /// # Errors /// Returns an internal-invariant error when the table is absent or holds no -/// function under `alias`: the scheduler answers `Local` only for an alias -/// the VM reports as registered. -pub(crate) fn local_handler(lua: &Lua, alias: &str) -> mlua::Result { - let handlers: Option
= lua.named_registry_value(LOCAL_HANDLERS_REGISTRY)?; +/// function under `name`: the scheduler answers `Adhoc` only for a tool +/// the VM reports as defined. +pub(crate) fn adhoc_handler(lua: &Lua, name: &str) -> mlua::Result { + let handlers: Option
= lua.named_registry_value(ADHOC_HANDLERS_REGISTRY)?; let handler = match handlers { - Some(handlers) => handlers.raw_get::(alias)?, + Some(handlers) => handlers.raw_get::(name)?, None => Value::Nil, }; match handler { Value::Function(handler) => Ok(handler), _ => Err(mlua::Error::external(Error::Internal( - "a local tool answer names a registered handler".into(), + "an adhoc tool answer names a registered handler".into(), ))), } } /// Installs the read-only `tools.calls` counter table over `counts`; -/// `offered`, the ids of every tool the run can offer, feeds the -/// unknown-key diagnostic. +/// `offered`, the ids of every tool the run can offer, and `adhoc`, the +/// VM's adhoc tools, feed the unknown-key diagnostic. /// /// # Errors /// Returns [`Error::LuaRuntime`] if the Lua table or callbacks cannot be created or /// installed. -fn install_lua_tool_calls(lua: &Lua, counts: &ToolCallCounts, offered: Vec) -> Result<()> { +fn install_lua_tool_calls( + lua: &Lua, + counts: &ToolCallCounts, + offered: Vec, + adhoc: AdhocTools, +) -> Result<()> { let globals = lua.globals(); let tools: Table = globals.raw_get("tools").map_err(Error::lua)?; @@ -101,23 +115,28 @@ fn install_lua_tool_calls(lua: &Lua, counts: &ToolCallCounts, offered: Vec Result { - let counts = ToolCallCounts::new(bindings.iter().map(|b| b.id().to_string())); + let counts = ToolCallCounts::new(ids.iter().map(ToString::to_string)); let offered: Vec = bound_tools .offered() .iter() .map(|binding| binding.id().to_string()) .collect(); - install_lua_tool_calls(lua, &counts, offered)?; + install_lua_tool_calls(lua, &counts, offered, adhoc.clone())?; Ok(counts) } -/// Resolves each of `call`'s entries to its offered binding's wire name, -/// checking every entry before the caller records any: an entry must parse -/// as a tool id, so a wire name, a malformed id, or a local alias is -/// refused as an id the run does not offer. -fn resolve_entries(call: &str, set: &ToolSet, entries: Vec) -> mlua::Result> { - entries - .into_iter() - .map(|name| { - let binding = match ToolId::parse(&name) { - Ok(_) => set.offered_binding(&name), - Err(_) => None, - }; - match binding { - Some(binding) => Ok(binding.alias().to_owned()), - None => Err(mlua::Error::external(format!( - "{call}: {name:?} is not a catalog tool in this run" - ))), - } - }) - .collect() -} - -/// Installs the tool scoping and local-tool APIs into one section VM (H1 -/// included: there is one install path for every section). +/// Installs the tool and Pack APIs into one section VM (H1 included: there +/// is one install path for every section): the tool objects, the Pack +/// objects, `tools.packs`, both offer verbs, `tools.adhoc`, and +/// `tools.get`. /// /// The coroutine shim prelude installs the suspending `tools.call` on this /// table as a Lua function, because yield cannot cross the Rust callback -/// boundary. Both offer functions refuse once `list`, the section's +/// boundary. Both offer verbs refuse once `list`, the section's /// conversation, holds a rendered tool list. /// /// # Errors @@ -201,96 +201,176 @@ pub(crate) fn install_tools( globals: &Table, set: &Arc, runtime: &Arc>, - local_tools: &LocalTools, + adhoc_tools: &AdhocTools, list: &MessageList, ) -> Result<()> { let tools = lua.create_table().map_err(Error::lua)?; install_tool_objects(lua, set)?; - install_offer(lua, &tools, set, runtime, list)?; - install_tool_lists(lua, &tools, set)?; - install_offer_local(lua, &tools, local_tools, list)?; + let names = install_pack_objects(lua, set, adhoc_tools)?; + install_pack_list(lua, &tools, names)?; + let offering = Offering { + set: Arc::clone(set), + runtime: Arc::clone(runtime), + adhoc: adhoc_tools.clone(), + list: list.clone(), + }; + install_offer(lua, &tools, "offer", OfferMode::Static, &offering)?; + install_offer(lua, &tools, "offer_dynamic", OfferMode::Dynamic, &offering)?; + install_adhoc(lua, &tools, adhoc_tools)?; + install_tool_get(lua, &tools)?; globals.raw_set("tools", tools).map_err(Error::lua) } -/// Installs `tools.offer(tool)` and its array form: each tool enters the -/// section's scope once, under its wire name, in first-offer order. -fn install_offer( - lua: &Lua, - tools: &Table, - set: &Arc, - runtime: &Arc>, - list: &MessageList, -) -> Result<()> { - let frozen = Arc::clone(set); - let state = Arc::clone(runtime); - let list = list.clone(); - let offer = lua - .create_function(move |_, args: Variadic| { - refuse_after_render("tools.offer", &list)?; - let entries = collect_offer_entries("tools.offer", args)?; - let resolved = resolve_entries("tools.offer", &frozen, entries)?; - let mut state = state - .lock() - .map_err(|_| mlua::Error::external("tool declaration runtime was poisoned"))?; - for name in resolved { - if !state.added.iter().any(|existing| existing == &name) { - state.added.push(name); - } - } - Ok(()) - }) - .map_err(Error::lua)?; - tools.set("offer", offer).map_err(Error::lua) -} - -/// Installs `tools.offer_local(alias, description, params, handler)` over -/// a fresh handler table: the alias and schema register on `local_tools` -/// for membership and advertising, and the handler is written under the -/// alias for the scheduler's `Local` answer to read back. An alias may -/// equal an offered tool's wire name; the local tool wins in scope. The -/// arguments convert after the rendered-list check, so that refusal comes -/// first. -fn install_offer_local( - lua: &Lua, - tools: &Table, - local_tools: &LocalTools, - list: &MessageList, -) -> Result<()> { +/// Installs `tools.adhoc(attrs, f)` over a fresh handler table: `f` is +/// written under the tool's wire name for the scheduler's `Adhoc` answer +/// to read back, the tool object is stored in the tool-object registry, +/// the tool registers on `adhoc_tools`, and the tool object is returned. +/// A definition is all or nothing: the tool registers last, and a failed +/// registration clears the handler and the object, so a definition that +/// fails at any point leaves no tool defined and the name free to define +/// again. Defining offers nothing, and works before and after the section +/// renders. +fn install_adhoc(lua: &Lua, tools: &Table, adhoc_tools: &AdhocTools) -> Result<()> { lua.set_named_registry_value( - LOCAL_HANDLERS_REGISTRY, + ADHOC_HANDLERS_REGISTRY, lua.create_table().map_err(Error::lua)?, ) .map_err(Error::lua)?; - let local = local_tools.clone(); - let list = list.clone(); - let offer_local = lua + let adhoc = adhoc_tools.clone(); + let define = lua .create_function(move |lua, args: MultiValue| { - refuse_after_render("tools.offer_local", &list)?; - let (alias, description, params, handler) = - <(String, String, Table, Function)>::from_lua_args(args, 1, None, lua)?; - let alias = Alias::parse(alias).map_err(mlua::Error::external)?; - if local - .contains(alias.as_str()) - .map_err(mlua::Error::external)? - { + let mut args = args.into_iter(); + let attrs = match args.next().unwrap_or(Value::Nil) { + Value::Table(attrs) => attrs, + other => { + return Err(mlua::Error::external(format!( + "tools.adhoc takes an attrs table first, got {}", + other.type_name() + ))); + } + }; + let handler = match args.next().unwrap_or(Value::Nil) { + Value::Function(handler) => handler, + other => { + return Err(mlua::Error::external(format!( + "tools.adhoc takes the tool's function second, got {}", + other.type_name() + ))); + } + }; + if let Some(extra) = args.next() { return Err(mlua::Error::external(format!( - "tools.offer_local alias {:?} is already registered", - alias.as_str() + "tools.adhoc takes two arguments, got extra {}", + extra.type_name() ))); } - let parameters = local_params_schema(¶ms)?; - let schema = tool_schema_new(alias.as_str(), description, parameters) - .map_err(mlua::Error::external)?; - let handlers: Table = lua.named_registry_value(LOCAL_HANDLERS_REGISTRY)?; - handlers.raw_set(alias.as_str(), handler)?; - local - .register(alias, schema) - .map_err(mlua::Error::external)?; - Ok(()) + let (name, info) = adhoc_info(lua, &attrs, &adhoc)?; + let binding = ToolBinding::from_info(&name, &info); + let handlers: Table = lua.named_registry_value(ADHOC_HANDLERS_REGISTRY)?; + handlers.raw_set(name.as_str(), handler)?; + let object = insert_tool_object(lua, &binding)?; + let id = binding.id().to_string(); + if let Err(error) = adhoc.register(binding) { + handlers.raw_set(name, Value::Nil)?; + remove_tool_object(lua, &id)?; + return Err(mlua::Error::external(error)); + } + Ok(object) }) .map_err(Error::lua)?; - tools.set("offer_local", offer_local).map_err(Error::lua) + tools.set("adhoc", define).map_err(Error::lua) +} + +/// Reads `tools.adhoc`'s attrs as the tool's wire name and info, checking +/// the keys, the name, the description, the parameters, and the schema, +/// and that neither the name nor the id is taken. +fn adhoc_info(lua: &Lua, attrs: &Table, adhoc: &AdhocTools) -> mlua::Result<(String, ToolInfo)> { + let mut unknown: Vec = Vec::new(); + for pair in attrs.pairs::() { + let (key, _) = pair?; + let known = matches!(&key, Value::String(text) + if text == "name" || text == "description" || text == "parameters"); + if !known { + unknown.push(key_text(&key)); + } + } + unknown.sort(); + if let Some(key) = unknown.first() { + return Err(mlua::Error::external(format!( + "tools.adhoc: unknown key {key:?}; accepted keys are name, description, parameters" + ))); + } + let name = attr_text(attrs, "name")?; + let name = String::from(Alias::parse(name.clone()).map_err(|_| { + mlua::Error::external(format!( + "tools.adhoc: invalid name {name:?}: expected [A-Za-z][A-Za-z0-9_-]{{0,63}}" + )) + })?); + let description = attr_text(attrs, "description")?; + let id = format!("{ADHOC_PACK}/{}", name.to_ascii_lowercase()); + if adhoc + .binding(&name) + .map_err(mlua::Error::external)? + .is_some() + { + return Err(mlua::Error::external(format!( + "tools.adhoc name {name:?} is already registered" + ))); + } + if let Some(holder) = adhoc.binding(&id).map_err(mlua::Error::external)? { + return Err(mlua::Error::external(format!( + "tools.adhoc name {name:?} has the id {id}, which {:?} already holds", + holder.alias() + ))); + } + let parameters = match attrs.raw_get::("parameters")? { + Value::Nil => json!({ "type": "object", "properties": {}, "required": [] }), + Value::Table(parameters) => parameters_schema(lua, ¶meters)?, + other => { + return Err(mlua::Error::external(format!( + "tools.adhoc: parameters must be a table, got {}", + other.type_name() + ))); + } + }; + tool_schema_new(name.clone(), description.clone(), parameters.clone()) + .map_err(|error| mlua::Error::external(format!("tools.adhoc: {error}")))?; + let id = ToolId::parse(&id).map_err(|error| { + mlua::Error::external(Error::Internal( + format!("an adhoc tool's id is a tool id: {error}").into(), + )) + })?; + Ok((name, ToolInfo::new(id, description, parameters))) +} + +/// Reads the required string attr `key`. +fn attr_text(attrs: &Table, key: &str) -> mlua::Result { + match attrs.raw_get::(key)? { + Value::String(text) => Ok(text.to_string_lossy()), + Value::Nil => Err(mlua::Error::external(format!( + "tools.adhoc: {key:?} is required" + ))), + other => Err(mlua::Error::external(format!( + "tools.adhoc: {key:?} must be a string, got {}", + other.type_name() + ))), + } +} + +/// The schema a `parameters` table declares: a JSON Schema object when +/// its `type` is `"object"`, as `json.encode` converts it, else the short +/// form, one type name or `{type, description}` per parameter. +fn parameters_schema(lua: &Lua, parameters: &Table) -> mlua::Result { + match parameters.raw_get::("type")? { + Value::String(kind) if kind == "object" => to_json( + lua, + PARAMETERS_NOT_JSON, + false, + &Value::Table(parameters.clone()), + ), + _ => adhoc_params_schema(parameters), + } } #[cfg(test)] diff --git a/crates/runvil-internal/lua/src/tools/decode.rs b/crates/runvil-internal/lua/src/tools/decode.rs index a5ae2a8d4..764b6857e 100644 --- a/crates/runvil-internal/lua/src/tools/decode.rs +++ b/crates/runvil-internal/lua/src/tools/decode.rs @@ -1,90 +1,75 @@ //! Shared value decoding for the `tools.*` Engine functions. //! -//! The id-or-tool-object polymorphism lives here once: `tools.offer` and -//! the `tools.call` protocol parse both accept a bare string or a tool -//! object and resolve each to the name it stands for, a tool object -//! standing for its canonical id. The -//! `tools.offer_local` params-table-to-JSON-Schema conversion sits beside -//! it as the namespace's other argument decode. +//! The id-or-tool-object polymorphism of `tools.call` lives here once: a +//! string holding a `/` is a tool id and a tool object stands for its id. +//! The short-form `parameters` conversion of `tools.adhoc` sits beside it +//! as the namespace's other argument decode. -use mlua::{Value, Variadic}; +use mlua::Value; use serde_json::{Value as Json, json}; use super::userdata::LuaToolHandle; -/// Resolves one string or tool object to the name it stands for. +/// Resolves the tool a script's `tools.call` names to its id. /// -/// This is the single decode behind the namespace's argument polymorphism: -/// a string is the name verbatim (a tool id, or for `tools.call` a local -/// alias), a tool object contributes its canonical id, and anything else -/// is an argument error naming the accepted forms. +/// A string holding a `/` is the id verbatim, a tool object contributes +/// its id, and anything else is an argument error naming the accepted +/// forms. /// /// # Errors -/// Returns an `mlua` external error when the value is neither a string nor -/// a tool object. +/// Returns an `mlua` external error when the value is neither a string +/// holding a `/` nor a tool object. pub(crate) fn tool_name(value: &Value) -> mlua::Result { - let rejected = || { - mlua::Error::external(format!( - "tools.call takes a tool id, a local alias, or a tool object, got {}", - value.type_name() - )) - }; match value { - Value::String(s) => Ok(s.to_string_lossy()), - // A userdata that is not a tool object (a model handle) takes the - // same rejection as any other wrong type. - Value::UserData(ud) => ud - .borrow::() - .map(|handle| handle.id().to_string()) - .map_err(|_| rejected()), - _ => Err(rejected()), + Value::String(text) => { + let name = text.to_string_lossy(); + if name.contains('/') { + Ok(name) + } else { + Err(mlua::Error::external(format!( + "tools.call: {name:?} is not a tool id" + ))) + } + } + other => object_id(other).ok_or_else(|| { + mlua::Error::external(format!( + "tools.call takes a tool id or a tool object, got {}", + other.type_name() + )) + }), } } -/// Reads one element of `call`'s arguments as a name: a string or a tool -/// object. -fn entry_name(call: &str, value: &Value) -> mlua::Result { - tool_name(value).map_err(|_| { - mlua::Error::external(format!( - "{call} takes tool ids, tool objects, or arrays of them, got {}", - value.type_name() - )) - }) -} - -/// Flattens the arguments of `call` (`tools.offer`) into the names they -/// stand for, naming `call` in every argument-shape error. +/// Resolves the tool a model-issued call names: the wire name the model +/// sent, verbatim. /// -/// `tools.offer(tool)` takes one tool id or tool object. A table is the -/// array form, `tools.offer({"web/search", "web/fetch"})` or -/// `tools.offer(tools.extras())`, which covers bulk. Any second argument is -/// refused. -pub(super) fn collect_offer_entries( - call: &str, - args: Variadic, -) -> mlua::Result> { - let mut args = args.into_iter(); - let Some(target) = args.next() else { - return Ok(Vec::new()); - }; - if let Some(extra) = args.next() { - return Err(mlua::Error::external(format!( - "{call} takes one tool or a list of them, got extra {}", - extra.type_name() - ))); +/// # Errors +/// Returns an `mlua` external error when the value is not a string. +pub(crate) fn model_tool_name(value: &Value) -> mlua::Result { + match value { + Value::String(text) => Ok(text.to_string_lossy()), + other => Err(mlua::Error::external(format!( + "a model-issued tool call names a tool by its wire name, got {}", + other.type_name() + ))), } - match target { - Value::Table(table) => table - .sequence_values::() - .map(|item| entry_name(call, &item?)) - .collect(), - single => Ok(vec![entry_name(call, &single)?]), +} + +/// The id of the tool object `value` is, or `None` for any other value, +/// a userdata that is not a tool object included. +pub(super) fn object_id(value: &Value) -> Option { + match value { + Value::UserData(object) => object + .borrow::() + .ok() + .map(|handle| handle.id().to_string()), + _ => None, } } -/// Builds the JSON Schema `parameters` object from a `tools.offer_local` params -/// table. Each value is a bare type string or a `{type, description}` array; -/// every declared parameter is required. +/// Builds the JSON Schema `parameters` object from the short form of a +/// `tools.adhoc` `parameters` table. Each value is a bare type string or a +/// `{type, description}` array; every declared parameter is required. /// /// The `required` list is the only Rust-side `table.pairs` walk in this crate /// that builds an ordered array without sorting it, so it sorts its names @@ -93,7 +78,7 @@ pub(super) fn collect_offer_entries( /// walk's order is unspecified, so a schema left in it would read differently /// in two VMs; `properties` needs no sort because it is a `serde_json::Map`, /// a `BTreeMap` with no `preserve_order` in the graph. -pub(super) fn local_params_schema(params: &mlua::Table) -> mlua::Result { +pub(super) fn adhoc_params_schema(params: &mlua::Table) -> mlua::Result { let mut properties = serde_json::Map::new(); let mut required = Vec::new(); for pair in params.pairs::() { @@ -103,13 +88,13 @@ pub(super) fn local_params_schema(params: &mlua::Table) -> mlua::Result { Value::Table(t) => (t.get::(1)?, t.get::>(2)?), _ => { return Err(mlua::Error::external(format!( - "tools.offer_local param {name:?} must be a type string or a {{type, description}} array" + "tools.adhoc: param {name:?} must be a type string or a {{type, description}} array" ))); } }; if !matches!(ty.as_str(), "string" | "integer" | "number" | "boolean") { return Err(mlua::Error::external(format!( - "tools.offer_local param {name:?} has unsupported type {ty:?}: \ + "tools.adhoc: param {name:?} has unsupported type {ty:?}: \ expected \"string\", \"integer\", \"number\", or \"boolean\"" ))); } diff --git a/crates/runvil-internal/lua/src/tools/objects.rs b/crates/runvil-internal/lua/src/tools/objects.rs index 721581b3e..a5a7e83b2 100644 --- a/crates/runvil-internal/lua/src/tools/objects.rs +++ b/crates/runvil-internal/lua/src/tools/objects.rs @@ -1,24 +1,23 @@ -//! Tool objects: one frozen [`LuaToolHandle`] per offered tool, built once -//! per VM, and `tools.required`, `tools.extras`, and `tools.get` over them. +//! Tool objects: one frozen [`LuaToolHandle`] per offered catalog tool, +//! built once per VM, one per adhoc tool, added as `tools.adhoc` defines +//! it, and `tools.get` over them. //! -//! Every list hands out the same userdata for a tool, so a tool read -//! through any of them compares `==` to the same tool read through -//! another, while each list call builds a fresh array. - -use std::sync::Arc; +//! Every read hands out the same userdata for a tool, so a tool read +//! through `tools.get` compares `==` to the same tool read through a +//! Pack's `tools`, while each `tools` read builds a fresh array. use mlua::{AnyUserData, Lua, Table, Value}; use super::userdata::LuaToolHandle; use crate::error::{Error, Result}; -use crate::handles::ToolSet; +use crate::handles::{ToolBinding, ToolSet}; -/// The registry key of the VM's tool-object table: one object per offered -/// tool, keyed by id, built when `install_tools` runs. -const TOOL_OBJECTS_REGISTRY: &str = "runvil.tools.objects"; +/// The registry key of the VM's tool-object table: one object per tool, +/// keyed by id. +pub(super) const TOOL_OBJECTS_REGISTRY: &str = "runvil.tools.objects"; -/// The VM's tool object for the catalog id `id`, or `None` when the run -/// cannot offer that tool. +/// The VM's tool object for the id `id`, or `None` when the run cannot +/// offer that tool and no adhoc tool holds that id. /// /// # Errors /// Returns an `mlua` error when the registry cannot be read. @@ -52,32 +51,32 @@ pub(super) fn install_tool_objects(lua: &Lua, set: &ToolSet) -> Result<()> { .map_err(Error::lua) } -/// Installs `tools.required()` and `tools.extras()`, the offered tools -/// whose Pack the frontmatter declares and every other one, and -/// `tools.get(id)`. +/// Adds the tool object of the adhoc tool `binding` binds and returns it. /// /// # Errors -/// Returns [`Error::Lua`] if a function cannot be created or installed. -pub(super) fn install_tool_lists(lua: &Lua, tools: &Table, set: &Arc) -> Result<()> { - for (name, required) in [("required", true), ("extras", false)] { - let set = Arc::clone(set); - let list = lua - .create_function(move |lua, ()| { - let objects = lua.create_table()?; - for binding in set - .offered() - .iter() - .filter(|binding| set.declared().contains(&binding.id().pack()) == required) - { - if let Some(object) = tool_object(lua, &binding.id().to_string())? { - objects.raw_push(object)?; - } - } - Ok(objects) - }) - .map_err(Error::lua)?; - tools.set(name, list).map_err(Error::lua)?; - } +/// Returns an `mlua` error when the object cannot be created or stored. +pub(super) fn insert_tool_object(lua: &Lua, binding: &ToolBinding) -> mlua::Result { + let objects: Table = lua.named_registry_value(TOOL_OBJECTS_REGISTRY)?; + let object = lua.create_userdata(LuaToolHandle::from_binding(binding))?; + objects.raw_set(binding.id().to_string(), object.clone())?; + Ok(object) +} + +/// Clears the tool object stored under the id `id`. +/// +/// # Errors +/// Returns an `mlua` error when the tool-object table cannot be read or +/// written. +pub(super) fn remove_tool_object(lua: &Lua, id: &str) -> mlua::Result<()> { + let objects: Table = lua.named_registry_value(TOOL_OBJECTS_REGISTRY)?; + objects.raw_set(id, Value::Nil) +} + +/// Installs `tools.get(id)`. +/// +/// # Errors +/// Returns [`Error::Lua`] if the function cannot be created or installed. +pub(super) fn install_tool_get(lua: &Lua, tools: &Table) -> Result<()> { let get = lua .create_function(|lua, id: Value| match id { Value::String(id) => tool_object(lua, &id.to_str()?), diff --git a/crates/runvil-internal/lua/src/tools/offer.rs b/crates/runvil-internal/lua/src/tools/offer.rs new file mode 100644 index 000000000..6db9f4b2d --- /dev/null +++ b/crates/runvil-internal/lua/src/tools/offer.rs @@ -0,0 +1,239 @@ +//! The offer verbs, `tools.offer` and `tools.offer_dynamic`: one +//! installer, parameterized by name and mode, and its decode. +//! +//! A verb takes one argument: a string with a `/`, a tool id; a string +//! without one, a Pack id, since Pack ids are one segment and tool ids +//! two or more; a tool object; a Pack object; or an array mixing them. +//! An array is its `#t` entries, read through `__index`, with no gap, no +//! nested table, and no raw key outside `1..#t`. The rendered-list +//! refusal comes first, and every entry is checked before any is +//! recorded. + +use std::sync::{Arc, Mutex}; + +use mlua::{Lua, Table, Value, Variadic}; +use runvil_types::packs::PackId; +use runvil_types::tools::ToolId; + +use super::refuse_after_render; +use super::userdata::LuaToolHandle; +use crate::error::{Error, Result}; +use crate::handles::ToolSet; +use crate::messages::MessageList; +use crate::packs::{LuaPackHandle, is_adhoc, pack_object}; +use crate::proxy::key_text; +use crate::scope::{OfferMode, OfferTarget, ToolRuntime}; +use crate::vm::AdhocTools; +use crate::walk_budget::HELD_KEY_LIMIT; + +/// What an offer verb checks its entries against and records into: the +/// run's tool set, the section's offers, the VM's adhoc tools, and the +/// section's conversation, whose rendered list fixes the offers. +#[derive(Clone)] +pub(super) struct Offering { + pub(super) set: Arc, + pub(super) runtime: Arc>, + pub(super) adhoc: AdhocTools, + pub(super) list: MessageList, +} + +/// Installs the offer verb `tools.` on `tools`, recording under +/// `mode`. +/// +/// # Errors +/// Returns [`Error::LuaRuntime`] if the function cannot be created or +/// installed. +pub(super) fn install_offer( + lua: &Lua, + tools: &Table, + key: &'static str, + mode: OfferMode, + offering: &Offering, +) -> Result<()> { + let verb = format!("tools.{key}"); + let offering = offering.clone(); + let offer = lua + .create_function(move |lua, args: Variadic| { + refuse_after_render(&verb, &offering.list)?; + let targets = offering.targets(lua, &verb, args)?; + let mut runtime = offering + .runtime + .lock() + .map_err(|_| mlua::Error::external("tool declaration runtime was poisoned"))?; + for target in targets { + match target { + OfferTarget::Tool(id) => runtime.add_tool(id, mode), + OfferTarget::Pack(pack) => runtime.add_pack(pack, mode), + } + } + Ok(()) + }) + .map_err(Error::lua)?; + tools.set(key, offer).map_err(Error::lua) +} + +impl Offering { + /// Reads `verb`'s arguments as the targets they name, checking every + /// entry before returning any. No argument names nothing. + fn targets( + &self, + lua: &Lua, + verb: &str, + args: Variadic, + ) -> mlua::Result> { + let mut args = args.into_iter(); + let Some(target) = args.next() else { + return Ok(Vec::new()); + }; + if let Some(extra) = args.next() { + return Err(mlua::Error::external(format!( + "{verb} takes one tool or Pack, or an array of them, got extra {}", + extra.type_name() + ))); + } + match target { + Value::Table(table) => array(verb, &table)? + .iter() + .enumerate() + .map(|(index, entry)| self.target(lua, verb, entry, Some(index + 1))) + .collect(), + single => Ok(vec![self.target(lua, verb, &single, None)?]), + } + } + + /// Reads one entry, at `index` of an array or alone, as the tool or + /// Pack it names. A tool object under the `adhoc` Pack must be a + /// defined adhoc tool, since only those have a binding to render. + fn target( + &self, + lua: &Lua, + verb: &str, + entry: &Value, + index: Option, + ) -> mlua::Result { + match entry { + Value::String(text) => self.named(lua, verb, &text.to_string_lossy()), + Value::UserData(object) => { + if let Ok(tool) = object.borrow::() { + let id = tool.id(); + if is_adhoc(&id.pack()) { + return self.named(lua, verb, &id.to_string()); + } + return Ok(OfferTarget::Tool(id.clone())); + } + if let Ok(pack) = object.borrow::() { + return Ok(OfferTarget::Pack(pack.name().clone())); + } + Err(wrong_type(verb, entry, index)) + } + other => Err(wrong_type(verb, other, index)), + } + } + + /// Reads a string entry: with a `/`, the id of a tool in the offering + /// or a defined adhoc tool; without one, the id of a Pack with an + /// object. + fn named(&self, lua: &Lua, verb: &str, name: &str) -> mlua::Result { + if name.contains('/') { + let known = match ToolId::parse(name) { + Ok(id) if is_adhoc(&id.pack()) => self + .adhoc + .binding(name) + .map_err(mlua::Error::external)? + .is_some() + .then_some(id), + Ok(id) => self.set.offered_binding(name).is_some().then_some(id), + Err(_) => None, + }; + return known.map(OfferTarget::Tool).ok_or_else(|| { + mlua::Error::external(format!("{verb}: {name:?} is not a tool in this run")) + }); + } + if let Ok(pack) = PackId::parse(name) { + if let Some(Some(reason)) = self.set.roster().get(&pack) { + return Err(mlua::Error::external(format!( + "{verb}: the {pack} Pack is unavailable in this run: {reason}" + ))); + } + if pack_object(lua, name)?.is_some() { + return Ok(OfferTarget::Pack(pack)); + } + } + let hint = match self.wire_named(name)? { + Some(id) => format!(" (did you mean '{id}'?)"), + None => String::new(), + }; + Err(mlua::Error::external(format!( + "{verb}: no Pack named {name:?} in this run; declare it under uses: to require \ + it, or offer it only when tools.packs.get({name:?}) returns it{hint}" + ))) + } + + /// The id of the tool in the offering, or else the defined adhoc tool, + /// whose wire name is `name`. + fn wire_named(&self, name: &str) -> mlua::Result> { + if let Some(binding) = self.set.wire_binding(name) { + return Ok(Some(binding.id().clone())); + } + Ok(self + .adhoc + .binding(name) + .map_err(mlua::Error::external)? + .map(|binding| binding.id().clone())) + } +} + +/// The entries of the array `table`: `#t` of them, honoring `__len`, each +/// read through `__index`. A `#t` above [`HELD_KEY_LIMIT`] is refused +/// before any entry is read, since each entry holds a Lua reference until +/// the offer is recorded. A nil or a table in that range, or a raw key +/// outside it, is refused. +fn array(verb: &str, table: &Table) -> mlua::Result> { + let len = table.len()?; + let count = usize::try_from(len).unwrap_or(if len > 0 { usize::MAX } else { 0 }); + if count > HELD_KEY_LIMIT { + return Err(mlua::Error::external(format!( + "{verb} takes an array of at most {HELD_KEY_LIMIT} entries, got {len}" + ))); + } + let mut entries = Vec::with_capacity(count); + for index in 1..=count { + let entry: Value = table.get(index)?; + if matches!(entry, Value::Nil | Value::Table(_)) { + return Err(wrong_type(verb, &entry, Some(index))); + } + entries.push(entry); + } + for pair in table.pairs::() { + let (key, _) = pair?; + let inside = match key { + Value::Integer(index) => { + usize::try_from(index).is_ok_and(|index| (1..=count).contains(&index)) + } + _ => false, + }; + if !inside { + return Err(mlua::Error::external(format!( + "{verb} takes an array, got a table with the key {}", + key_text(&key) + ))); + } + } + Ok(entries) +} + +/// The refusal of `entry`, at `index` of an array or alone, as a value +/// that names no tool or Pack. +fn wrong_type(verb: &str, entry: &Value, index: Option) -> mlua::Error { + let kind = entry.type_name(); + mlua::Error::external(match index { + Some(index) => format!( + "{verb} takes tool ids, Pack ids, tool objects, or Pack objects in an array, \ + got {kind} at index {index}" + ), + None => format!( + "{verb} takes tool ids, Pack ids, tool objects, Pack objects, or an array of \ + them, got {kind}" + ), + }) +} diff --git a/crates/runvil-internal/lua/src/tools/tests-adhoc.rs b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs new file mode 100644 index 000000000..9ee2a9cbd --- /dev/null +++ b/crates/runvil-internal/lua/src/tools/tests-adhoc.rs @@ -0,0 +1,322 @@ +//! Tests for `tools.adhoc`: the tool object it returns, the id and wire +//! name it gives, the schema it builds from `parameters`, its refusals, +//! a definition that fails partway, the adhoc Pack, and the counts an +//! adhoc tool keys. + +use mlua::{LuaSerdeExt, Table, Value}; +use serde_json::{Value as Json, json}; + +use super::super::objects::TOOL_OBJECTS_REGISTRY; +use super::parameters::search_set; +use crate::Argv; +use crate::test_vm::{TestVm, lua_over, offer_set, raised, scoped}; + +/// The `grab` definition of the listing, held in a local. +const GRAB: &str = "local grab = tools.adhoc({\n\ + name = 'grab',\n\ + description = 'Grab a value',\n\ + parameters = { value = 'string' },\n\ + }, function(args) return 'got ' .. args.value end)\n"; + +/// The schema the adhoc tool with wire name `name` on `vm` binds. +fn adhoc_schema(vm: &crate::SectionVm, name: &str) -> Json { + vm.adhoc_tools() + .binding(name) + .expect("the adhoc tools read") + .unwrap_or_else(|| panic!("{name} is defined")) + .schema() + .clone() +} + +#[test] +fn the_returned_object_is_the_tools_object_and_is_rendered_only_once_offered() { + let vm = lua_over(offer_set()); + let fields: (bool, String, String, String, bool, bool) = vm.eval(&format!( + "{GRAB}\ + return grab == tools.get('adhoc/grab'), grab.id, grab.description, \ + grab.parameters.properties.value.type, grab.pack == tools.packs.get('adhoc'), \ + grab.pack.tools[1] == grab" + )); + assert_eq!( + fields, + ( + true, + "adhoc/grab".to_owned(), + "Grab a value".to_owned(), + "string".to_owned(), + true, + true + ) + ); + assert!(scoped(&vm).is_empty(), "defining a tool offers nothing"); + vm.exec("tools.offer(tools.get('adhoc/grab'))"); + assert_eq!(scoped(&vm), ["grab"]); +} + +#[test] +fn the_id_lowercases_the_name_and_the_wire_name_keeps_it() { + let vm = lua_over(offer_set()); + let id: String = vm.eval( + "local shout = tools.adhoc({ name = 'Shout', description = 'Uppercase text', \ + parameters = { text = 'string' } }, function(args) return args.text:upper() end)\n\ + tools.offer(shout)\n\ + assert(tools.get('adhoc/shout') == shout)\n\ + return shout.id", + ); + assert_eq!(id, "adhoc/shout"); + assert_eq!( + scoped(&vm), + ["Shout"], + "the wire name is the name as written" + ); +} + +#[test] +fn each_refusal_raises_on_the_defining_line() { + let vm = lua_over(offer_set()); + vm.exec(GRAB); + let invalid = "tools.adhoc: invalid name \"1x\": expected [A-Za-z][A-Za-z0-9_-]{0,63}"; + for (call, expected) in [ + ( + "tools.adhoc('grab', function() end)", + "tools.adhoc takes an attrs table first, got string", + ), + ( + "tools.adhoc({ name = 'x', description = 'y' })", + "tools.adhoc takes the tool's function second, got nil", + ), + ( + "tools.adhoc({ name = 'x', description = 'y' }, function() end, 'z')", + "tools.adhoc takes two arguments, got extra string", + ), + ( + "tools.adhoc({ name = 'grab', description = 'again' }, function() end)", + "tools.adhoc name \"grab\" is already registered", + ), + ( + "tools.adhoc({ name = 'GRAB', description = 'again' }, function() end)", + "tools.adhoc name \"GRAB\" has the id adhoc/grab, which \"grab\" already holds", + ), + ( + "tools.adhoc({ name = 'x', description = 'y', run = 1 }, function() end)", + "tools.adhoc: unknown key \"run\"; accepted keys are name, description, parameters", + ), + ( + "tools.adhoc({ description = 'y' }, function() end)", + "tools.adhoc: \"name\" is required", + ), + ( + "tools.adhoc({ name = 'x' }, function() end)", + "tools.adhoc: \"description\" is required", + ), + ( + "tools.adhoc({ name = 7, description = 'y' }, function() end)", + "tools.adhoc: \"name\" must be a string, got integer", + ), + ( + "tools.adhoc({ name = 'x', description = false }, function() end)", + "tools.adhoc: \"description\" must be a string, got boolean", + ), + ( + "tools.adhoc({ name = '1x', description = 'y' }, function() end)", + invalid, + ), + ( + "tools.adhoc({ name = 'x', description = 'y', parameters = 'string' }, function() end)", + "tools.adhoc: parameters must be a table, got string", + ), + ( + "tools.adhoc({ name = 'x', description = 'y', parameters = { p = 'table' } }, \ + function() end)", + "tools.adhoc: param \"p\" has unsupported type \"table\"", + ), + ( + "tools.adhoc({ name = 'x', description = 'y', \ + parameters = { type = 'object', properties = { q = function() end } } }, \ + function() end)", + "tools.adhoc: parameters schema cannot be encoded as JSON: value.properties.q \ + must be JSON data, got function", + ), + ] { + let error = raised(&vm, call); + assert!(error.contains(expected), "{call}: {error}"); + } + let ids: Vec = vm + .adhoc_tools() + .bindings() + .expect("the adhoc tools read") + .iter() + .map(|binding| binding.id().to_string()) + .collect(); + assert_eq!(ids, ["adhoc/grab"], "no refused definition registers"); +} + +#[test] +fn a_definition_whose_tool_object_cannot_be_stored_leaves_no_tool_behind() { + let vm = lua_over(offer_set()); + let lua = vm.lua(); + let objects: Table = lua + .named_registry_value(TOOL_OBJECTS_REGISTRY) + .expect("the tool-object table is installed"); + lua.set_named_registry_value(TOOL_OBJECTS_REGISTRY, false) + .expect("the tool-object registry value is replaced"); + let defined: bool = vm.eval( + "return (pcall(tools.adhoc, { name = 'x', description = 'y' }, \ + function() return 'z' end))", + ); + assert!(!defined, "the object insert fails the definition"); + lua.set_named_registry_value(TOOL_OBJECTS_REGISTRY, objects) + .expect("the tool-object table is restored"); + assert_eq!( + vm.adhoc_tools() + .binding("adhoc/x") + .expect("the adhoc tools read"), + None, + "a failed definition registers nothing" + ); + let same: bool = vm.eval( + "local x = tools.adhoc({ name = 'x', description = 'y' }, function() return 'z' end)\n\ + return x == tools.get('adhoc/x')", + ); + assert!( + same, + "a second definition stores the object tools.get reads" + ); +} + +#[test] +fn parameters_take_the_short_form_a_json_schema_or_nothing() { + let vm = lua_over(offer_set()); + vm.exec( + "tools.adhoc({ name = 'find', description = 'Find notes', \ + parameters = { query = 'string', limit = { 'integer', 'maximum hits' } } }, \ + function() end)\n\ + tools.adhoc({ name = 'typed', description = 'Typed', \ + parameters = { type = 'string' } }, function() end)\n\ + tools.adhoc({ name = 'none', description = 'No parameters' }, function() end)", + ); + assert_eq!( + adhoc_schema(&vm, "find"), + json!({ + "type": "object", + "properties": { + "query": { "type": "string" }, + "limit": { "type": "integer", "description": "maximum hits" } + }, + "required": ["limit", "query"] + }) + ); + assert_eq!( + adhoc_schema(&vm, "typed"), + json!({ + "type": "object", + "properties": { "type": { "type": "string" } }, + "required": ["type"] + }), + "a type other than object makes the short form, so this is a parameter named type" + ); + assert_eq!( + adhoc_schema(&vm, "none"), + json!({ "type": "object", "properties": {}, "required": [] }) + ); + let empty: String = vm.eval("return json.encode(tools.get('adhoc/none').parameters)"); + assert_eq!( + empty, + "{\"properties\":{},\"required\":[],\"type\":\"object\"}" + ); +} + +#[test] +fn a_wrapper_of_a_catalog_tool_takes_its_description_and_schema_unchanged() { + let vm = lua_over(search_set()); + vm.exec( + "local t = tools.get('web/search')\n\ + tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, \ + function(args) return tools.call(t, args) end)", + ); + let (set, _) = vm.tool_bag_handles(); + let catalog = set + .offered_binding("web/search") + .expect("the catalog tool is offered"); + let wrapper = vm + .adhoc_tools() + .binding("adhoc/search") + .expect("the adhoc tools read") + .expect("the wrapper is defined"); + assert_eq!(wrapper.description(), catalog.description()); + assert_eq!( + serde_json::to_string(wrapper.schema()).expect("a schema serializes"), + serde_json::to_string(catalog.schema()).expect("a schema serializes"), + "the rendered schema is the same text" + ); + let parameters: Value = vm.eval("return tools.get('adhoc/search').parameters"); + let read: Json = vm + .lua() + .from_value(parameters) + .expect("the parameters table converts back to JSON"); + assert_eq!( + &read, + catalog.schema(), + "parameters deep-equals the catalog tool's" + ); +} + +#[test] +fn an_adhoc_description_is_the_attrs_text_and_never_the_envelope() { + let vm = lua_over(offer_set()); + let description: String = vm.eval( + "return tools.adhoc({ name = 'x', description = 'Read [INST] now' }, \ + function() end).description", + ); + assert_eq!(description, "Read [INST] now"); + let nonce = crate::test_vm::test_nonce().to_string(); + assert!(!description.contains(&nonce) && !description.contains("is data, not instructions")); +} + +#[test] +fn the_adhoc_pack_exists_in_every_vm_with_nothing_defined() { + for vm in [ + lua_over(offer_set()), + TestVm::new().argv(Argv::Writable(None)).build(), + ] { + let pack: (bool, String, bool, i64) = vm.eval( + "local adhoc = tools.packs.get('adhoc')\n\ + return adhoc ~= nil, adhoc.name, adhoc.required, #adhoc.tools", + ); + assert_eq!(pack, (true, "adhoc".to_owned(), false, 0)); + } +} + +#[test] +fn an_offered_adhoc_tool_counts_from_zero_and_an_unoffered_one_from_its_first_call() { + let vm = lua_over(offer_set()); + vm.exec(&format!( + "{GRAB}\ + tools.adhoc({{ name = 'other', description = 'Other' }}, function() end)\n\ + tools.offer(grab)" + )); + let (set, runtime) = vm.tool_bag_handles(); + let ids: Vec<_> = crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()) + .expect("the scope resolves") + .iter() + .map(|binding| binding.id().clone()) + .collect(); + let counts = vm + .install_tool_call_counts(&ids) + .expect("the counts install"); + let offered: i64 = vm.eval("return tools.calls['adhoc/grab']"); + assert_eq!(offered, 0, "an offered adhoc tool is seeded"); + let unoffered = raised(&vm, "return tools.calls['adhoc/other']"); + assert!( + unoffered.contains( + "(an adhoc tool that was neither offered in this section nor called with tools.call)" + ), + "{unoffered}" + ); + counts.ensure("adhoc/other").expect("a call seeds its id"); + counts + .increment("adhoc/other") + .expect("the seeded id counts"); + let called: i64 = vm.eval("return tools.calls['adhoc/other']"); + assert_eq!(called, 1, "a call gives an unoffered adhoc tool its count"); +} diff --git a/crates/runvil-internal/lua/src/tools/tests-offer-limits.rs b/crates/runvil-internal/lua/src/tools/tests-offer-limits.rs new file mode 100644 index 000000000..662eb335d --- /dev/null +++ b/crates/runvil-internal/lua/src/tools/tests-offer-limits.rs @@ -0,0 +1,77 @@ +//! Tests for the offer verbs' array length bound: an array longer than +//! the held key limit is refused before any entry is read. + +use crate::test_vm::{lua_over, offer_set, offers, raised}; + +#[test] +fn a_len_of_maxinteger_is_refused_and_the_vm_still_offers() { + let vm = lua_over(offer_set()); + let error = raised( + &vm, + "tools.offer(setmetatable({}, { __len = function() return math.maxinteger end }))", + ); + assert!( + error.contains( + "tools.offer takes an array of at most 100000 entries, got 9223372036854775807" + ), + "the refusal names the bound and the reported length: {error}" + ); + vm.exec("tools.offer('web')"); + assert_eq!( + offers(&vm), + ["static:web"], + "the refused offer recorded nothing and the verb still works" + ); +} + +#[test] +fn an_array_past_the_reference_stack_cap_is_refused() { + let vm = lua_over(offer_set()); + let error = raised( + &vm, + "local t = {} for i = 1, 1200000 do t[i] = 'web' end tools.offer(t)", + ); + assert!( + error.contains("got 1200000"), + "an array too long to hold one reference per entry is refused: {error}" + ); + assert!(offers(&vm).is_empty(), "a refusal records nothing"); +} + +#[test] +fn an_array_of_exactly_the_limit_is_accepted() { + let vm = lua_over(offer_set()); + vm.exec("local t = {} for i = 1, 100000 do t[i] = 'web' end tools.offer(t)"); + assert_eq!( + offers(&vm), + ["static:web"], + "an array at the bound is read whole" + ); +} + +#[test] +fn offer_dynamic_refuses_an_array_one_past_the_limit() { + let vm = lua_over(offer_set()); + let error = raised( + &vm, + "local t = {} for i = 1, 100001 do t[i] = 'web' end tools.offer_dynamic(t)", + ); + assert!( + error.contains("tools.offer_dynamic takes an array of at most 100000 entries, got 100001"), + "the bound applies to both verbs and names the verb: {error}" + ); + assert!(offers(&vm).is_empty(), "a refusal records nothing"); +} + +#[test] +fn a_len_too_large_to_allocate_is_refused() { + let vm = lua_over(offer_set()); + let error = raised( + &vm, + "tools.offer(setmetatable({}, { __len = function() return 1 << 40 end }))", + ); + assert!( + error.contains("tools.offer takes an array of at most 100000 entries, got 1099511627776"), + "a length that would abort on allocation is refused first: {error}" + ); +} diff --git a/crates/runvil-internal/lua/src/tools/tests-offering.rs b/crates/runvil-internal/lua/src/tools/tests-offering.rs index b670de36b..7c91a0893 100644 --- a/crates/runvil-internal/lua/src/tools/tests-offering.rs +++ b/crates/runvil-internal/lua/src/tools/tests-offering.rs @@ -1,251 +1,349 @@ -//! Tests for the id-keyed `tools` namespace: tool objects, `tools.offer` -//! over ids and tool objects, the `required` and `extras` lists, -//! `tools.get`, and `tools.offer_local`. +//! Tests for the offer verbs, `tools.offer` and `tools.offer_dynamic`: +//! every accepted form, the string-shape rule, each refusal, the +//! rendered-list refusal first, and the `tools` table they sit on. use std::collections::BTreeMap; -use mlua::{Lua, Value, Variadic}; -use runvil_types::packs::PackId; - +use crate::RenderedTools; use crate::handles::ToolSet; -use crate::test_vm::{lua_over, scoped, tool_at}; -use crate::tools::decode::collect_offer_entries; -use crate::{RenderedTools, ToolBinding}; +use crate::test_vm::{lua_over, offer_set, offers, raised, scoped, tool_at}; -/// A set declaring the Pack `web` and offering its two tools beside the -/// two of the undeclared Pack `gh`, in id order, under their wire names. -fn offering_set() -> ToolSet { - ToolSet::new( - vec![PackId::parse("web").expect("`web` is a valid Pack id")], - std::collections::BTreeMap::new(), - vec![ - ToolBinding::from_info("gh_issues_list", &tool_at("gh/issues.list")), - ToolBinding::from_info("gh_search", &tool_at("gh/search")), - ToolBinding::from_info("web_fetch", &tool_at("web/fetch")), - ToolBinding::from_info("web_search", &tool_at("web/search")), - ], +/// The adhoc tool `grab` the forms below offer as an object. +const GRAB: &str = + "local grab = tools.adhoc({ name = 'grab', description = 'Grab a value' }, function() end)\n"; + +/// Every accepted form, each line through `verb`. +fn forms(verb: &str) -> String { + format!( + "{GRAB}\ + assert(select('#', {verb}('web/search')) == 0, 'the verb returns nothing')\n\ + {verb}('web')\n\ + {verb}(grab)\n\ + {verb}(tools.packs.get('web'))\n\ + {verb}(tools.get('web/search').pack)\n\ + {verb}({{ 'web/search', grab, 'subagents', tools.packs.get('user-input') }})\n\ + {verb}(tools.packs.get('web').tools)\n\ + {verb}(tools.packs.get('adhoc'))\n\ + {verb}(tools.packs.extras())\n\ + {verb}(tools.packs)\n\ + {verb}()\n\ + {verb}({{}})" ) } #[test] -fn a_tool_object_reads_its_id_and_catalog_description_and_refuses_assignment() { - let vm = lua_over(offering_set()); - let result: (String, String, bool, String) = vm.eval( - "local tool = tools.get('gh/search')\n\ - tools.offer(tool)\n\ - local ok, err = pcall(function() tool.description = 'x' end)\n\ - return tool.id, tool.description, ok, tostring(err)", - ); - assert_eq!(result.0, "gh/search"); - assert_eq!( - result.1, "gh/search tool", - "description is the catalog text" - ); - assert!(!result.2, "assigning a field is refused"); - assert!( - result - .3 - .contains("Tool objects are frozen: cannot assign field \"description\""), - "the refusal names the field: {}", - result.3 +fn every_accepted_form_records_each_target_once_in_call_order_under_its_verbs_mode() { + for (verb, mode) in [ + ("tools.offer", "static"), + ("tools.offer_dynamic", "dynamic"), + ] { + let vm = lua_over(offer_set()); + vm.exec(&forms(verb)); + let expected: Vec = [ + "web/search", + "web", + "adhoc/grab", + "subagents", + "user-input", + "web/fetch", + "adhoc", + "gh", + ] + .iter() + .map(|target| format!("{mode}:{target}")) + .collect(); + assert_eq!( + offers(&vm), + expected, + "{verb}: ids, Packs, objects, and arrays record their targets, an equal \ + offer is skipped, and no argument or an empty array records nothing" + ); + } +} + +#[test] +fn the_two_verbs_record_separately_so_the_same_target_takes_both_modes() { + let vm = lua_over(offer_set()); + vm.exec( + "tools.offer('web')\n\ + tools.offer_dynamic('web')\n\ + tools.offer('web')", ); + assert_eq!(offers(&vm), ["static:web", "dynamic:web"]); } #[test] -fn offer_decodes_ids_tool_objects_and_arrays_into_wire_names() { - let vm = lua_over(offering_set()); +fn a_string_with_a_slash_is_a_tool_id_and_one_without_is_a_pack_id() { + let vm = lua_over(offer_set()); vm.exec( - "tools.offer('web/search')\n\ - tools.offer(tools.get('gh/search'))\n\ - tools.offer({ 'gh/issues.list', tools.get('web/fetch'), 'web/search' })\n\ - tools.offer()", + "tools.adhoc({ name = 'Shout', description = 'Uppercase text' }, function() end)\n\ + tools.offer('web/search')\n\ + tools.offer('gh')\n\ + tools.offer('adhoc/shout')\n\ + tools.offer('adhoc')", ); assert_eq!( - scoped(&vm), - ["web_search", "gh_search", "gh_issues_list", "web_fetch"], - "each tool enters the scope once, under its wire name, in first-offer order" + offers(&vm), + [ + "static:web/search", + "static:gh", + "static:adhoc/shout", + "static:adhoc" + ] + ); + let pack = |name: &str| { + format!( + "tools.offer: no Pack named \"{name}\" in this run; declare it under uses: to \ + require it, or offer it only when tools.packs.get(\"{name}\") returns it" + ) + }; + for (call, expected) in [ + ("tools.offer('Web')", pack("Web")), + ( + "tools.offer('Shout')", + format!("{} (did you mean 'adhoc/shout'?)", pack("Shout")), + ), + ( + "tools.offer('web//x')", + "tools.offer: \"web//x\" is not a tool in this run".to_owned(), + ), + ( + "tools.offer('adhoc/other')", + "tools.offer: \"adhoc/other\" is not a tool in this run".to_owned(), + ), + ] { + let error = raised(&vm, call); + assert!( + error.contains(&expected), + "an invalid id takes the message of an unknown one of its shape: {call}: {error}" + ); + } +} + +#[test] +fn a_catalog_tool_object_under_the_adhoc_pack_is_refused_as_an_unknown_tool() { + let catalog_adhoc = crate::ToolBinding::from_info("adhoc_x", &tool_at("adhoc/x")); + let vm = lua_over(ToolSet::new( + Vec::new(), + BTreeMap::new(), + vec![catalog_adhoc], + )); + let error: String = vm.eval( + "local ok, err = pcall(tools.offer, tools.get('adhoc/x'))\n\ + assert(not ok, 'offering the catalog adhoc/x object raises')\n\ + return tostring(err)", + ); + assert!( + error.contains("tools.offer: \"adhoc/x\" is not a tool in this run"), + "the object takes the string form's message: {error}" + ); + assert!(offers(&vm).is_empty(), "the refused offer records nothing"); + assert!( + scoped(&vm).is_empty(), + "the scope resolves without an adhoc/x binding" ); } #[test] -fn offer_records_nothing_when_any_entry_is_not_a_catalog_tool() { - for bad in ["gh/missing", "Not An Id", "gh_search", "helper"] { - let vm = lua_over(offering_set()); - vm.exec("tools.offer_local('helper', 'a local helper', {}, function() return '' end)"); - let error: String = vm.eval(&format!( - "local ok, err = pcall(tools.offer, {{ 'web/search', {bad:?} }})\n\ - assert(not ok, 'the bad entry is refused')\n\ - return tostring(err)" - )); - assert!( - error.contains(&format!( - "tools.offer: {bad:?} is not a catalog tool in this run" - )), - "tools.offer names {bad:?}: {error}" +fn each_refusal_names_its_verb_and_a_refused_call_records_nothing() { + for verb in ["tools.offer", "tools.offer_dynamic"] { + let vm = lua_over(offer_set()); + let unknown_pack = |name: &str| { + format!( + "{verb}: no Pack named \"{name}\" in this run; declare it under uses: to \ + require it, or offer it only when tools.packs.get(\"{name}\") returns it" + ) + }; + let shape = format!( + "{verb} takes tool ids, Pack ids, tool objects, Pack objects, or an array of them, got" + ); + let entry = format!( + "{verb} takes tool ids, Pack ids, tool objects, or Pack objects in an array, got" ); + let unavailable = + format!("{verb}: the mcp Pack is unavailable in this run: the mcp server is down"); + for (call, expected) in [ + ("'wbe'", unknown_pack("wbe")), + ( + "'web_search'", + format!( + "{} (did you mean 'web/search'?)", + unknown_pack("web_search") + ), + ), + ( + "'web/serch'", + format!("{verb}: \"web/serch\" is not a tool in this run"), + ), + ("'mcp'", unavailable.clone()), + ( + "'web/search', 'web/fetch'", + format!("{verb} takes one tool or Pack, or an array of them, got extra string"), + ), + ("42", format!("{shape} integer")), + ("nil", format!("{shape} nil")), + ("tools.get('web/serch')", format!("{shape} nil")), + ("messages", format!("{shape} userdata")), + ( + "{ 'web', nil, 'mcp' }", + format!("{verb} takes an array, got a table with the key 3"), + ), + ( + "setmetatable({ 'web' }, { __len = function() return 2 end })", + format!("{entry} nil at index 2"), + ), + ("{ { 'web' } }", format!("{entry} table at index 1")), + ("{ 'web', 42 }", format!("{entry} integer at index 2")), + ( + "{ name = 'web' }", + format!("{verb} takes an array, got a table with the key name"), + ), + ( + "setmetatable({ 'web' }, { __len = function() return 0 end })", + format!("{verb} takes an array, got a table with the key 1"), + ), + ("{ 'web', 'mcp' }", unavailable.clone()), + ("{ 'web/search', 'wbe' }", unknown_pack("wbe")), + ] { + let error = raised(&vm, &format!("{verb}({call})")); + assert!(error.contains(&expected), "{verb}({call}): {error}"); + } assert!( - scoped(&vm).is_empty(), - "a refused call records no entry, the valid one included ({bad:?})" + offers(&vm).is_empty(), + "{verb}: a refused call records no entry, its valid ones included" ); } } #[test] -fn offering_a_tool_object_matches_offering_its_id() { - let by_object = lua_over(offering_set()); - by_object.exec("tools.offer(tools.get('gh/search'))"); - let by_id = lua_over(offering_set()); - by_id.exec("tools.offer('gh/search')"); - assert_eq!(scoped(&by_object), scoped(&by_id)); - assert_eq!(scoped(&by_id), ["gh_search"]); +fn an_array_is_read_through_its_metamethods() { + let vm = lua_over(offer_set()); + vm.exec( + "local entries = { 'gh', 'web/fetch' }\n\ + tools.offer(setmetatable({}, {\n\ + __len = function() return #entries end,\n\ + __index = function(_, i) return entries[i] end,\n\ + }))", + ); + assert_eq!(offers(&vm), ["static:gh", "static:web/fetch"]); } #[test] -fn required_and_extras_split_the_offering_by_declared_pack_and_share_objects() { - let vm = lua_over(offering_set()); - let result: (String, String, bool, bool, bool, bool) = vm.eval( - "local function ids(list)\n\ - local out = {}\n\ - for i, tool in ipairs(list) do out[i] = tool.id end\n\ - return table.concat(out, ',')\n\ - end\n\ - local required, extras = tools.required(), tools.extras()\n\ - return ids(required), ids(extras), \ - required[1] == tools.get('web/fetch'), \ - extras[2] == tools.get('gh/search'), \ - tools.required() ~= required, \ - tools.required()[2] == required[2]", +fn the_render_refusal_comes_before_every_other_check_and_defining_still_works() { + let vm = lua_over(offer_set()); + vm.exec("tools.offer('web/search')"); + vm.messages().attach_rendered_tools(RenderedTools { + schemas: Vec::new(), + dispatch: BTreeMap::new(), + dynamic: Vec::new(), + }); + for verb in ["tools.offer", "tools.offer_dynamic"] { + for args in ["'gh'", "'web/search'", "'wbe'", "42", "", "'web', 'gh'"] { + let error = raised(&vm, &format!("{verb}({args})")); + assert!( + error.contains(&format!( + "{verb}: the section's tool list is fixed at its first models.loop \ + round; make every offer before the first loop, or offer in another section" + )), + "{verb}({args}) raises the fixed-list refusal first: {error}" + ); + } + } + let defined: bool = vm.eval( + "local late = tools.adhoc({ name = 'late', description = 'Late' }, function() end)\n\ + return tools.get('adhoc/late') == late", ); + assert!(defined, "defining an adhoc tool works after the render"); assert_eq!( - result, - ( - "web/fetch,web/search".to_owned(), - "gh/issues.list,gh/search".to_owned(), - true, - true, - true, - true, - ), - "a declared Pack's tools are required, the rest extras, both in id \ - order, every list a fresh array of the same shared objects" + offers(&vm), + ["static:web/search"], + "no refused offer records" ); } #[test] -fn tools_get_reads_one_object_by_id_and_nil_for_anything_else() { - let vm = lua_over(offering_set()); - let result: (String, bool, bool, bool, bool, String) = vm.eval( - "tools.offer_local('helper', 'a local helper', {}, function() return '' end)\n\ - local ok, err = pcall(tools.get, 42)\n\ - assert(not ok, 'a non-string is refused')\n\ - return tools.get('gh/search').id, tools.get('gh/missing') == nil, \ - tools.get('helper') == nil, tools.get('Not An Id') == nil, \ - tools.get('gh_search') == nil, tostring(err)", +fn offering_every_pack_or_the_extras_offers_their_tools() { + let every = lua_over(offer_set()); + every.exec( + "tools.adhoc({ name = 'grab', description = 'Grab a value' }, function() end)\n\ + tools.offer(tools.packs)", ); - assert_eq!(result.0, "gh/search"); - assert!( - result.1 && result.2 && result.3 && result.4, - "a miss, a local alias, a malformed id, and a wire name all read nil: {result:?}" + assert_eq!( + scoped(&every), + [ + "grab", + "gh_issues_list", + "gh_search", + "user-input_ask", + "web_fetch", + "web_search" + ], + "every Pack, the adhoc Pack included, in name order" ); - assert!( - result.5.contains("tools.get takes a tool id, got integer"), - "the refusal names the type: {}", - result.5 + let extras = lua_over(offer_set()); + extras.exec( + "tools.adhoc({ name = 'grab', description = 'Grab a value' }, function() end)\n\ + tools.offer(tools.packs.extras())", + ); + assert_eq!( + scoped(&extras), + ["gh_issues_list", "gh_search"], + "only the undeclared Packs, never the adhoc Pack" ); } #[test] -fn offer_local_registers_a_local_tool_and_names_itself_in_its_errors() { - let vm = lua_over(offering_set()); - let errors: (String, String, String) = vm.eval( - "tools.offer_local('gh_search', 'a local search', {}, function() return '' end)\n\ - local _, duplicate = pcall(tools.offer_local, 'gh_search', 'again', {}, function() end)\n\ - local _, param = pcall(tools.offer_local, 'other', 'x', { p = 'table' }, function() end)\n\ - local _, alias = pcall(tools.offer_local, 'web/search', 'x', {}, function() end)\n\ - return tostring(duplicate), tostring(param), tostring(alias)", - ); - assert!( - errors - .0 - .contains("tools.offer_local alias \"gh_search\" is already registered"), - "{}", - errors.0 +fn a_tool_object_reads_its_id_and_catalog_description_and_refuses_assignment() { + let vm = lua_over(offer_set()); + let result: (String, String, bool, String) = vm.eval( + "local tool = tools.get('gh/search')\n\ + tools.offer(tool)\n\ + local ok, err = pcall(function() tool.description = 'x' end)\n\ + return tool.id, tool.description, ok, tostring(err)", ); - assert!( - errors - .1 - .contains("tools.offer_local param \"p\" has unsupported type \"table\""), - "{}", - errors.1 + assert_eq!(result.0, "gh/search"); + assert_eq!( + result.1, "gh/search tool", + "description is the catalog text" ); + assert!(!result.2, "assigning a field is refused"); assert!( - errors.2.contains("invalid alias \"web/search\""), - "a local alias keeps the alias grammar: {}", - errors.2 + result + .3 + .contains("Tool objects are frozen: cannot assign field \"description\""), + "the refusal names the field: {}", + result.3 ); } #[test] -fn every_offer_function_raises_once_the_tool_list_is_rendered_before_any_other_check() { - let vm = lua_over(offering_set()); - vm.exec( - "tools.offer('web/search')\n\ - packs.offer('web')\n\ - tools.offer_local('helper', 'a local helper', {}, function() return '' end)", +fn tools_get_reads_one_object_by_id_and_nil_for_anything_else() { + let vm = lua_over(offer_set()); + let result: (String, bool, bool, bool, bool, bool, String) = vm.eval( + "local helper = tools.adhoc({ name = 'helper', description = 'A helper' }, function() end)\n\ + local ok, err = pcall(tools.get, 42)\n\ + assert(not ok, 'a non-string is refused')\n\ + return tools.get('gh/search').id, tools.get('adhoc/helper') == helper, \ + tools.get('gh/missing') == nil, tools.get('helper') == nil, \ + tools.get('Not An Id') == nil, tools.get('gh_search') == nil, tostring(err)", ); - let before = scoped(&vm); - vm.messages().attach_rendered_tools(RenderedTools { - schemas: Vec::new(), - dispatch: BTreeMap::new(), - }); - let refusals: Vec = vm.eval( - "local out = {}\n\ - for _, offer in ipairs({\n\ - function() tools.offer('gh/search') end,\n\ - function() tools.offer('web/search') end,\n\ - function() tools.offer('gh/missing') end,\n\ - function() packs.offer('web') end,\n\ - function() packs.offer('nope') end,\n\ - function() tools.offer_local('other', 'x', {}, function() return '' end) end,\n\ - function() tools.offer_local('helper', 'again', {}, function() return '' end) end,\n\ - function() tools.offer_local() end,\n\ - }) do\n\ - local ok, err = pcall(offer)\n\ - out[#out + 1] = ok and 'accepted' or tostring(err)\n\ - end\n\ - return out", + assert_eq!(result.0, "gh/search"); + assert!(result.1, "an adhoc tool reads by its id"); + assert!( + result.2 && result.3 && result.4 && result.5, + "a miss, an adhoc tool's wire name, a malformed id, and a catalog wire name \ + all read nil: {result:?}" ); - let fixed = "the section's tool list is fixed at its first models.loop round; make every \ - offer before the first loop, or offer in another section"; - let calls = [ - "tools.offer", - "tools.offer", - "tools.offer", - "packs.offer", - "packs.offer", - "tools.offer_local", - "tools.offer_local", - "tools.offer_local", - ]; - assert_eq!(refusals.len(), calls.len(), "{refusals:?}"); - for (refusal, call) in refusals.iter().zip(calls) { - assert!( - refusal.contains(&format!("{call}: {fixed}")), - "a new, a repeated, and an unknown entry, and a missing argument, all \ - raise the fixed-list refusal first: {refusal}" - ); - } - assert_eq!(scoped(&vm), before, "no refused offer reaches the scope"); assert!( - !vm.has_local_tool("other") - .expect("the local-tools registry reads"), - "no refused local offer registers" + result.6.contains("tools.get takes a tool id, got integer"), + "the refusal names the type: {}", + result.6 ); } #[test] -fn the_tools_namespace_holds_exactly_its_five_functions() { - let vm = lua_over(offering_set()); +fn the_tools_table_holds_exactly_its_api() { + let vm = lua_over(offer_set()); let keys: String = vm.eval( "local keys = {}\n\ for key, value in pairs(tools) do keys[#keys + 1] = key .. ':' .. type(value) end\n\ @@ -253,43 +351,7 @@ fn the_tools_namespace_holds_exactly_its_five_functions() { return table.concat(keys, ',')", ); assert_eq!( - keys, "extras:function,get:function,offer:function,offer_local:function,required:function", - "the tools table holds exactly the namespace's functions" + keys, "adhoc:function,get:function,offer:function,offer_dynamic:function,packs:table", + "the tools table holds exactly the namespace's members before the shim installs call" ); } - -#[test] -fn tools_entries_name_the_calling_function_in_every_shape_error() { - let lua = Lua::new(); - let string = |text: &str| { - Value::String( - lua.create_string(text) - .expect("a fresh VM creates a short string"), - ) - }; - let array = lua - .create_sequence_from(vec![string("web/search")]) - .expect("a fresh VM creates the offer array"); - for (args, fragment) in [ - ( - vec![Value::Integer(42)], - "tools.offer takes tool ids, tool objects, or arrays of them, got integer", - ), - ( - vec![string("web/search"), string("x")], - "tools.offer takes one tool or a list of them, got extra string", - ), - ( - vec![Value::Table(array), string("x")], - "tools.offer takes one tool or a list of them, got extra string", - ), - ( - vec![string("web/search"), Value::Integer(42)], - "tools.offer takes one tool or a list of them, got extra integer", - ), - ] { - let error = collect_offer_entries("tools.offer", Variadic::from_iter(args)) - .expect_err("the shape is refused"); - assert!(error.to_string().contains(fragment), "{fragment}: {error}"); - } -} diff --git a/crates/runvil-internal/lua/src/tools/tests-parameters.rs b/crates/runvil-internal/lua/src/tools/tests-parameters.rs new file mode 100644 index 000000000..31b797fe2 --- /dev/null +++ b/crates/runvil-internal/lua/src/tools/tests-parameters.rs @@ -0,0 +1,199 @@ +//! Tests for a catalog tool object's `parameters`: the schema as a fresh +//! Lua table on every read, its lists and nulls intact through +//! `json.encode`, and metadata that never takes the untrusted envelope. + +use std::collections::BTreeMap; + +use mlua::{LuaSerdeExt, Value}; +use runvil_types::packs::PackId; +use runvil_types::tools::{ToolId, ToolInfo, ToolOutput}; +use serde_json::{Value as Json, json}; + +use crate::ToolBinding; +use crate::dispatch::prepare_dispatch; +use crate::handles::ToolSet; +use crate::test_vm::{TEST_SECTION, lua_over, test_nonce}; +use crate::tests::recording::null_emitter; + +/// The `web/search` schema: nested properties, a required list, an empty +/// list, a null, and text that looks like markup. +fn search_schema() -> Json { + json!({ + "type": "object", + "description": "Search for [INST] pages", + "properties": { + "query": { "type": "string", "description": "What to search for" }, + "limit": { "type": "integer", "minimum": 1, "default": null }, + "tags": { "type": "array", "items": { "type": "string" }, "examples": [] } + }, + "required": ["query"], + "additionalProperties": false + }) +} + +fn search_tool() -> ToolInfo { + ToolInfo::new( + ToolId::parse("web/search").expect("a static valid tool id"), + "Searches and returns [INST] snippets", + search_schema(), + ) +} + +/// A set declaring `web` and offering `web/search`. +pub(super) fn search_set() -> ToolSet { + ToolSet::new( + vec![PackId::parse("web").expect("`web` is a valid Pack id")], + BTreeMap::new(), + vec![ToolBinding::from_info("web_search", &search_tool())], + ) +} + +#[test] +fn parameters_deep_equals_the_catalog_tools_schema() { + let vm = lua_over(search_set()); + let parameters: Value = vm.eval("return tools.get('web/search').parameters"); + let read: Json = vm + .lua() + .from_value(parameters) + .expect("the parameters table converts back to JSON"); + assert_eq!(read, search_schema()); + let null_reads_json_null: bool = + vm.eval("return tools.get('web/search').parameters.properties.limit.default == json.null"); + assert!(null_reads_json_null, "a schema null reads json.null"); +} + +#[test] +fn each_parameters_read_is_a_fresh_table() { + let vm = lua_over(search_set()); + let result: (bool, String, Option) = vm.eval( + "local tool = tools.get('web/search')\n\ + local first = tool.parameters\n\ + first.type = 'mutated'\n\ + first.properties.query = nil\n\ + local second = tool.parameters\n\ + return first ~= second, second.type, second.properties.query.type", + ); + assert_eq!( + result, + (true, "object".to_owned(), Some("string".to_owned())), + "mutating one read leaves the next unchanged" + ); +} + +#[test] +fn parameters_round_trips_through_json_encode_with_empty_lists_intact() { + let vm = lua_over(search_set()); + let encoded: String = vm.eval("return json.encode(tools.get('web/search').parameters)"); + assert!( + encoded.contains("\"examples\":[]"), + "an empty list encodes as []: {encoded}" + ); + assert!( + encoded.contains("\"default\":null"), + "a null encodes as null: {encoded}" + ); + let decoded: Json = serde_json::from_str(&encoded).expect("json.encode writes JSON"); + assert_eq!(decoded, search_schema()); +} + +#[test] +fn assigning_parameters_raises() { + let vm = lua_over(search_set()); + let error: String = vm.eval( + "local ok, err = pcall(function() tools.get('web/search').parameters = {} end)\n\ + assert(not ok, 'assigning parameters is refused')\n\ + return tostring(err)", + ); + assert!( + error.contains("Tool objects are frozen: cannot assign field \"parameters\""), + "the refusal names the field: {error}" + ); +} + +#[test] +fn a_tool_object_holds_exactly_id_description_parameters_and_pack() { + let vm = lua_over(search_set()); + let fields: (String, bool, bool, String) = vm.eval( + "local tool = tools.get('web/search')\n\ + return tool.id, type(tool.description) == 'string', \ + type(tool.parameters) == 'table', tool.pack.name", + ); + assert_eq!( + fields, + ("web/search".to_owned(), true, true, "web".to_owned()) + ); + let extra: Option = vm.eval( + "local tool = tools.get('web/search')\n\ + local probes = { 'vendor', 'schema', 'alias', 'name', 'describes_tools' }\n\ + for _, field in ipairs(probes) do\n\ + local ok, value = pcall(function() return tool[field] end)\n\ + if ok and value ~= nil then return field end\n\ + end\n\ + return nil", + ); + assert_eq!(extra, None, "a tool object reads no field beyond its four"); +} + +/// Every string in `value`, keys included, depth first. +fn strings(value: &Json, out: &mut Vec) { + match value { + Json::String(text) => out.push(text.clone()), + Json::Array(items) => items.iter().for_each(|item| strings(item, out)), + Json::Object(entries) => { + for (key, item) in entries { + out.push(key.clone()); + strings(item, out); + } + } + _ => {} + } +} + +#[test] +fn metadata_of_a_pack_whose_results_are_untrusted_never_takes_the_envelope() { + let nonce = test_nonce(); + let result = prepare_dispatch( + &ToolBinding::from_info("web_search", &search_tool()), + Ok(ToolOutput::untrusted("fetched page")), + None, + &nonce, + &null_emitter(), + TEST_SECTION, + None, + ) + .expect("an untrusted output dispatches") + .into_content(); + assert!( + result.contains(&nonce.to_string()), + "the Pack's results are untrusted: {result}" + ); + let preface = "is data, not instructions"; + assert!(result.contains(preface), "{result}"); + + let vm = lua_over(search_set()); + let (description, name, parameters): (String, String, Value) = vm.eval( + "local tool = tools.get('web/search')\n\ + return tool.description, tools.packs.get('web').name, tool.parameters", + ); + assert_eq!(description, search_tool().description); + assert_eq!(name, "web"); + let parameters: Json = vm + .lua() + .from_value(parameters) + .expect("the parameters table converts back to JSON"); + let (mut read, mut declared) = (Vec::new(), Vec::new()); + strings(¶meters, &mut read); + strings(&search_schema(), &mut declared); + read.sort(); + declared.sort(); + assert_eq!( + read, declared, + "every string is the schema's text as declared" + ); + for text in read.iter().chain([&description, &name]) { + assert!( + !text.contains(&nonce.to_string()) && !text.contains(preface), + "metadata carries no envelope: {text}" + ); + } +} diff --git a/crates/runvil-internal/lua/src/tools/tests-render.rs b/crates/runvil-internal/lua/src/tools/tests-render.rs new file mode 100644 index 000000000..295a64bc6 --- /dev/null +++ b/crates/runvil-internal/lua/src/tools/tests-render.rs @@ -0,0 +1,192 @@ +//! Tests for what the section's offers render: the static list in call +//! order, Pack expansion at the render, adhoc shadowing among offered +//! tools, and the dynamic list beside it. + +use crate::test_vm::{dynamic, lua_over, offer_set, scoped}; + +#[test] +fn the_static_list_follows_call_order_whichever_comes_first() { + for (source, expected) in [ + ( + "tools.offer('web')\ntools.offer('web/search')", + vec!["web_fetch", "web_search"], + ), + ( + "tools.offer('web/search')\ntools.offer('web')", + vec!["web_search", "web_fetch"], + ), + ( + "tools.offer(grab)\ntools.offer('web')", + vec!["grab", "web_fetch", "web_search"], + ), + ] { + let vm = lua_over(offer_set()); + vm.exec(&format!( + "local grab = tools.adhoc({{ name = 'grab', description = 'Grab a value' }}, \ + function() end)\n{source}" + )); + assert_eq!(scoped(&vm), expected, "{source}"); + } +} + +#[test] +fn repeated_offers_neither_duplicate_nor_move_a_tool() { + let vm = lua_over(offer_set()); + vm.exec( + "tools.offer('web/fetch')\n\ + tools.offer('gh')\n\ + tools.offer('web')\n\ + tools.offer('web/fetch')\n\ + tools.offer({ 'gh/search', tools.packs.get('gh') })", + ); + assert_eq!( + scoped(&vm), + ["web_fetch", "gh_issues_list", "gh_search", "web_search"], + "a tool followed by its Pack keeps the tool's place, and no repeat moves it" + ); +} + +#[test] +fn the_adhoc_pack_expands_at_the_render_while_its_tools_array_is_read_at_the_call() { + let by_pack = lua_over(offer_set()); + by_pack.exec( + "tools.adhoc({ name = 'zeta', description = 'Z' }, function() end)\n\ + tools.offer(tools.packs.get('adhoc'))\n\ + tools.adhoc({ name = 'alpha', description = 'A' }, function() end)", + ); + assert_eq!( + scoped(&by_pack), + ["alpha", "zeta"], + "a Pack offer takes every adhoc tool defined before the render, in id order" + ); + let by_tools = lua_over(offer_set()); + by_tools.exec( + "tools.adhoc({ name = 'zeta', description = 'Z' }, function() end)\n\ + tools.offer(tools.packs.get('adhoc').tools)\n\ + tools.adhoc({ name = 'alpha', description = 'A' }, function() end)", + ); + assert_eq!( + scoped(&by_tools), + ["zeta"], + "a tools array takes only the tools defined when it was read" + ); +} + +#[test] +fn an_offered_adhoc_tool_shadows_an_offered_catalog_tool_of_its_wire_name() { + let shadowed = lua_over(offer_set()); + shadowed.exec( + "tools.offer('web')\n\ + tools.offer(tools.adhoc({ name = 'web_search', description = 'Mine' }, function() end))", + ); + assert_eq!(scoped(&shadowed), ["web_fetch", "web_search"]); + let (set, runtime) = shadowed.tool_bag_handles(); + let bound = crate::current_tool_bindings(&set, &runtime, shadowed.adhoc_tools()) + .expect("the scope resolves"); + assert_eq!( + bound[1].id().to_string(), + "adhoc/web_search", + "the adhoc tool is rendered and the catalog tool dropped where it was offered" + ); + let unoffered = lua_over(offer_set()); + unoffered.exec( + "tools.adhoc({ name = 'web_search', description = 'Mine' }, function() end)\n\ + tools.offer('web')", + ); + let (set, runtime) = unoffered.tool_bag_handles(); + let ids: Vec = crate::current_tool_bindings(&set, &runtime, unoffered.adhoc_tools()) + .expect("the scope resolves") + .iter() + .map(|binding| binding.id().to_string()) + .collect(); + assert_eq!( + ids, + ["web/fetch", "web/search"], + "a defined but unoffered adhoc tool shadows nothing" + ); +} + +#[test] +fn the_dynamic_list_keeps_what_the_static_list_lacks_in_offer_order_once() { + for source in [ + "tools.offer('web/search')\ntools.offer_dynamic('web')", + "tools.offer_dynamic('web')\ntools.offer('web/search')", + ] { + let vm = lua_over(offer_set()); + vm.exec(source); + assert_eq!(scoped(&vm), ["web_search"], "{source}"); + assert_eq!( + dynamic(&vm), + ["web/fetch"], + "static wins in either order: {source}" + ); + } + let vm = lua_over(offer_set()); + vm.exec( + "tools.offer_dynamic('gh/search')\n\ + tools.offer_dynamic({ 'gh', 'gh/search' })\n\ + tools.offer_dynamic(tools.packs.get('adhoc'))\n\ + tools.adhoc({ name = 'late', description = 'Late' }, function() end)", + ); + assert_eq!( + dynamic(&vm), + ["gh/search", "gh/issues.list", "adhoc/late"], + "each tool once, in offer order, adhoc tools defined before the render included" + ); + assert!( + scoped(&vm).is_empty(), + "dynamic offers never reach the static list" + ); +} + +#[test] +fn a_catalog_tool_a_static_adhoc_tool_shadows_stays_out_of_the_dynamic_list() { + let vm = lua_over(offer_set()); + vm.exec( + "tools.offer(tools.adhoc({ name = 'web_search', description = 'Mine' }, function() end))\n\ + tools.offer_dynamic('web')", + ); + assert_eq!(scoped(&vm), ["web_search"]); + assert_eq!( + dynamic(&vm), + ["web/fetch"], + "a tool whose wire name the static list holds is skipped" + ); +} + +#[test] +fn dynamic_offers_leave_the_static_bindings_unchanged() { + let plain = lua_over(offer_set()); + plain.exec("tools.offer({ 'web/search', 'gh' })"); + let with_dynamic = lua_over(offer_set()); + with_dynamic.exec( + "tools.offer_dynamic('web')\n\ + tools.offer({ 'web/search', 'gh' })\n\ + tools.offer_dynamic(tools.packs)", + ); + let bindings = |vm: &crate::SectionVm| { + let (set, runtime) = vm.tool_bag_handles(); + crate::current_tool_bindings(&set, &runtime, vm.adhoc_tools()).expect("the scope resolves") + }; + assert_eq!(bindings(&plain), bindings(&with_dynamic)); +} + +#[test] +fn the_same_offers_give_the_same_lists_in_every_vm() { + let source = "tools.adhoc({ name = 'b', description = 'B' }, function() end)\n\ + tools.adhoc({ name = 'a', description = 'A' }, function() end)\n\ + tools.offer(tools.packs)\n\ + tools.offer_dynamic(tools.packs.extras())"; + let lists: Vec<(Vec, Vec)> = (0..4) + .map(|_| { + let vm = lua_over(offer_set()); + vm.exec(source); + (scoped(&vm), dynamic(&vm)) + }) + .collect(); + assert!( + lists.windows(2).all(|pair| pair[0] == pair[1]), + "nothing is hash-ordered: {lists:?}" + ); + assert_eq!(lists[0].0[..2], ["a", "b"]); +} diff --git a/crates/runvil-internal/lua/src/tools/tests.rs b/crates/runvil-internal/lua/src/tools/tests.rs index 456cb4de7..64351e02f 100644 --- a/crates/runvil-internal/lua/src/tools/tests.rs +++ b/crates/runvil-internal/lua/src/tools/tests.rs @@ -1,17 +1,26 @@ -//! Tests for the `tools` namespace installers, alias decoding, and the local params schema. +//! Tests for the `tools` namespace installers, tool-name decoding, and the +//! short-form `parameters` schema. use std::collections::BTreeMap; -use mlua::{Lua, Value, Variadic}; +use mlua::{Lua, Value}; use serde_json::json; -use super::decode::{collect_offer_entries, local_params_schema, tool_name}; +use super::decode::{adhoc_params_schema, model_tool_name, tool_name}; use super::userdata::LuaToolHandle; use crate::test_vm::{TestVm, echo_tool, lua_over, tool_at}; use crate::{ToolBinding, ToolSet}; +#[path = "tests-adhoc.rs"] +mod adhoc; +#[path = "tests-offer-limits.rs"] +mod offer_limits; #[path = "tests-offering.rs"] mod offering; +#[path = "tests-parameters.rs"] +mod parameters; +#[path = "tests-render.rs"] +mod render; /// A userdata that is not a Tool object, for the foreign-userdata rejection. struct Foreign; @@ -22,16 +31,53 @@ fn echo_handle() -> LuaToolHandle { LuaToolHandle::from_binding(&ToolBinding::from_info("tools_echo", &echo_tool())) } +fn string(lua: &Lua, text: &str) -> Value { + Value::String( + lua.create_string(text) + .expect("a fresh VM creates a short string"), + ) +} + #[test] -fn tool_name_accepts_a_bare_string() { +fn tool_name_accepts_an_id_and_refuses_a_string_without_a_slash() { let lua = Lua::new(); - let value = lua - .create_string("tools/echo") - .expect("a fresh VM creates a short string"); assert_eq!( - tool_name(&Value::String(value)).expect("a string decodes"), + tool_name(&string(&lua, "tools/echo")).expect("an id decodes"), "tools/echo" ); + let error = tool_name(&string(&lua, "grab")).expect_err("a bare name is not an id"); + assert!( + error + .to_string() + .contains("tools.call: \"grab\" is not a tool id"), + "the refusal names the string: {error}" + ); +} + +#[test] +fn a_model_issued_name_is_read_verbatim() { + let lua = Lua::new(); + assert_eq!( + model_tool_name(&string(&lua, "web_search")).expect("a wire name decodes"), + "web_search", + "a model names a tool by the wire name it was sent" + ); +} + +#[test] +fn a_model_issued_call_refuses_a_tool_object() { + let lua = Lua::new(); + let userdata = lua + .create_userdata(echo_handle()) + .expect("a fresh VM creates a tool object"); + let error = model_tool_name(&Value::UserData(userdata)) + .expect_err("a model-issued call names a wire name, not an object"); + assert!( + error + .to_string() + .contains("a model-issued tool call names a tool by its wire name, got userdata"), + "the refusal names the wire-name form: {error}" + ); } #[test] @@ -43,7 +89,7 @@ fn tool_name_reads_the_id_off_a_tool_object() { assert_eq!( tool_name(&Value::UserData(userdata)).expect("a tool object decodes"), "tools/echo", - "a tool object stands for its canonical id, not its wire name" + "a tool object stands for its id, not its wire name" ); } @@ -54,7 +100,7 @@ fn tool_name_rejects_tables_other_types_and_other_userdata() { assert!( number .to_string() - .contains("tools.call takes a tool id, a local alias, or a tool object, got integer"), + .contains("tools.call takes a tool id or a tool object, got integer"), "the rejection names the accepted forms: {number}" ); let record = lua @@ -75,59 +121,19 @@ fn tool_name_rejects_tables_other_types_and_other_userdata() { assert!( other .to_string() - .contains("tools.call takes a tool id, a local alias, or a tool object, got userdata"), + .contains("tools.call takes a tool id or a tool object, got userdata"), "a foreign userdata gets the same rejection: {other}" ); } #[test] -fn tools_offer_entries_accept_strings_tool_objects_and_arrays() { - let lua = Lua::new(); - let tool = lua - .create_userdata(echo_handle()) - .expect("a fresh VM creates a tool object"); - let entries = collect_offer_entries( - "tools.offer", - Variadic::from_iter([Value::String( - lua.create_string("web/search") - .expect("a fresh VM creates a short string"), - )]), - ) - .expect("an id decodes"); - assert_eq!(entries, ["web/search"]); - let entries = collect_offer_entries( - "tools.offer", - Variadic::from_iter([Value::UserData(tool.clone())]), - ) - .expect("a tool object decodes"); - assert_eq!(entries, ["tools/echo"]); - - let array = lua - .create_sequence_from(vec![Value::UserData(tool)]) - .expect("a fresh VM creates the offer array"); - array - .raw_push(Value::String( - lua.create_string("web/fetch") - .expect("a fresh VM creates a short string"), - )) - .expect("a fresh array takes a pushed id"); - let entries = collect_offer_entries("tools.offer", Variadic::from_iter([Value::Table(array)])) - .expect("the array form decodes"); - assert_eq!( - entries, - ["tools/echo", "web/fetch"], - "tool objects and ids mix in the array form" - ); -} - -#[test] -fn local_params_schema_builds_object_schema_with_required_fields() { +fn adhoc_params_schema_builds_object_schema_with_required_fields() { let lua = Lua::new(); let params = lua .load("{ query = 'string', limit = { 'integer', 'maximum hits' } }") .eval::() .expect("params table evaluates"); - let schema = local_params_schema(¶ms).expect("the schema builds"); + let schema = adhoc_params_schema(¶ms).expect("the schema builds"); assert_eq!( schema["properties"], json!({ @@ -144,7 +150,7 @@ fn local_params_schema_builds_object_schema_with_required_fields() { } #[test] -fn local_params_schema_sorts_required_so_the_schema_text_is_deterministic() { +fn adhoc_params_schema_sorts_required_so_the_schema_text_is_deterministic() { // `required` is the one Rust-side `table.pairs` walk whose output is an // ordered array rather than a table or map, so it must impose an order // itself: left as the walk produced it, two VMs emit different schema @@ -158,7 +164,7 @@ fn local_params_schema_sorts_required_so_the_schema_text_is_deterministic() { ) .eval::() .expect("params table evaluates"); - let schema = local_params_schema(¶ms).expect("the schema builds"); + let schema = adhoc_params_schema(¶ms).expect("the schema builds"); assert_eq!( schema["required"], json!([ @@ -169,32 +175,36 @@ fn local_params_schema_sorts_required_so_the_schema_text_is_deterministic() { } #[test] -fn local_params_schema_rejects_an_unsupported_type() { +fn adhoc_params_schema_rejects_an_unsupported_type_under_the_adhoc_prefix() { let lua = Lua::new(); let params = lua .load("{ payload = 'table' }") .eval::() .expect("params table evaluates"); - let error = local_params_schema(¶ms).expect_err("an unsupported type fails"); + let error = adhoc_params_schema(¶ms).expect_err("an unsupported type fails"); assert!( - error.to_string().contains("unsupported type \"table\""), + error + .to_string() + .contains("tools.adhoc: param \"payload\" has unsupported type \"table\""), "the rejection names the bad type: {error}" ); } #[test] -fn the_tools_namespace_exposes_scoping_without_bind_or_call() { +fn the_tools_namespace_exposes_its_verbs_without_bind_or_call() { // `call` is absent here on purpose: it suspends, so the coroutine shim // prelude installs it - this table exposes exactly the non-suspending // operations. `bind` is absent too. let vm = TestVm::new().build(); - let (has_offer, has_offer_local, call_is_nil, bind_is_nil): (bool, bool, bool, bool) = vm.eval( + let shapes: (bool, bool, bool, bool, bool, bool) = vm.eval( "return type(tools.offer) == 'function', \ - type(tools.offer_local) == 'function', \ + type(tools.offer_dynamic) == 'function', \ + type(tools.adhoc) == 'function', \ + type(tools.packs) == 'table', \ tools.call == nil, \ tools.bind == nil", ); - assert!(has_offer && has_offer_local && call_is_nil && bind_is_nil); + assert_eq!(shapes, (true, true, true, true, true, true)); } #[test] @@ -219,7 +229,7 @@ fn tool_call_counts_seed_by_id_and_explain_an_unseeded_key() { ], )); let counts = vm - .install_tool_call_counts(&[echo]) + .install_tool_call_counts(&[echo.id().clone()]) .expect("the counts install"); assert_eq!( counts @@ -234,6 +244,7 @@ fn tool_call_counts_seed_by_id_and_explain_an_unseeded_key() { None, "counts key a catalog tool by its id, never its wire name" ); + vm.exec("tools.adhoc({ name = 'grab', description = 'Grab a value' }, function() end)"); let read = |key: &str| -> String { vm.eval(&format!( "local ok, err = pcall(function() return tools.calls[{key:?}] end); return tostring(err)" @@ -248,7 +259,17 @@ fn tool_call_counts_seed_by_id_and_explain_an_unseeded_key() { ), "an offered id that was never seeded says so: {offered}" ); - for key in ["ghost", "tools_echo"] { + let adhoc = read("adhoc/grab"); + assert!( + adhoc.contains( + "tools.calls: \"adhoc/grab\" has no seeded count; seeded names: [\"tools/echo\"] \ + (an adhoc tool that was neither offered in this section nor called with \ + tools.call)" + ), + "a defined adhoc tool's id that was never seeded says so, even when defined \ + after the counts install: {adhoc}" + ); + for key in ["ghost", "tools_echo", "grab"] { let error = read(key); assert!( error.contains(&format!("tools.calls: {key:?} has no seeded count")) diff --git a/crates/runvil-internal/lua/src/tools/userdata.rs b/crates/runvil-internal/lua/src/tools/userdata.rs index c2a5108f3..fa662436f 100644 --- a/crates/runvil-internal/lua/src/tools/userdata.rs +++ b/crates/runvil-internal/lua/src/tools/userdata.rs @@ -1,35 +1,40 @@ -//! The tool object: one catalog tool as Lua sees it. +//! The tool object: one tool as Lua sees it. //! -//! Presentation only: the userdata exposes an offered tool's `id`, its -//! catalog `description`, and its `pack`, the Pack object -//! `packs.get` returns, and stands for its id wherever `tools.offer` and -//! `tools.call` take a tool. The object is frozen and methodless, so -//! `description` is always the catalog text. Unlike a model handle, which -//! carries `infer` and `loop`, it is invoked only through -//! `tools.call(tool, arguments)`. +//! Presentation only: the userdata exposes a tool's `id`, its +//! `description`, its `parameters` schema, and its `pack`, the Pack +//! object `tools.packs.get` returns, and stands for its id wherever +//! `tools.offer` and `tools.call` take a tool. The object is frozen and +//! methodless, so `description` is always the text the Pack or the +//! `tools.adhoc` attrs declared, and each `parameters` read is a fresh +//! table of the schema as declared. Neither is ever in the untrusted +//! envelope. Unlike a model handle, which carries `infer` and `loop`, it +//! is invoked only through `tools.call(tool, arguments)`. -use mlua::{MetaMethod, UserData, UserDataFields, UserDataMethods, Value}; +use mlua::{LuaSerdeExt, MetaMethod, UserData, UserDataFields, UserDataMethods, Value}; use runvil_types::tools::ToolId; +use serde_json::Value as Json; use crate::handles::ToolBinding; use crate::packs::pack_object; -/// One catalog tool as Lua sees it: `id`, `description`, and `pack`, -/// all read-only. +/// One tool as Lua sees it: `id`, `description`, `parameters`, and +/// `pack`, all read-only. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct LuaToolHandle { id: ToolId, description: String, + parameters: Json, } impl LuaToolHandle { - /// Builds the object for an offered tool from its binding: the id and - /// the catalog description, never the binding's override. + /// Builds the object for a tool from its binding: the id, the + /// declared description, and the parameter schema. #[must_use] pub(crate) fn from_binding(binding: &ToolBinding) -> Self { Self { id: binding.id().clone(), description: binding.description().to_owned(), + parameters: binding.schema().clone(), } } @@ -44,6 +49,10 @@ impl UserData for LuaToolHandle { fn add_fields>(fields: &mut F) { fields.add_field_method_get("id", |_, this| Ok(this.id.to_string())); fields.add_field_method_get("description", |_, this| Ok(this.description.clone())); + // The conversion `json.decode` makes: lists carry the array + // marker, so `json.encode` gives back `[]`, and nulls read + // `json.null`. + fields.add_field_method_get("parameters", |lua, this| lua.to_value(&this.parameters)); fields.add_field_method_get("pack", |lua, this| { pack_object(lua, &this.id.pack().to_string()) }); diff --git a/crates/runvil-internal/lua/src/vm.rs b/crates/runvil-internal/lua/src/vm.rs index ad657c32f..f5aba89e9 100644 --- a/crates/runvil-internal/lua/src/vm.rs +++ b/crates/runvil-internal/lua/src/vm.rs @@ -1,19 +1,20 @@ //! The per-section Lua VM: its construction, the typestate that orders //! Engine injection before every later step, the section's one -//! `messages` list it creates, and the local tools it registers. +//! `messages` list it creates, and the adhoc tools it defines. //! Injection is in `inject`, the later installs in `install`, block -//! coroutines in `run`, and state reads in `state`. +//! coroutines in `run`, state reads in `state`, and the adhoc tools in +//! `adhoc`. //! -//! A local tool, registered with `tools.offer_local` before the section's -//! first loop round, runs inside the calling block's coroutine, never as -//! an Engine function. The VM keeps only each -//! tool's alias and schema; the handler sits in the VM's handler table. A -//! call takes two yields: the shim's `tool_call`, which the scheduler -//! answers with the handler itself, and the shim's `local_tool_done` -//! after the handler ran, which carries its result for the scheduler to -//! report. Every suspending call the handler makes in between is an -//! ordinary yield of the same coroutine. - +//! An adhoc tool, defined with `tools.adhoc`, runs inside the calling +//! block's coroutine, never as an Engine function. The VM keeps each +//! tool's binding; the handler sits in the VM's handler table. A call +//! takes two yields: the shim's `tool_call`, which the scheduler answers +//! with the handler itself, and the shim's `adhoc_tool_done` after the +//! handler ran, which carries its result for the scheduler to report. +//! Every suspending call the handler makes in between is an ordinary +//! yield of the same coroutine. + +mod adhoc; mod inject; mod install; mod run; @@ -23,7 +24,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use mlua::{Lua, LuaOptions, StdLib}; -use runvil_model_client::client::ToolSchema; use runvil_model_client::model::ModelSet; use runvil_types::emitter::Emitter; use runvil_types::event::lifecycle; @@ -31,7 +31,6 @@ use runvil_types::untrusted::GuardNonce; use runvil_vfs::Access; use serde_json::Value as Json; -use crate::alias::Alias; use crate::engine_globals::{LUA_LOG_CHARACTER_LIMIT, install_untrusted}; use crate::error::{Error, Result}; use crate::handles::ToolSet; @@ -43,10 +42,11 @@ use crate::program::LuaProgram; use crate::scope::ToolRuntime; use crate::walk_budget::HeapCeiling; +pub use adhoc::AdhocTools; pub use inject::InjectFailure; pub use run::CoroStep; pub(crate) use run::scalar_return; -pub use state::{current_tool_bindings, resolve_model_binding}; +pub use state::{current_dynamic_tools, current_tool_bindings, resolve_model_binding}; /// Default per-VM Lua heap ceiling, matching the executor's `RunLimits`. pub(crate) const DEFAULT_LUA_MEMORY_BYTES: usize = 64 * 1024 * 1024; @@ -128,9 +128,9 @@ pub struct SectionVm { model_runtime: Arc>, /// Set by Lua `jump` before it aborts the current chunk. jump_slot: Arc>>, - /// How many local tool handlers are running on this VM; `jump` refuses + /// How many adhoc tool handlers are running on this VM; `jump` refuses /// while it is above zero. - local_handler_depth: Arc, + adhoc_handler_depth: Arc, /// Set while a `models.loop` or `handle:loop` runs on this VM. loop_running: Arc, /// How many libraries `require` is loading on this VM; `jump` refuses @@ -153,9 +153,8 @@ pub struct SectionVm { /// Remaining cumulative `log()` message bytes this VM may emit. Bounds total /// log volume even when each event is under the per-event ceilings. log_byte_budget: Arc, - /// Local tools registered by Lua code: alias and schema, for - /// membership and advertising. - local_tools: LocalTools, + /// The adhoc tools `tools.adhoc` defined. + adhoc_tools: AdhocTools, /// The VM's instruction-budget counter, shared with every block /// coroutine's hook (hooks are per-coroutine in PUC Lua). instruction_budget: InstructionBudget, @@ -171,68 +170,6 @@ pub struct SectionVm { state: S, } -/// Local tool registrations owned by a section VM. -/// -/// Each entry holds the tool alias and its prebuilt schema, which serve -/// membership and advertising. The handler function itself lives in the -/// VM's handler table, written by `tools.offer_local` and read back when the -/// scheduler answers a call with it. The entries are shared with the -/// `tools.offer_local` Lua callback, which must be `Send`, hence the `Mutex`; -/// the VM is single-threaded, so the lock never contends. -#[derive(Debug, Default, Clone)] -pub(crate) struct LocalTools { - entries: Arc>>, -} - -impl LocalTools { - /// Locks the registry. - /// - /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. - fn entries(&self) -> Result>> { - self.entries - .lock() - .map_err(|_| Error::Internal("local tools registry was poisoned".into())) - } - - /// Registers a local tool: alias and prebuilt schema. - /// - /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. - pub(crate) fn register(&self, alias: Alias, schema: ToolSchema) -> Result<()> { - self.entries()?.push((alias, schema)); - Ok(()) - } - - /// Returns the schemas of every registered local tool. - /// - /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. - pub(crate) fn schemas(&self) -> Result> { - Ok(self - .entries()? - .iter() - .map(|(_, schema)| schema.clone()) - .collect()) - } - - /// Returns whether `alias` names a registered local tool. - /// - /// # Errors - /// Returns [`Error::Internal`] if the entries lock was poisoned. - pub(crate) fn contains(&self, alias: &str) -> Result { - Ok(self - .entries()? - .iter() - .any(|(name, _)| name.as_str() == alias)) - } - - #[cfg(test)] - pub(crate) fn entries_handle(&self) -> Arc>> { - Arc::clone(&self.entries) - } -} - impl SectionVm { /// Creates a hardened section VM. /// @@ -274,7 +211,7 @@ impl SectionVm { tool_runtime: Arc::new(Mutex::new(ToolRuntime::default())), model_runtime: Arc::new(Mutex::new(ModelRuntime::new())), jump_slot: Arc::new(Mutex::new(None)), - local_handler_depth: Arc::new(AtomicU32::new(0)), + adhoc_handler_depth: Arc::new(AtomicU32::new(0)), loop_running: Arc::new(AtomicBool::new(false)), library_load_depth: Arc::new(AtomicU32::new(0)), library_programs: Arc::new(Mutex::new(Vec::new())), @@ -282,7 +219,7 @@ impl SectionVm { store_conflicts: Arc::new(Mutex::new(None)), log_budget: Arc::new(AtomicU32::new(DEFAULT_LUA_LOG_EVENTS)), log_byte_budget: Arc::new(AtomicUsize::new(log_byte_budget(DEFAULT_LUA_LOG_EVENTS))), - local_tools: LocalTools::default(), + adhoc_tools: AdhocTools::default(), instruction_budget: InstructionBudget::default(), raw_model_ids: RawModelIds::Refused, messages: MessageList::default(), @@ -382,13 +319,19 @@ impl SectionVm { self.instruction_budget.set_cancel(cancel); } - /// The section's tool-addition runtime: what `tools.offer` and - /// `packs.offer` recorded. + /// The section's offers: what `tools.offer` and `tools.offer_dynamic` + /// recorded. #[must_use] pub fn tool_runtime(&self) -> &Arc> { &self.tool_runtime } + /// The adhoc tools `tools.adhoc` defined on this VM. + #[must_use] + pub fn adhoc_tools(&self) -> &AdhocTools { + &self.adhoc_tools + } + /// The section's model-selection runtime: what `models.use` recorded. #[must_use] pub fn model_runtime(&self) -> &Arc> { diff --git a/crates/runvil-internal/lua/src/vm/adhoc.rs b/crates/runvil-internal/lua/src/vm/adhoc.rs new file mode 100644 index 000000000..7856d162e --- /dev/null +++ b/crates/runvil-internal/lua/src/vm/adhoc.rs @@ -0,0 +1,76 @@ +//! The adhoc tools a section VM defines: each one's binding, looked up by +//! wire name or by id. + +use std::sync::{Arc, Mutex, MutexGuard}; + +use crate::error::{Error, Result}; +use crate::handles::ToolBinding; + +/// The adhoc tools `tools.adhoc` defined on a section VM, in definition +/// order. +/// +/// Each entry is the tool's binding, under its wire name, with no vendor, +/// and never describing tools. The tool's function sits in the VM's handler +/// table under the wire name. The entries are shared with the `tools.adhoc` +/// Lua callback, which must be `Send`, hence the `Mutex`; the VM is +/// single-threaded, so the lock never contends. +#[derive(Debug, Default, Clone)] +pub struct AdhocTools { + entries: Arc>>, +} + +impl AdhocTools { + /// Locks the registry. + /// + /// # Errors + /// Returns [`Error::Internal`] if the entries lock was poisoned. + fn entries(&self) -> Result>> { + self.entries + .lock() + .map_err(|_| Error::Internal("adhoc tools registry was poisoned".into())) + } + + /// Registers the adhoc tool `binding` binds. + /// + /// # Errors + /// Returns [`Error::Internal`] if the entries lock was poisoned. + pub(crate) fn register(&self, binding: ToolBinding) -> Result<()> { + self.entries()?.push(binding); + Ok(()) + } + + /// Returns the binding of the adhoc tool whose id is `name` when it + /// holds a `/`, else whose wire name is `name`. + /// + /// # Errors + /// Returns [`Error::Internal`] if the entries lock was poisoned. + pub fn binding(&self, name: &str) -> Result> { + let by_id = name.contains('/'); + Ok(self + .entries()? + .iter() + .find(|binding| { + if by_id { + binding.id().to_string() == name + } else { + binding.alias() == name + } + }) + .cloned()) + } + + /// Returns every adhoc tool's binding, in id order. + /// + /// # Errors + /// Returns [`Error::Internal`] if the entries lock was poisoned. + pub(crate) fn bindings(&self) -> Result> { + let mut bindings = self.entries()?.clone(); + bindings.sort_by(|left, right| left.id().cmp(right.id())); + Ok(bindings) + } + + #[cfg(test)] + pub(crate) fn entries_handle(&self) -> Arc>> { + Arc::clone(&self.entries) + } +} diff --git a/crates/runvil-internal/lua/src/vm/inject.rs b/crates/runvil-internal/lua/src/vm/inject.rs index f8c7de7ac..3fd6abc73 100644 --- a/crates/runvil-internal/lua/src/vm/inject.rs +++ b/crates/runvil-internal/lua/src/vm/inject.rs @@ -1,7 +1,6 @@ //! Engine injection: the one step that turns an uninjected section VM into //! an injected one, installing `args`, `argv`, `sys`, `var`, the `tools`, -//! `packs`, `models`, and `compactors` tables, and the VM's `messages` -//! list. +//! `models`, and `compactors` tables, and the VM's `messages` list. use std::sync::Arc; @@ -15,7 +14,6 @@ use crate::compactors::install_compactors; use crate::error::{Error, Result}; use crate::messages::install_messages; use crate::models::install_models; -use crate::packs::install_packs; use crate::sys::{guarded_var, seal_sys}; use crate::tools::install_tools; @@ -139,14 +137,7 @@ impl SectionVm { &globals, &self.bound_tools, &self.tool_runtime, - &self.local_tools, - &self.messages, - )?; - install_packs( - &self.lua, - &globals, - &self.bound_tools, - &self.tool_runtime, + &self.adhoc_tools, &self.messages, )?; install_models( @@ -169,7 +160,7 @@ impl SectionVm { tool_runtime, model_runtime, jump_slot, - local_handler_depth, + adhoc_handler_depth, loop_running, library_load_depth, library_programs, @@ -177,7 +168,7 @@ impl SectionVm { store_conflicts, log_budget, log_byte_budget, - local_tools, + adhoc_tools, instruction_budget, raw_model_ids, messages, @@ -190,7 +181,7 @@ impl SectionVm { tool_runtime, model_runtime, jump_slot, - local_handler_depth, + adhoc_handler_depth, loop_running, library_load_depth, library_programs, @@ -198,7 +189,7 @@ impl SectionVm { store_conflicts, log_budget, log_byte_budget, - local_tools, + adhoc_tools, instruction_budget, raw_model_ids, messages, diff --git a/crates/runvil-internal/lua/src/vm/install.rs b/crates/runvil-internal/lua/src/vm/install.rs index 6c1baac05..64272077e 100644 --- a/crates/runvil-internal/lua/src/vm/install.rs +++ b/crates/runvil-internal/lua/src/vm/install.rs @@ -139,7 +139,7 @@ impl SectionVm { install_shim_prelude( &self.lua, max_tool_iterations, - &self.local_handler_depth, + &self.adhoc_handler_depth, &self.loop_running, &self.instruction_budget, ) @@ -198,14 +198,14 @@ impl SectionVm { fn install_jump_global(&self, globals: &mlua::Table) -> Result<()> { let jump_slot = Arc::clone(&self.jump_slot); - let local_handler_depth = Arc::clone(&self.local_handler_depth); + let adhoc_handler_depth = Arc::clone(&self.adhoc_handler_depth); let library_load_depth = Arc::clone(&self.library_load_depth); let jump_fn = self .lua .create_function(move |_, target: Value| -> mlua::Result<()> { - if local_handler_depth.load(Ordering::Relaxed) > 0 { + if adhoc_handler_depth.load(Ordering::Relaxed) > 0 { return Err(mlua::Error::external( - "jump is unavailable inside a local tool handler: return a value from \ + "jump is unavailable inside an adhoc tool handler: return a value from \ the handler and call jump from the block after the tool call returns", )); } diff --git a/crates/runvil-internal/lua/src/vm/state.rs b/crates/runvil-internal/lua/src/vm/state.rs index 5c5b21596..2ada37889 100644 --- a/crates/runvil-internal/lua/src/vm/state.rs +++ b/crates/runvil-internal/lua/src/vm/state.rs @@ -1,20 +1,21 @@ //! Section VM state reads: `var`, bare globals, `argv`, the tool call -//! counts, the local tools, and the section's effective tool and model -//! bindings. +//! counts, the rendered list's entries, and the section's effective tool +//! and model bindings. use std::borrow::Cow; use std::sync::Mutex; use mlua::{LuaSerdeExt, Value}; -use runvil_model_client::client::ToolSchema; use runvil_model_client::model::{ModelBinding, ModelView}; +use runvil_types::tools::ToolId; use serde_json::Value as Json; -use super::SectionVm; +use super::{AdhocTools, SectionVm}; use crate::error::{Error, Result}; use crate::handles::{ToolBinding, ToolSet}; use crate::models::ModelRuntime; -use crate::scope::{ToolCallCounts, ToolRuntime}; +use crate::packs::is_adhoc; +use crate::scope::{Advertised, OfferMode, OfferTarget, ToolCallCounts, ToolRuntime}; use crate::sys::var_to_json; use crate::tools::install_tool_call_counts as install_tool_call_counts_impl; @@ -64,7 +65,7 @@ impl SectionVm { } /// Installs `tools.calls` as a read-only Lua table backed by a fresh - /// [`ToolCallCounts`] seeded with the ids of `bindings`. Each seeded key + /// [`ToolCallCounts`] seeded with `ids`. Each seeded key /// reads its live count; indexing an unseeded key is a hard error that /// names the bad key and lists the seeded set. When the key is the id /// of a tool the run offers but was never seeded - neither offered to @@ -79,24 +80,31 @@ impl SectionVm { /// /// # Errors /// Returns [`Error::LuaRuntime`] when installing the `tools.calls` index fails. - pub fn install_tool_call_counts(&self, bindings: &[ToolBinding]) -> Result { - install_tool_call_counts_impl(&self.lua, &self.bound_tools, bindings) + pub fn install_tool_call_counts(&self, ids: &[ToolId]) -> Result { + install_tool_call_counts_impl(&self.lua, &self.bound_tools, &self.adhoc_tools, ids) } - /// Returns the schemas of every registered local tool. - /// - /// # Errors - /// Returns [`Error::Internal`] if the local-tools registry was poisoned. - pub fn local_tool_schemas(&self) -> Result> { - self.local_tools.schemas() + /// Returns the tool the section's rendered list advertises under + /// `wire_name`, or `None` when no list is rendered or it has no such + /// name. + #[must_use] + pub fn rendered_target(&self, wire_name: &str) -> Option { + self.messages.rendered_target(wire_name) } - /// Returns whether `alias` names a registered local tool. - /// - /// # Errors - /// Returns [`Error::Internal`] if the local-tools registry was poisoned. - pub fn has_local_tool(&self, alias: &str) -> Result { - self.local_tools.contains(alias) + /// Returns the ids of the tools the section's rendered list + /// advertises, never its dynamic ones, or `None` when no list is + /// rendered. + #[must_use] + pub fn rendered_ids(&self) -> Option> { + let rendered = self.messages.rendered_tools()?; + Some( + rendered + .dispatch + .into_values() + .map(|advertised| advertised.id) + .collect(), + ) } /// How many of this VM's instruction-hook firings have counted. @@ -106,47 +114,104 @@ impl SectionVm { } } -/// Reads the section's effective tool bindings without mutating the tool -/// runtime: the section's `tools.offer` tools in first-offer order, then -/// every offered tool of each `packs.offer` Pack, Packs in -/// first-offer order and each Pack's tools in id order. Each tool -/// appears once, at its first place, resolved against the offering. +/// Reads the section's static bindings without mutating the tool +/// runtime: each static offer in call order, a tool adding itself and a +/// Pack adding its tools in id order, each tool once, at its first place. +/// Catalog ids resolve against the offering and adhoc ids against +/// `adhoc`. Last, an offered catalog tool whose wire name an offered adhoc +/// tool shares is dropped. /// /// The section's first loop round renders its tool list from these -/// bindings. `tools.offer`, `packs.offer`, and `tools.offer_local` raise -/// after that, so the bindings never change once the list is rendered. +/// bindings. An offered adhoc Pack expands to the adhoc tools defined +/// when this function runs, so after the render the Engine reads the +/// rendered list instead. /// /// # Errors -/// Returns [`Error::Internal`] if the tool runtime's mutex is poisoned or an -/// offered wire name has no binding. +/// Returns [`Error::Internal`] if the tool runtime's mutex or the adhoc +/// tools are poisoned, or an offered id has no binding. pub fn current_tool_bindings( - bindings: &ToolSet, + set: &ToolSet, + runtime: &Mutex, + adhoc: &AdhocTools, +) -> Result> { + let mut bindings: Vec = Vec::new(); + for binding in expanded(set, runtime, adhoc, OfferMode::Static)? { + if !bindings.iter().any(|held| held.id() == binding.id()) { + bindings.push(binding); + } + } + let adhoc_names: Vec = bindings + .iter() + .filter(|binding| is_adhoc(&binding.id().pack())) + .map(|binding| binding.alias().to_owned()) + .collect(); + bindings.retain(|binding| { + is_adhoc(&binding.id().pack()) || !adhoc_names.iter().any(|name| name == binding.alias()) + }); + Ok(bindings) +} + +/// Reads the section's dynamic list without mutating the tool runtime: +/// each tool the dynamic offers name, in offer order and once, unless +/// `static_bindings` holds its id or its wire name. +/// +/// # Errors +/// Returns [`Error::Internal`] if the tool runtime's mutex or the adhoc +/// tools are poisoned, or an offered id has no binding. +pub fn current_dynamic_tools( + set: &ToolSet, runtime: &Mutex, + adhoc: &AdhocTools, + static_bindings: &[ToolBinding], +) -> Result> { + let mut dynamic: Vec = Vec::new(); + for binding in expanded(set, runtime, adhoc, OfferMode::Dynamic)? { + let in_static = static_bindings + .iter() + .any(|held| held.id() == binding.id() || held.alias() == binding.alias()); + if !in_static && !dynamic.contains(binding.id()) { + dynamic.push(binding.id().clone()); + } + } + Ok(dynamic) +} + +/// Every binding the offers of `mode` name, in call order, with repeats: +/// the one expansion both lists read, so they never disagree about what +/// an offer names. +fn expanded( + set: &ToolSet, + runtime: &Mutex, + adhoc: &AdhocTools, + mode: OfferMode, ) -> Result> { let runtime = runtime .lock() .map_err(|_| Error::Internal("tool declaration runtime was poisoned".into()))?; - let pack_tools = runtime.packs.iter().flat_map(|pack| { - bindings - .offered() - .iter() - .filter(move |binding| binding.id().pack() == *pack) - .map(ToolBinding::alias) - }); - let mut names: Vec<&str> = Vec::new(); - for name in runtime.added.iter().map(String::as_str).chain(pack_tools) { - if !names.contains(&name) { - names.push(name); + let mut bindings = Vec::new(); + for offer in runtime.offers.iter().filter(|offer| offer.mode == mode) { + match &offer.target { + OfferTarget::Tool(id) => { + let name = id.to_string(); + let binding = if is_adhoc(&id.pack()) { + adhoc.binding(&name)? + } else { + set.offered_binding(&name).cloned() + }; + bindings.push(binding.ok_or_else(|| { + Error::Internal(Cow::Owned(format!("offered tool {name:?} has no binding"))) + })?); + } + OfferTarget::Pack(pack) if is_adhoc(pack) => bindings.extend(adhoc.bindings()?), + OfferTarget::Pack(pack) => bindings.extend( + set.offered() + .iter() + .filter(|binding| binding.id().pack() == *pack) + .cloned(), + ), } } - names - .into_iter() - .map(|name| { - bindings.offered_binding(name).cloned().ok_or_else(|| { - Error::Internal(Cow::Owned(format!("tool {name:?} has no offered binding"))) - }) - }) - .collect() + Ok(bindings) } /// Reads the section's effective model binding through the run's model view diff --git a/crates/runvil-internal/parser/src/contract.rs b/crates/runvil-internal/parser/src/contract.rs index 5a05154e4..1fd58b72e 100644 --- a/crates/runvil-internal/parser/src/contract.rs +++ b/crates/runvil-internal/parser/src/contract.rs @@ -125,6 +125,11 @@ pub(crate) enum PackListError { /// The list names this Pack more than once. #[error("invalid frontmatter: Pack id {0} is declared more than once under uses")] Duplicate(PackId), + /// The list names the Pack id `adhoc`, from any vendor. + #[error( + "invalid frontmatter: adhoc holds a section's own tools and is never declared under uses" + )] + Adhoc, } /// Refuses a `uses:` list that names one Pack id twice, whatever the @@ -138,3 +143,13 @@ pub(crate) fn check_distinct_packs(packs: &[PackRef]) -> Result<(), PackListErro None => Ok(()), } } + +/// Refuses a `uses:` list that names the Pack id `adhoc`, whatever the +/// vendor, because `adhoc` holds a section's own tools. +pub(crate) fn check_no_adhoc_pack(packs: &[PackRef]) -> Result<(), PackListError> { + if packs.iter().any(|pack| pack.id().to_string() == "adhoc") { + Err(PackListError::Adhoc) + } else { + Ok(()) + } +} diff --git a/crates/runvil-internal/parser/src/contract/models.rs b/crates/runvil-internal/parser/src/contract/models.rs index dd24617ac..01b740ac9 100644 --- a/crates/runvil-internal/parser/src/contract/models.rs +++ b/crates/runvil-internal/parser/src/contract/models.rs @@ -84,7 +84,7 @@ impl ModelRole { /// keyed by label. /// /// A label is a name local to the prompt. It follows the same grammar as -/// a local tool alias: `[A-Za-z][A-Za-z0-9_-]{0,63}`. +/// an adhoc tool alias: `[A-Za-z][A-Za-z0-9_-]{0,63}`. /// /// A script reaches a role's model handle with `models.get(label)`. A label /// is never a Lua global, so it may be a reserved name. diff --git a/crates/runvil-internal/parser/src/contract/tests-packs.rs b/crates/runvil-internal/parser/src/contract/tests-packs.rs index c54943b6a..62bae43d4 100644 --- a/crates/runvil-internal/parser/src/contract/tests-packs.rs +++ b/crates/runvil-internal/parser/src/contract/tests-packs.rs @@ -133,6 +133,24 @@ fn a_pack_id_declared_twice_is_refused() { } } +#[test] +fn the_adhoc_pack_id_is_refused_from_any_vendor() { + for entries in ["[adhoc]", "[acme/adhoc]", "[web, runvil/adhoc]"] { + let error = parse_err(&prompt(&format!("uses: {entries}"), &[])); + assert_eq!( + error.kind(), + ParseErrorKind::Frontmatter, + "{entries}: {error}" + ); + assert_eq!( + error.to_string(), + "invalid frontmatter: adhoc holds a section's own tools and is never declared \ + under uses", + "{entries}" + ); + } +} + #[test] fn a_list_of_distinct_pack_ids_still_parses() { let prompt = parse_ok(&prompt("uses: [web, web2, web-search, acme/poop]", &[])); diff --git a/crates/runvil-internal/parser/src/parse.rs b/crates/runvil-internal/parser/src/parse.rs index 4531567ee..6c2509fb3 100644 --- a/crates/runvil-internal/parser/src/parse.rs +++ b/crates/runvil-internal/parser/src/parse.rs @@ -98,11 +98,13 @@ impl Prompt { column, } })?; - contract::check_distinct_packs(frontmatter.uses()).map_err(|refusal| match refusal { - PackListError::Duplicate(_) => { - Error::parse(ParseErrorKind::Frontmatter, refusal.to_string()) - } - })?; + contract::check_distinct_packs(frontmatter.uses()) + .and_then(|()| contract::check_no_adhoc_pack(frontmatter.uses())) + .map_err(|refusal| match refusal { + PackListError::Duplicate(_) | PackListError::Adhoc => { + Error::parse(ParseErrorKind::Frontmatter, refusal.to_string()) + } + })?; // Everything past the frontmatter postdates the prompt's name, so a // failure from here on is stamped with it (and its span's position). let name = frontmatter.name().to_owned(); diff --git a/crates/runvil-internal/types/src/emitter-tests.rs b/crates/runvil-internal/types/src/emitter-tests.rs index 50719ea3e..52f072475 100644 --- a/crates/runvil-internal/types/src/emitter-tests.rs +++ b/crates/runvil-internal/types/src/emitter-tests.rs @@ -188,6 +188,7 @@ fn content_reports_land_in_the_buffer_in_order() { "call_1", "echo", Some(&echo), + Some("acme"), "out", OutputTrust::Trusted, ); @@ -195,8 +196,8 @@ fn content_reports_land_in_the_buffer_in_order() { let events = sink.take(); assert!(matches!( &events[0], - Event::ToolResult { turn: 3, tool_call_id, alias, tool: Some(tool), content, trusted: true, .. } - if tool_call_id == "call_1" && alias == "echo" && *tool == echo && content == "out" + Event::ToolResult { turn: 3, tool_call_id, alias, tool: Some(tool), vendor: Some(vendor), content, trusted: true, .. } + if tool_call_id == "call_1" && alias == "echo" && *tool == echo && vendor == "acme" && content == "out" )); assert!(matches!( &events[1], @@ -215,6 +216,7 @@ fn an_untrusted_tool_result_reports_the_event_with_trusted_false() { "call_9", "fetch", None, + None, "", OutputTrust::Untrusted, ); @@ -228,6 +230,7 @@ fn an_untrusted_tool_result_reports_the_event_with_trusted_false() { tool_call_id: "call_9".to_owned(), alias: "fetch".to_owned(), tool: None, + vendor: None, content: "".to_owned(), trusted: false, }], diff --git a/crates/runvil-internal/types/src/emitter.rs b/crates/runvil-internal/types/src/emitter.rs index ea74da69e..651501de4 100644 --- a/crates/runvil-internal/types/src/emitter.rs +++ b/crates/runvil-internal/types/src/emitter.rs @@ -351,13 +351,14 @@ impl Emitter { }); } - /// Reports the result of one dispatched tool call. `tool` is the bound - /// tool `alias` resolved to, or `None` for a Lua-local tool. The event - /// records `trust` as its `trusted` flag: `true` only for - /// [`OutputTrust::Trusted`]. + /// Reports the result of one dispatched tool call. `tool` is the tool + /// `alias` resolved to, and `vendor` the vendor of the Pack that + /// answered it, or `None` for an adhoc tool or when the caller reported + /// none. The event records `trust` as its `trusted` flag: `true` only + /// for [`OutputTrust::Trusted`]. #[expect( clippy::too_many_arguments, - reason = "the result report names its full run coordinates, including the alias and the tool it resolved to, in one call" + reason = "the result report names its full run coordinates, including the alias, the tool it resolved to, and its vendor, in one call" )] pub fn tool_result( &self, @@ -366,6 +367,7 @@ impl Emitter { tool_call_id: &str, alias: &str, tool: Option<&ToolId>, + vendor: Option<&str>, content: &str, trust: OutputTrust, ) { @@ -379,6 +381,7 @@ impl Emitter { tool_call_id: tool_call_id.to_owned(), alias: alias.to_owned(), tool: tool.cloned(), + vendor: vendor.map(str::to_owned), content: content.to_owned(), trusted, } diff --git a/crates/runvil-internal/types/src/event-tests.rs b/crates/runvil-internal/types/src/event-tests.rs index 1ed737860..8bd6dac25 100644 --- a/crates/runvil-internal/types/src/event-tests.rs +++ b/crates/runvil-internal/types/src/event-tests.rs @@ -230,7 +230,8 @@ fn content_samples() -> Vec { model: "llama-3".to_owned(), calls: vec![ ToolCallEvent::new("call_1", "read_file", json!({ "path": "notes.txt" })) - .with_tool(Some(read_tool())), + .with_tool(Some(read_tool())) + .with_vendor(Some("acme".to_owned())), ], }, Event::ToolResult { @@ -241,6 +242,7 @@ fn content_samples() -> Vec { tool_call_id: "call_1".to_owned(), alias: "read_file".to_owned(), tool: Some(read_tool()), + vendor: Some("acme".to_owned()), content: "file contents".to_owned(), trusted: false, }, @@ -356,11 +358,18 @@ fn a_reply_without_origin_reads_back_as_chat() { #[test] fn a_tool_result_without_a_bound_tool_keeps_its_logged_shape() { - // A result logged before `tool` existed reads back with none, and a - // result with none writes the line it always did. + // A result logged without `tool` or `vendor` reads back with neither, + // and a result with neither writes that same line. let line = r#"{"kind":"tool_result","execution":"run-1","section":"Gather","provenance":{"task":"0","seq":5},"turn":2,"tool_call_id":"","alias":"ask","content":"hi","trusted":true}"#; let event: Event = serde_json::from_str(line).expect("an older tool result parses"); - assert!(matches!(&event, Event::ToolResult { tool: None, .. })); + assert!(matches!( + &event, + Event::ToolResult { + tool: None, + vendor: None, + .. + } + )); assert_wire(&event, line); } diff --git a/crates/runvil-internal/types/src/event.rs b/crates/runvil-internal/types/src/event.rs index 842585077..ea91d965f 100644 --- a/crates/runvil-internal/types/src/event.rs +++ b/crates/runvil-internal/types/src/event.rs @@ -402,10 +402,15 @@ events! { tool_call_id: String, /// The alias the call named. alias: String, - /// The bound tool `alias` resolved to; `None` for a Lua-local - /// tool or an event logged before the field. + /// The tool `alias` resolved to; `None` for an event logged + /// before the field. #[serde(default, skip_serializing_if = "Option::is_none")] tool: Option, + /// The vendor of the Pack that answered `tool`, as the caller + /// reported it; `None` for an adhoc tool, a run given no + /// roster, or an event logged before the field. + #[serde(default, skip_serializing_if = "Option::is_none")] + vendor: Option, /// The tool's output: untrusted unless `trusted`. content: String, /// Whether the dispatch treated the tool as trusted (its output diff --git a/crates/runvil-internal/types/src/metrics-tests.rs b/crates/runvil-internal/types/src/metrics-tests.rs index 499bebeaa..2e3a2d6f4 100644 --- a/crates/runvil-internal/types/src/metrics-tests.rs +++ b/crates/runvil-internal/types/src/metrics-tests.rs @@ -49,7 +49,7 @@ fn a_constructor_leaves_every_optional_field_at_its_default() { let client = ClientTiming::new(41.5); assert_eq!((client.ttft_ms, client.mean_itl_ms), (None, None)); let call = ToolCallEvent::new("call_1", "fetch", json!({})); - assert_eq!(call.tool, None); + assert_eq!((call.tool, call.vendor), (None, None)); } #[test] @@ -71,7 +71,8 @@ fn every_vocabulary_type_round_trips_through_serde() { json!({ "path": "notes.txt", "lines": 3 }), ), ToolCallEvent::new("call_1", "fetch", json!({})) - .with_tool(Some(ToolId::parse("web/fetch").expect("a valid tool id"))), + .with_tool(Some(ToolId::parse("web/fetch").expect("a valid tool id"))) + .with_vendor(Some("runvil".to_owned())), ] { assert_eq!(round_trip(&call), call); } @@ -79,11 +80,11 @@ fn every_vocabulary_type_round_trips_through_serde() { #[test] fn a_tool_call_without_a_bound_tool_keeps_its_logged_shape() { - // A call logged before the field existed reads back with no tool, - // and a call with none writes the same line it always did. + // A call logged without `tool` or `vendor` reads back with neither, + // and a call with neither writes that same line. let line = r#"{"id":"call_1","name":"fetch","arguments":{}}"#; let call: ToolCallEvent = serde_json::from_str(line).expect("an older call deserializes"); - assert_eq!(call.tool, None); + assert_eq!((&call.tool, &call.vendor), (&None, &None)); assert_wire(&call, line); } diff --git a/crates/runvil-internal/types/src/metrics.rs b/crates/runvil-internal/types/src/metrics.rs index 505ba4bfd..d6be6a876 100644 --- a/crates/runvil-internal/types/src/metrics.rs +++ b/crates/runvil-internal/types/src/metrics.rs @@ -16,8 +16,8 @@ use crate::tools::ToolId; /// Records one tool call the model requested. /// -/// It holds the call's id, name, and raw arguments, and the bound tool the -/// name resolved to. +/// It holds the call's id, name, and raw arguments, the bound tool the +/// name resolved to, and the vendor of the Pack that answers it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub struct ToolCallEvent { @@ -28,15 +28,22 @@ pub struct ToolCallEvent { pub name: String, /// The call arguments exactly as the model produced them. pub arguments: serde_json::Value, - /// The bound tool `name` resolved to; `None` for a Lua-local tool or a - /// name outside the round's scope. + /// The tool `name` resolved to; `None` for a name outside the round's + /// scope. #[serde(default, skip_serializing_if = "Option::is_none")] pub tool: Option, + /// The vendor of the Pack that answers `tool`, as the caller reported + /// it, such as `runvil`; `None` for an adhoc tool, a name outside the + /// round's scope, or a run given no roster. A vendor is one segment, + /// so `vendor/tool` is the vendor-qualified name, such as + /// `runvil/user-input/ask`, and only when `vendor` is present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vendor: Option, } impl ToolCallEvent { /// Builds the record of call `id` to `name` with `arguments`, bound to - /// no tool. + /// no tool and naming no vendor. #[must_use] pub fn new( id: impl Into, @@ -48,6 +55,7 @@ impl ToolCallEvent { name: name.into(), arguments, tool: None, + vendor: None, } } @@ -57,6 +65,13 @@ impl ToolCallEvent { self.tool = tool; self } + + /// Sets the vendor of the Pack that answers the bound tool. + #[must_use] + pub fn with_vendor(mut self, vendor: Option) -> ToolCallEvent { + self.vendor = vendor; + self + } } /// Groups everything measured about one model call by the source that diff --git a/crates/runvil-internal/types/src/tools/info.rs b/crates/runvil-internal/types/src/tools/info.rs index a0416de96..892a971b0 100644 --- a/crates/runvil-internal/types/src/tools/info.rs +++ b/crates/runvil-internal/types/src/tools/info.rs @@ -9,8 +9,9 @@ use super::ids::ToolId; /// A tool described as data. /// /// A `ToolInfo` holds the tool's stable id, the description the model -/// reads, its parameter schema, whether its output is structured, and -/// whether a stop leaves its calls running. +/// reads, its parameter schema, whether its output is structured, +/// whether a stop leaves its calls running, and whether its output lists +/// tools. /// /// The caller collects the `ToolInfo` values of every Pack it can serve /// into a [`ToolCatalog`](super::ToolCatalog). The implementations stay with @@ -40,10 +41,19 @@ pub struct ToolInfo { /// `false`. #[serde(default)] pub survives_stop: bool, + /// Whether the tool's output lists tools, such as the tools of the + /// servers a Pack reaches. A script that calls such a tool receives + /// its untrusted output as plain text, so the script can read the + /// listing, while a model's call still receives the untrusted + /// envelope. A `ToolInfo` serialized without the field reads as + /// `false`. + #[serde(default)] + pub describes_tools: bool, } impl ToolInfo { - /// Builds a `ToolInfo` with plain-text output that a stop reaches. + /// Builds a `ToolInfo` with plain-text output that a stop reaches and + /// that lists no tools. #[must_use] pub fn new(id: ToolId, description: impl Into, parameters_schema: Value) -> ToolInfo { ToolInfo { @@ -52,6 +62,7 @@ impl ToolInfo { parameters_schema, structured_output: false, survives_stop: false, + describes_tools: false, } } @@ -70,4 +81,12 @@ impl ToolInfo { self.survives_stop = survives; self } + + /// Sets whether the tool's output lists tools (`true`) or not + /// (`false`). + #[must_use] + pub fn describes_tools(mut self, describes: bool) -> ToolInfo { + self.describes_tools = describes; + self + } } diff --git a/crates/runvil-plugin/src/registry-tests.rs b/crates/runvil-plugin/src/registry-tests.rs index 29261747d..9ce1dcfd0 100644 --- a/crates/runvil-plugin/src/registry-tests.rs +++ b/crates/runvil-plugin/src/registry-tests.rs @@ -112,6 +112,19 @@ fn add_refuses_a_third_party_pack_taking_the_builtins_pack_id() { ); } +#[test] +fn add_refuses_a_pack_taking_the_adhoc_pack_id_from_any_vendor() { + for fqn in ["acme/adhoc", "runvil/adhoc"] { + let mut packs = PackRegistry::new(); + let refused = packs.insert(fixture(fqn), Value::Null); + assert_eq!(refused, Err(InstallError::AdhocPackId { fqn }), "{fqn}"); + assert!( + !packs.list().any(|(info, _)| info.fqn == fqn), + "the refused {fqn} is not in the registry" + ); + } +} + #[test] fn add_accepts_a_pack_whose_id_another_vendor_has() { let mut packs = PackRegistry::new(); @@ -185,6 +198,7 @@ fn the_registry_refusals_name_the_pack() { fqn: "acme/subagents", name: name("subagents"), }, + InstallError::AdhocPackId { fqn: "acme/adhoc" }, ]; let messages: Vec = refusals.iter().map(ToString::to_string).collect(); assert_eq!( @@ -194,6 +208,8 @@ fn the_registry_refusals_name_the_pack() { "Pack `acme/web` is already in the registry", "Pack `acme/subagents` cannot take the Pack id subagents, \ which the built-in runvil/subagents reserves", + "Pack `acme/adhoc` cannot take the Pack id adhoc, \ + which holds a section's own tools", ] ); } diff --git a/crates/runvil-plugin/src/registry.rs b/crates/runvil-plugin/src/registry.rs index 689e8428f..ea1504575 100644 --- a/crates/runvil-plugin/src/registry.rs +++ b/crates/runvil-plugin/src/registry.rs @@ -90,13 +90,17 @@ impl PackRegistry { /// Returns [`InstallError::InvalidFqn`] when the FQN is not a /// `vendor/name` pair of valid one-segment names, /// [`InstallError::DuplicatePack`] when the registry holds the FQN - /// already, as it holds each built-in's from the start, and + /// already, as it holds each built-in's from the start, + /// [`InstallError::AdhocPackId`] when the Pack id is `adhoc`, and /// [`InstallError::ReservedName`] when the Pack id is a built-in's. pub fn insert(&mut self, info: PackInfo, config: Value) -> Result<(), InstallError> { let id = pack_id(info.fqn).ok_or(InstallError::InvalidFqn { fqn: info.fqn })?; if self.entries.contains_key(info.fqn) { return Err(InstallError::DuplicatePack { fqn: info.fqn }); } + if id.to_string() == ADHOC { + return Err(InstallError::AdhocPackId { fqn: info.fqn }); + } if self.reserves(&id) { return Err(InstallError::ReservedName { fqn: info.fqn, @@ -188,8 +192,18 @@ pub enum InstallError { /// The Pack id it would have taken. name: PackId, }, + /// The inserted Pack's id is `adhoc`, which holds a section's own + /// tools, whatever the vendor. + #[error("Pack `{fqn}` cannot take the Pack id adhoc, which holds a section's own tools")] + AdhocPackId { + /// The FQN as its `PackInfo` spells it. + fqn: &'static str, + }, } +/// The Pack id of a section's own tools, which no Pack may take. +const ADHOC: &str = "adhoc"; + /// The built-ins every registry holds from the start. /// /// A built-in Pack's behavior and tools live in the Engine. Its Pack diff --git a/crates/runvil-plugin/src/testing.rs b/crates/runvil-plugin/src/testing.rs index daadc73db..ac7e20b6c 100644 --- a/crates/runvil-plugin/src/testing.rs +++ b/crates/runvil-plugin/src/testing.rs @@ -8,8 +8,10 @@ use crate::{HostServices, ToolContext}; /// One tool call a test makes directly, without a Harness. /// /// It owns everything a [`ToolContext`] borrows: the called tool's id, an -/// access over a fresh in-memory filesystem, an origin naming the script -/// of the section `Test`, and the run's services, which are empty unless +/// access over a fresh in-memory filesystem, an origin naming the section +/// `Test` and a caller that is the script unless +/// [`with_caller`](TestCall::with_caller) names another, and the run's +/// services, which are empty unless /// [`with_services`](TestCall::with_services) supplies them. #[derive(Debug)] pub struct TestCall { @@ -51,6 +53,14 @@ impl TestCall { self } + /// Returns the call with `caller` as the origin's caller, keeping its + /// execution and section. + #[must_use] + pub fn with_caller(mut self, caller: ToolCaller) -> TestCall { + self.origin = ToolCallOrigin::new("plugin-test", "Test", caller); + self + } + /// Lends the context to one call. #[must_use] pub fn context(&self) -> ToolContext<'_> { diff --git a/crates/runvil-plugin/tests/it/test_call.rs b/crates/runvil-plugin/tests/it/test_call.rs index c0916c272..752e26d91 100644 --- a/crates/runvil-plugin/tests/it/test_call.rs +++ b/crates/runvil-plugin/tests/it/test_call.rs @@ -33,6 +33,15 @@ fn a_test_call_lends_its_tool_a_script_origin_and_a_working_filesystem() { ); } +#[test] +fn a_test_call_with_a_caller_lends_that_caller() { + let call = TestCall::new(inspect()).with_caller(ToolCaller::Model); + let cx = call.context(); + assert_eq!(cx.tool(), &inspect()); + assert_eq!(cx.origin().caller, ToolCaller::Model); + assert_eq!(cx.origin().section, "Test"); +} + #[test] fn a_test_call_lends_the_services_it_was_given() { let mut services = HostServices::new(); diff --git a/crates/runvil/public-api.txt b/crates/runvil/public-api.txt index d1636fe4c..00390e652 100644 --- a/crates/runvil/public-api.txt +++ b/crates/runvil/public-api.txt @@ -45,6 +45,7 @@ #[non_exhaustive] pub struct runvil::SourceLocation { .. } #[non_exhaustive] pub struct runvil::UnavailablePack { .. } #[non_exhaustive] pub struct runvil::UnmetRequirement { .. } +#[non_exhaustive] pub struct runvil::UsablePack { .. } #[non_exhaustive] pub struct runvil::effect::ChatAnswerRecord { .. } #[non_exhaustive] pub struct runvil::effect::FailureRecord { .. } #[non_exhaustive] pub struct runvil::effect::Round { .. } @@ -108,6 +109,7 @@ auto runvil::SourceLocation: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSaf auto runvil::Step: Freeze, !RefUnwindSafe, Send, Sync, Unpin, !UnwindSafe auto runvil::UnavailablePack: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::UnmetRequirement: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe +auto runvil::UsablePack: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::cancel::CancelHandle: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::cancel::Cancelled: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe auto runvil::effect::AnswerRecord: Freeze, RefUnwindSafe, Send, Sync, Unpin, UnwindSafe @@ -233,6 +235,7 @@ derives runvil::SourceLocation: Clone, Debug, Eq, PartialEq derives runvil::Step: Debug derives runvil::UnavailablePack: Clone, Debug, Eq, PartialEq derives runvil::UnmetRequirement: Clone, Debug, Eq, PartialEq +derives runvil::UsablePack: Clone, Debug, Eq, PartialEq derives runvil::cancel::CancelHandle: Clone, Debug, Default derives runvil::cancel::Cancelled: Debug derives runvil::effect::AnswerRecord: Clone, Debug, Deserialize, PartialEq, Serialize @@ -474,7 +477,7 @@ pub fn runvil::RunContext::limits(self, limits: runvil::RunLimits) -> runvil::Ru pub fn runvil::RunContext::model(self, model: runvil::model::ModelInfo) -> runvil::RunContext pub fn runvil::RunContext::model_bindings(&self) -> &runvil::model::ModelBindings pub fn runvil::RunContext::new(name: impl core::convert::Into, seed: runvil::RunSeed, started_at: runvil::timestamp::Timestamp) -> runvil::RunContext -pub fn runvil::RunContext::packs(self, usable: alloc::vec::Vec, unavailable: alloc::vec::Vec) -> runvil::RunContext +pub fn runvil::RunContext::packs(self, usable: alloc::vec::Vec, unavailable: alloc::vec::Vec) -> runvil::RunContext pub fn runvil::RunContext::preludes(self, preludes: alloc::vec::Vec) -> runvil::RunContext pub fn runvil::RunContext::prepare(self, prompt: &runvil::Prompt) -> (runvil::RunContext, runvil::Requirements) pub fn runvil::RunContext::provenance_start(self, start: u32) -> runvil::RunContext @@ -501,6 +504,7 @@ pub fn runvil::RunLimits::request_timeout(self, value: core::time::Duration) -> pub fn runvil::RunLimits::response_bytes(&self) -> core::num::nonzero::NonZeroU64 pub fn runvil::RunLimits::timeout(&self) -> core::time::Duration pub fn runvil::UnavailablePack::new(pack: runvil::packs::PackId, reason: impl core::convert::Into) -> runvil::UnavailablePack +pub fn runvil::UsablePack::new(pack: runvil::packs::PackId, vendor: impl core::convert::Into) -> runvil::UsablePack pub fn runvil::cancel::CancelHandle::cancel(&self) pub fn runvil::cancel::CancelHandle::cancelled(&self) -> runvil::cancel::Cancelled pub fn runvil::cancel::CancelHandle::child(&self) -> runvil::cancel::CancelHandle @@ -536,6 +540,7 @@ pub fn runvil::metrics::LlamaTimings::with_draft_n(self, draft_n: u32) -> runvil pub fn runvil::metrics::LlamaTimings::with_draft_n_accepted(self, draft_n_accepted: u32) -> runvil::metrics::LlamaTimings pub fn runvil::metrics::ToolCallEvent::new(id: impl core::convert::Into, name: impl core::convert::Into, arguments: serde_json::value::Value) -> runvil::metrics::ToolCallEvent pub fn runvil::metrics::ToolCallEvent::with_tool(self, tool: core::option::Option) -> runvil::metrics::ToolCallEvent +pub fn runvil::metrics::ToolCallEvent::with_vendor(self, vendor: core::option::Option) -> runvil::metrics::ToolCallEvent pub fn runvil::metrics::Usage::new(prompt_tokens: u32, completion_tokens: u32, total_tokens: u32) -> runvil::metrics::Usage pub fn runvil::metrics::Usage::with_cached_tokens(self, cached_tokens: core::option::Option) -> runvil::metrics::Usage pub fn runvil::metrics::Usage::with_reasoning_tokens(self, reasoning_tokens: core::option::Option) -> runvil::metrics::Usage @@ -694,6 +699,7 @@ pub fn runvil::tools::ToolId::name(&self) -> &str pub fn runvil::tools::ToolId::pack(&self) -> runvil::packs::PackId pub fn runvil::tools::ToolId::parse(id: &str) -> core::result::Result pub fn runvil::tools::ToolIdError::kind(&self) -> runvil::tools::ToolIdErrorKind +pub fn runvil::tools::ToolInfo::describes_tools(self, describes: bool) -> runvil::tools::ToolInfo pub fn runvil::tools::ToolInfo::new(id: runvil::tools::ToolId, description: impl core::convert::Into, parameters_schema: serde_json::value::Value) -> runvil::tools::ToolInfo pub fn runvil::tools::ToolInfo::structured(self, structured: bool) -> runvil::tools::ToolInfo pub fn runvil::tools::ToolInfo::survives_stop(self, survives: bool) -> runvil::tools::ToolInfo @@ -798,6 +804,7 @@ pub runvil::RequirementCheck::ContextMinimum pub runvil::RequirementCheck::HardKeyword pub runvil::Requirements::missing_required: alloc::vec::Vec pub runvil::Requirements::missing_services: alloc::vec::Vec +pub runvil::Requirements::reserved_adhoc: bool pub runvil::Requirements::unavailable: alloc::vec::Vec pub runvil::Requirements::unmet_requirements: alloc::vec::Vec pub runvil::RunErrorKind::Binding @@ -836,6 +843,8 @@ pub runvil::UnmetRequirement::actual: alloc::string::String pub runvil::UnmetRequirement::check: runvil::RequirementCheck pub runvil::UnmetRequirement::required: alloc::string::String pub runvil::UnmetRequirement::role: alloc::string::String +pub runvil::UsablePack::pack: runvil::packs::PackId +pub runvil::UsablePack::vendor: alloc::string::String pub runvil::effect::AnswerRecord::Chat(..) pub runvil::effect::AnswerRecord::Chat::0: core::result::Result pub runvil::effect::AnswerRecord::Dropped @@ -1134,6 +1143,7 @@ pub runvil::event::Event::ToolResult::tool: core::option::Option pub runvil::event::Event::ToolScopeValidationFailed { .. } pub runvil::event::Event::ToolScopeValidationFailed::execution: alloc::string::String pub runvil::event::Event::ToolScopeValidationFailed::provenance: runvil::ids::Provenance @@ -1238,6 +1248,7 @@ pub runvil::metrics::ToolCallEvent::arguments: serde_json::value::Value pub runvil::metrics::ToolCallEvent::id: alloc::string::String pub runvil::metrics::ToolCallEvent::name: alloc::string::String pub runvil::metrics::ToolCallEvent::tool: core::option::Option +pub runvil::metrics::ToolCallEvent::vendor: core::option::Option pub runvil::metrics::Usage::cached_tokens: core::option::Option pub runvil::metrics::Usage::completion_tokens: u32 pub runvil::metrics::Usage::prompt_tokens: u32 @@ -1307,6 +1318,7 @@ pub runvil::tools::ToolIdErrorKind::Charset pub runvil::tools::ToolIdErrorKind::Control pub runvil::tools::ToolIdErrorKind::Empty pub runvil::tools::ToolIdErrorKind::SegmentCount +pub runvil::tools::ToolInfo::describes_tools: bool pub runvil::tools::ToolInfo::description: alloc::string::String pub runvil::tools::ToolInfo::id: runvil::tools::ToolId pub runvil::tools::ToolInfo::parameters_schema: serde_json::value::Value diff --git a/crates/runvil/src/lib.rs b/crates/runvil/src/lib.rs index b091feb3e..0affbf270 100644 --- a/crates/runvil/src/lib.rs +++ b/crates/runvil/src/lib.rs @@ -23,6 +23,7 @@ pub use runvil_engine::SourceLocation; pub use runvil_engine::Step; pub use runvil_engine::UnavailablePack; pub use runvil_engine::UnmetRequirement; +pub use runvil_engine::UsablePack; pub use runvil_parser::ParseError; pub use runvil_parser::ParseErrorKind; pub use runvil_parser::Prompt; diff --git a/crates/runvil/tests/suite/event.rs b/crates/runvil/tests/suite/event.rs index 7ed1b519c..9c7f44443 100644 --- a/crates/runvil/tests/suite/event.rs +++ b/crates/runvil/tests/suite/event.rs @@ -2,7 +2,9 @@ use std::collections::HashSet; +use runvil::UsablePack; use runvil::event::{DebugMode, Event}; +use runvil::packs::PackId; use serde_json::Value; use super::support::{Scenario, TestResult, note, prompt, shout, text}; @@ -47,7 +49,10 @@ fn every_event_round_trips_as_a_json_line_and_no_two_share_a_provenance() -> Tes #[test] fn content_events_pair_each_tool_result_with_its_caller_into_a_transcript() -> TestResult { let (source, scenario) = shouting(); - let trace = scenario.run(&source)?; + let example = PackId::parse("example")?; + let trace = scenario + .context(move |ctx| ctx.packs(vec![UsablePack::new(example.clone(), "acme")], vec![])) + .run(&source)?; assert_eq!(trace.text()?, "HI THERE"); let (mut asked, mut transcript) = (HashSet::new(), Vec::new()); @@ -64,6 +69,7 @@ fn content_events_pair_each_tool_result_with_its_caller_into_a_transcript() -> T tool_call_id, alias, tool, + vendor, content, .. } => { @@ -72,9 +78,11 @@ fn content_events_pair_each_tool_result_with_its_caller_into_a_transcript() -> T } else { "script" }; - let tool = tool - .as_ref() - .map_or("no tool".to_owned(), ToString::to_string); + let tool = match (vendor, tool) { + (Some(vendor), Some(tool)) => format!("{vendor}/{tool}"), + (None, Some(tool)) => tool.to_string(), + (_, None) => "no tool".to_owned(), + }; transcript.push(format!("{alias} ({tool}) for the {caller}: {content}")); } _ => {} @@ -84,9 +92,9 @@ fn content_events_pair_each_tool_result_with_its_caller_into_a_transcript() -> T transcript, [ "Infer reply: hi there", - "example_text_shout (example/text/shout) for the script: HI THERE" + "example_text_shout (acme/example/text/shout) for the script: HI THERE" ], - "a tool's result names its wire name and the tool it resolved to" + "a tool's result names its wire name and the vendor-qualified tool it resolved to" ); Ok(()) } diff --git a/crates/runvil/tests/suite/packs.rs b/crates/runvil/tests/suite/packs.rs index 556858709..4f5b258dd 100644 --- a/crates/runvil/tests/suite/packs.rs +++ b/crates/runvil/tests/suite/packs.rs @@ -1,17 +1,18 @@ //! Handing Packs to a run. -use runvil::UnavailablePack; use runvil::packs::PackId; +use runvil::{UnavailablePack, UsablePack}; use super::support::{Scenario, TestResult, greeter}; #[test] fn two_runs_whose_contexts_carry_different_rosters_each_see_their_own() -> TestResult { - let source = greeter("return tostring(packs.get('kit') ~= nil)"); + let source = greeter("return tostring(tools.packs.get('kit') ~= nil)"); let kit = PackId::parse("kit")?; let usable = { let kit = kit.clone(); - Scenario::default().context(move |ctx| ctx.packs(vec![kit.clone()], vec![])) + Scenario::default() + .context(move |ctx| ctx.packs(vec![UsablePack::new(kit.clone(), "acme")], vec![])) }; let unavailable = Scenario::default() .context(move |ctx| ctx.packs(vec![], vec![UnavailablePack::new(kit.clone(), "down")])); diff --git a/crates/workshop/agents/agents/chat.md b/crates/workshop/agents/agents/chat.md index 48ea63798..6b6f8a427 100644 --- a/crates/workshop/agents/agents/chat.md +++ b/crates/workshop/agents/agents/chat.md @@ -27,8 +27,8 @@ models.default("chat") ## Conversation ```lua -packs.offer("web") -tools.offer(tools.extras()) +tools.offer("web") +tools.offer(tools.packs.extras()) local function compact(reason) local first = 1 while messages[first] and messages[first].role == "system" do diff --git a/crates/workshop/agents/src/transcript-tests.rs b/crates/workshop/agents/src/transcript-tests.rs index 1a19a67e8..76111bab5 100644 --- a/crates/workshop/agents/src/transcript-tests.rs +++ b/crates/workshop/agents/src/transcript-tests.rs @@ -26,6 +26,7 @@ fn answered(content: &str) -> Event { tool_call_id: String::new(), alias: "ask".to_owned(), tool: None, + vendor: None, content: content.to_owned(), trusted: true, } diff --git a/crates/workshop/server/src/agents.rs b/crates/workshop/server/src/agents.rs index 469776b22..f3b52467d 100644 --- a/crates/workshop/server/src/agents.rs +++ b/crates/workshop/server/src/agents.rs @@ -65,6 +65,7 @@ use broker::{RunBroker, WorkshopBroker}; use gateway::usable_gateway; use search::GatewaySearchProvider; pub(crate) use state::{SessionsState, register}; +use wire::AskTool; /// The directory under the server's state directory the run log sits in: /// the `runs.db` every conversation's run is recorded in. @@ -75,9 +76,9 @@ const HARNESS_STATE_DIR: &str = "harness"; const RUNTIME: ServiceKey = ServiceKey::new(runvil_plugin::TOKIO_RUNTIME); /// The server's built Packs over its Host-wide [`services`], beside the -/// ask tool's id. `mcp` is the `[agents] mcp` setting: the mcp Pack is -/// built over the servers of the file it names. -fn host_context(registry: &Registry, mcp: Option<&Path>) -> (HostContext, Option) { +/// ask tool's vendor and id. `mcp` is the `[agents] mcp` setting: the mcp +/// Pack is built over the servers of the file it names. +fn host_context(registry: &Registry, mcp: Option<&Path>) -> (HostContext, Option) { with_plugins(services(registry), read_mcp_servers(mcp)) } @@ -140,9 +141,10 @@ fn read_mcp_servers(setting: Option<&Path>) -> Value { /// Plugins of the Packs agents may declare: `web`, `user-input`, so they /// can ask the operator, and `mcp`, with the `servers` object as its /// configuration. Building the Host builds those Packs and the built-in -/// `subagents`. The ask tool's id is the fixed `user-input/ask`, and -/// `None` only if the user-input Plugin was refused. -fn with_plugins(services: HostServices, servers: Value) -> (HostContext, Option) { +/// `subagents`. The ask tool is the fixed `user-input/ask` of the +/// `runvil/user-input` Pack, so its vendor is `runvil`, and `None` only if +/// the user-input Plugin was refused. +fn with_plugins(services: HostServices, servers: Value) -> (HostContext, Option) { let mut packs = PackRegistry::new(); if let Err(error) = plugin_web::install(&mut packs, Value::Null) { tracing::warn!(%error, "the web Plugin inserted no Pack"); @@ -150,7 +152,11 @@ fn with_plugins(services: HostServices, servers: Value) -> (HostContext, Option< let ask = match plugin_user_input::install(&mut packs, Value::Null) { Ok(()) => ToolId::parse(&format!("user-input/{}", plugin_user_input::ASK)) .inspect_err(|error| tracing::warn!(%error, "the ask tool's id does not parse")) - .ok(), + .ok() + .map(|id| AskTool { + vendor: "runvil".to_owned(), + id, + }), Err(error) => { tracing::warn!(%error, "the user-input Plugin inserted no Pack"); None @@ -213,10 +219,10 @@ struct Inner { broker: WorkshopBroker, /// The built Packs every run takes its snapshot of. host: Arc, - /// The ask tool's id, `user-input/ask`, which a script's ask result - /// is recognized by; `None` only if the user-input Plugin was - /// refused. - ask: Option, + /// The ask tool, `user-input/ask` of vendor `runvil`, which a + /// script's ask result is recognized by; `None` only if the + /// user-input Plugin was refused. + ask: Option, /// The running conversations. conversations: Conversations, } @@ -341,8 +347,9 @@ impl AgentSessions { ) } - /// The ask tool's id, which a socket frames a script's ask result by. - fn ask(&self) -> Option { + /// The ask tool's vendor and id, which a socket frames a script's ask + /// result by. + fn ask(&self) -> Option { self.inner.ask.clone() } diff --git a/crates/workshop/server/src/agents/socket.rs b/crates/workshop/server/src/agents/socket.rs index 926fd93b5..cc18cc0eb 100644 --- a/crates/workshop/server/src/agents/socket.rs +++ b/crates/workshop/server/src/agents/socket.rs @@ -28,7 +28,6 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::HeaderMap; use axum::response::Response; use harness::display_chain; -use runvil::tools::ToolId; use tokio::sync::broadcast; use tokio::sync::broadcast::error::RecvError; @@ -39,7 +38,9 @@ use workshop_support::recv_or_pending; use super::LaunchRefusal; use super::socket_frames::{delta_frame, drain_events, frame_entry, input_frame}; use super::state::SessionsState; -use crate::agents::wire::{AgentSessionFrame, AgentsFrame, RequestRefusal, SessionRequest}; +use crate::agents::wire::{ + AgentSessionFrame, AgentsFrame, AskTool, RequestRefusal, SessionRequest, +}; use crate::websocket::{cross_site_refusal, send_error, send_frame}; /// Upgrades a `GET /agents/ws` request to an agent-session socket. A @@ -57,14 +58,14 @@ pub(super) async fn upgrade( } /// The attachment state of one socket: the conversation it serves, the -/// ask tool's id, and the per-client cursors deriving durable-frame -/// indices. +/// ask tool's vendor and id, and the per-client cursors deriving +/// durable-frame indices. pub(super) struct Attached { /// The conversation this socket serves. pub(super) conversation: Conversation, - /// The ask tool's id, which a script's ask result is recognized by; - /// `None` when the launcher has no ask tool. - pub(super) ask: Option, + /// The ask tool's vendor and id, which a script's ask result is + /// recognized by; `None` when the launcher has no ask tool. + pub(super) ask: Option, /// The next transcript index to consider; everything below it has /// been read (framed or skipped) for this client already. pub(super) cursor: u64, @@ -411,7 +412,7 @@ fn refusal_text(refusal: &LaunchRefusal) -> String { /// `false` return means the client is gone. async fn attach( conversation: Conversation, - ask: Option, + ask: Option, attached: &mut Option, (events_rx, deltas_rx, input_rx, errors_rx): Subscriptions<'_>, socket: &mut WebSocket, diff --git a/crates/workshop/server/src/agents/socket_frames-tests.rs b/crates/workshop/server/src/agents/socket_frames-tests.rs index e641b88ce..9ee8a27be 100644 --- a/crates/workshop/server/src/agents/socket_frames-tests.rs +++ b/crates/workshop/server/src/agents/socket_frames-tests.rs @@ -10,7 +10,7 @@ use workshop_agents::{Delta, SessionEvent, WaitFrame}; use workshop_protocol::InputFrame; use super::{advance, delta_frame, input_frame}; -use crate::agents::wire::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame}; +use crate::agents::wire::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AskTool}; #[test] fn input_frames_map_conversation_waits_to_wire_shapes() { @@ -75,14 +75,19 @@ fn provenance() -> Provenance { /// The ask tool's fixed id, under the user-input Pack's id. const ASK_TOOL: &str = "user-input/ask"; -/// The ask tool's id, which a script's ask result is recognized by. -fn ask() -> ToolId { - ToolId::parse(ASK_TOOL).expect("the ask tool id parses") +/// The ask tool's vendor and id, which a script's ask result is +/// recognized by. +fn ask() -> AskTool { + AskTool { + vendor: "runvil".to_owned(), + id: ToolId::parse(ASK_TOOL).expect("the ask tool id parses"), + } } /// The operator's message as a script's ask result, which has a wire /// shape. fn operator_message(text: &str) -> Event { + let ask = ask(); Event::ToolResult { execution: "run".to_owned(), section: "chat".to_owned(), @@ -90,7 +95,8 @@ fn operator_message(text: &str) -> Event { turn: 0, tool_call_id: String::new(), alias: ASK_TOOL.to_owned(), - tool: Some(ask()), + tool: Some(ask.id), + vendor: Some(ask.vendor), content: text.to_owned(), trusted: true, } diff --git a/crates/workshop/server/src/agents/socket_frames.rs b/crates/workshop/server/src/agents/socket_frames.rs index caafbadab..ea2a5afd6 100644 --- a/crates/workshop/server/src/agents/socket_frames.rs +++ b/crates/workshop/server/src/agents/socket_frames.rs @@ -5,12 +5,11 @@ use axum::extract::ws::WebSocket; use runvil::event::Event; -use runvil::tools::ToolId; use workshop_agents::{Delta, DeltaKind, SessionEvent, WaitFrame}; use workshop_protocol::InputFrame; use super::socket::Attached; -use crate::agents::wire::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame}; +use crate::agents::wire::{AgentDeltaFrame, AgentDeltaKind, AgentEventFrame, AskTool}; use crate::websocket::send_frame; /// Renders a conversation's wait frame as the protocol's input frame: the @@ -74,7 +73,7 @@ pub(super) async fn frame_entry( fn advance( cursor: &mut u64, framed: &mut u64, - ask: Option<&ToolId>, + ask: Option<&AskTool>, entry: &SessionEvent, ) -> Option { if entry.index < *cursor { diff --git a/crates/workshop/server/src/agents/tests.rs b/crates/workshop/server/src/agents/tests.rs index 8e45ec9ae..98fad159e 100644 --- a/crates/workshop/server/src/agents/tests.rs +++ b/crates/workshop/server/src/agents/tests.rs @@ -1,7 +1,7 @@ //! The agent conversations' Harness setup: after the server calls the //! three Plugins and builds its Host, every run holds the `web`, -//! `user-input`, `subagents`, and `mcp` Packs, and the ask id is the -//! fixed `user-input/ask`. A prompt declaring `web` or the built-in +//! `user-input`, `subagents`, and `mcp` Packs, and the ask is the fixed +//! `user-input/ask` of vendor `runvil`. A prompt declaring `web` or the built-in //! `subagents` prepares over the server's built Packs, a Host without the //! search provider builds web as unavailable and refuses the prompt //! naming that service, and outside a runtime the services leave the @@ -30,6 +30,7 @@ use serde_json::json; use workshop_agents::{Conversations, SessionState, TokioTimer}; use workshop_registry::Registry; +use super::wire::AskTool; use super::{host_context, read_mcp_servers, services, with_plugins}; /// The runtime's key, built from the Plugin contract's name as any Host @@ -112,7 +113,7 @@ async fn every_run_holds_the_packs_the_three_plugins_add_and_the_builtin() { # Lists\n\n## Only\n\n```lua\n\ local names = {}\n\ for _, id in ipairs({ 'web', 'user-input', 'subagents', 'mcp' }) do\n\ - names[#names + 1] = packs.get(id).name\n\ + names[#names + 1] = tools.packs.get(id).name\n\ end\n\ return table.concat(names, ',')\n```\n"; let (host, _ask) = host_context(&Registry::new(), None); @@ -171,12 +172,15 @@ async fn a_host_without_the_search_provider_refuses_a_prompt_requiring_web() { } #[test] -fn the_ask_id_is_the_fixed_user_input_ask_tool() { +fn the_ask_is_the_runvil_vendors_fixed_user_input_ask_tool() { let (_host, ask) = host_context(&Registry::new(), None); assert_eq!( ask, - Some(ToolId::parse("user-input/ask").expect("the id parses")), - "the user-input Plugin added its Pack, whose Pack id is user-input" + Some(AskTool { + vendor: "runvil".to_owned(), + id: ToolId::parse("user-input/ask").expect("the id parses"), + }), + "the user-input Plugin added runvil/user-input, whose Pack id is user-input" ); } diff --git a/crates/workshop/server/src/agents/wire-tests-ask.rs b/crates/workshop/server/src/agents/wire-tests-ask.rs new file mode 100644 index 000000000..0e4804701 --- /dev/null +++ b/crates/workshop/server/src/agents/wire-tests-ask.rs @@ -0,0 +1,78 @@ +//! How a script's ask result frames: as the operator's message only when +//! both its vendor and its tool id are the ask's, whatever its alias, and +//! as a tool call update for anything else and for a model's ask. + +use super::*; + +/// The wire kind `event` frames as under the ask tool `ask`. +fn kind_under(event: &Event, ask: Option<&AskTool>) -> serde_json::Value { + wire(&AgentEventFrame::new(0, None, event, ask).expect("a tool result frames"))["event"]["kind"] + .clone() +} + +#[test] +fn a_script_side_ask_result_frames_as_the_operators_message() { + let ask = ask(); + let result = tool_result("", "ask", Some(ask_id()), RUNVIL, "two words"); + let frame = wire( + &AgentEventFrame::new(7, None, &result, Some(&ask)).expect("a script-side ask frames"), + ); + assert_eq!( + frame["event"], + serde_json::json!({ + "kind": "user_message", "section": "chat", "turn": 0, "content": "two words", + }), + "a script's ask result is the operator's message, framed on turn 0 without a tool-call id" + ); +} + +#[test] +fn a_scripts_ask_is_recognized_by_its_vendor_and_tool_whatever_its_alias() { + let ask = ask(); + let renamed = tool_result("", "question", Some(ask_id()), RUNVIL, "hi"); + assert_eq!(kind_under(&renamed, Some(&ask)), "user_message"); + let unbound = tool_result("", ASK_TOOL, None, RUNVIL, "hi"); + let acme = tool_result("", "ask", Some(ask_id()), Some("acme"), "hi"); + let vendorless = tool_result("", "ask", Some(ask_id()), None, "hi"); + let elsewhere = AskTool { + id: ToolId::parse("asker/ask").expect("a valid tool id"), + ..ask.clone() + }; + let fetch = ToolId::parse("web/fetch").expect("a valid tool id"); + let other = tool_result("", "fetch", Some(fetch), RUNVIL, "the page"); + for (event, ask, why) in [ + (&unbound, Some(&ask), "a result with no tool is not the ask"), + ( + &acme, + Some(&ask), + "the ask id of another vendor's Pack is not the ask", + ), + ( + &vendorless, + Some(&ask), + "the ask id with no vendor is not the ask", + ), + (&renamed, Some(&elsewhere), "another tool id is not the ask"), + (&renamed, None, "a Host without the ask frames none"), + (&other, Some(&ask), "only the ask is the operator's message"), + ] { + assert_eq!(kind_under(event, ask), "tool_call_update", "{why}"); + } +} + +#[test] +fn a_model_issued_ask_result_stays_a_tool_call_update() { + let ask = ask(); + let result = tool_result("call_9", "ask", Some(ask_id()), RUNVIL, "two words"); + let frame = wire( + &AgentEventFrame::new(8, None, &result, Some(&ask)).expect("a model-issued ask frames"), + ); + assert_eq!( + frame["event"], + serde_json::json!({ + "kind": "tool_call_update", "section": "chat", "turn": 2, + "content": "two words", "tool_call_id": "call_9", + }), + "a model's ask answers the model's own tool call, so it stays a tool call update" + ); +} diff --git a/crates/workshop/server/src/agents/wire-tests.rs b/crates/workshop/server/src/agents/wire-tests.rs index 549829b3c..0d54db4f6 100644 --- a/crates/workshop/server/src/agents/wire-tests.rs +++ b/crates/workshop/server/src/agents/wire-tests.rs @@ -2,7 +2,8 @@ //! against its exact JSON, the inbound requests against their variants //! and refusals, and both directions against the shared fixture the SPA //! suite (`crates/workshop/ui/test/agent-wire-fixtures.mjs`) asserts, so a -//! wire drift on either side fails that side's fixture test. +//! wire drift on either side fails that side's fixture test. How a +//! script's ask result frames sits in the `ask` child module. use std::path::Path; @@ -14,6 +15,9 @@ use workshop_protocol::{InputFrame, InputResponse}; use super::*; +#[path = "wire-tests-ask.rs"] +mod ask; + /// Parses the shared fixture into one object keyed by case name. fn agent_fixture() -> serde_json::Value { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/agent-frames.json"); @@ -31,16 +35,27 @@ fn provenance() -> Provenance { /// The ask tool's fixed id, under the user-input Pack's id. const ASK_TOOL: &str = "user-input/ask"; -/// The ask tool's id, which Workshop recognizes a script's ask by. -fn ask() -> ToolId { +/// The vendor of the Pack whose ask Workshop recognizes. +const RUNVIL: Option<&str> = Some("runvil"); + +fn ask_id() -> ToolId { ToolId::parse(ASK_TOOL).expect("the ask tool id parses") } +/// The ask tool's vendor and id, which Workshop recognizes a script's +/// ask by. +fn ask() -> AskTool { + AskTool { + vendor: "runvil".to_owned(), + id: ask_id(), + } +} + /// The operator's message in the `chat` section, as a script's ask /// result: the fixture's `agent_event_minimal` entry as the Engine event /// it projects from. fn operator_message(text: &str) -> Event { - tool_result("", "ask", Some(ask()), text) + tool_result("", "ask", Some(ask_id()), RUNVIL, text) } /// The fixture's `agent_event_stamped` entry as the Engine event it @@ -208,6 +223,7 @@ fn an_agent_event_frame_keeps_the_model_on_thinking_and_the_call_id_on_tool_resu tool_call_id: "call_7".to_owned(), alias: "read_file".to_owned(), tool: None, + vendor: None, content: "the file's text".to_owned(), trusted: false, }; @@ -225,8 +241,14 @@ fn an_agent_event_frame_keeps_the_model_on_thinking_and_the_call_id_on_tool_resu /// A trusted tool result in the `chat` section on turn 2, answering /// `tool_call_id` (empty for a script's call) under `alias`, bound to -/// `tool`. -fn tool_result(tool_call_id: &str, alias: &str, tool: Option, content: &str) -> Event { +/// `tool` of a Pack from `vendor`. +fn tool_result( + tool_call_id: &str, + alias: &str, + tool: Option, + vendor: Option<&str>, + content: &str, +) -> Event { Event::ToolResult { execution: "run".to_owned(), section: "chat".to_owned(), @@ -235,69 +257,12 @@ fn tool_result(tool_call_id: &str, alias: &str, tool: Option, content: & tool_call_id: tool_call_id.to_owned(), alias: alias.to_owned(), tool, + vendor: vendor.map(str::to_owned), content: content.to_owned(), trusted: true, } } -/// The wire kind `event` frames as under the ask tool id `ask`. -fn kind_under(event: &Event, ask: Option<&ToolId>) -> serde_json::Value { - wire(&AgentEventFrame::new(0, None, event, ask).expect("a tool result frames"))["event"]["kind"] - .clone() -} - -#[test] -fn a_script_side_ask_result_frames_as_the_operators_message() { - let ask = ask(); - let result = tool_result("", "ask", Some(ask.clone()), "two words"); - let frame = wire( - &AgentEventFrame::new(7, None, &result, Some(&ask)).expect("a script-side ask frames"), - ); - assert_eq!( - frame["event"], - serde_json::json!({ - "kind": "user_message", "section": "chat", "turn": 0, "content": "two words", - }), - "a script's ask result is the operator's message, framed on turn 0 without a tool-call id" - ); -} - -#[test] -fn a_scripts_ask_is_recognized_by_its_tool_whatever_its_alias() { - let ask = ask(); - let renamed = tool_result("", "question", Some(ask.clone()), "hi"); - assert_eq!(kind_under(&renamed, Some(&ask)), "user_message"); - let unbound = tool_result("", ASK_TOOL, None, "hi"); - let elsewhere = ToolId::parse("asker/ask").expect("a valid tool id"); - let fetch = ToolId::parse("web/fetch").expect("a valid tool id"); - let other = tool_result("", "fetch", Some(fetch), "the page"); - for (event, ask, why) in [ - (&unbound, Some(&ask), "a result with no tool is not the ask"), - (&renamed, Some(&elsewhere), "another tool id is not the ask"), - (&renamed, None, "a Host without the ask frames none"), - (&other, Some(&ask), "only the ask is the operator's message"), - ] { - assert_eq!(kind_under(event, ask), "tool_call_update", "{why}"); - } -} - -#[test] -fn a_model_issued_ask_result_stays_a_tool_call_update() { - let ask = ask(); - let result = tool_result("call_9", "ask", Some(ask.clone()), "two words"); - let frame = wire( - &AgentEventFrame::new(8, None, &result, Some(&ask)).expect("a model-issued ask frames"), - ); - assert_eq!( - frame["event"], - serde_json::json!({ - "kind": "tool_call_update", "section": "chat", "turn": 2, - "content": "two words", "tool_call_id": "call_9", - }), - "a model's ask answers the model's own tool call, so it stays a tool call update" - ); -} - #[test] fn an_agent_delta_frame_is_stamped_with_its_superseding_reply_id() { assert_eq!( diff --git a/crates/workshop/server/src/agents/wire.rs b/crates/workshop/server/src/agents/wire.rs index 374ca2349..799a609a4 100644 --- a/crates/workshop/server/src/agents/wire.rs +++ b/crates/workshop/server/src/agents/wire.rs @@ -217,14 +217,24 @@ struct AgentEvent { metrics: Option, } +/// The ask tool a script's result is recognized by: the vendor of the +/// Pack that answers it and its id. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct AskTool { + /// The vendor of the ask tool's Pack, `runvil`. + pub(super) vendor: String, + /// The ask tool's id, `user-input/ask`. + pub(super) id: ToolId, +} + impl AgentEvent { /// Projects one Engine event onto the wire shape, or `None` for a /// variant the transcript does not render (lifecycle, task, and debug - /// events). A script's call to the ask tool, whose id is `ask`, frames - /// as the operator's `user_message`, because its result is the text - /// the operator typed. + /// events). A script's call to the ask tool `ask`, matched by both its + /// vendor and its id, frames as the operator's `user_message`, because + /// its result is the text the operator typed. #[must_use] - fn from_event(event: &Event, ask: Option<&ToolId>) -> Option { + fn from_event(event: &Event, ask: Option<&AskTool>) -> Option { let base = |kind: AgentEventKind, turn: u32, content: String| AgentEvent { kind, section: event.section().to_owned(), @@ -270,9 +280,12 @@ impl AgentEvent { Event::ToolResult { tool_call_id, tool: Some(tool), + vendor: Some(vendor), content, .. - } if tool_call_id.is_empty() && ask == Some(tool) => { + } if tool_call_id.is_empty() + && ask.is_some_and(|ask| ask.id == *tool && ask.vendor == *vendor) => + { base(AgentEventKind::UserInput, 0, content.clone()) } Event::ToolResult { @@ -323,13 +336,13 @@ pub(super) struct AgentEventFrame { impl AgentEventFrame { /// Builds the frame for the entry at `index`, or `None` when `event` /// is a variant the transcript does not render. `ask` is the ask - /// tool's id, which a script's ask result is recognized by. + /// tool's vendor and id, which a script's ask result is recognized by. #[must_use] pub(super) fn new( index: u64, reply: Option, event: &Event, - ask: Option<&ToolId>, + ask: Option<&AskTool>, ) -> Option { Some(Self { kind: "agent_event", diff --git a/crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts b/crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts index 5948576c9..e6a8cf481 100644 --- a/crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts +++ b/crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts @@ -46,22 +46,27 @@ export interface TurnState { } /** - * The family of a bound tool id (the wire's `namespace/name` string). - * The ask tool is recognized by its name, `ask`, under any namespace; - * the web tools are fixed ids. - * A null tool is generic. + * The family of a vendor-qualified tool name (`vendor/tool`, such as + * `runvil/web/search`). Only the fixed names of runvil's ask, search, + * and fetch tools are special, so the same tool id from another vendor's + * Pack is generic. A null name, a call without a vendor, is generic. */ -export function toolKind(tool: string | null): ToolKind { - if (tool === "web/search") { - return "search"; +export function toolKind(qualified: string | null): ToolKind { + switch (qualified) { + case "runvil/web/search": + return "search"; + case "runvil/web/fetch": + return "fetch"; + case "runvil/user-input/ask": + return "ask"; + default: + return "other"; } - if (tool === "web/fetch") { - return "fetch"; - } - if (tool !== null && tool.slice(tool.lastIndexOf("/") + 1) === "ask") { - return "ask"; - } - return "other"; +} + +/** A call's vendor-qualified tool name, or null when the call names no vendor or no tool. */ +export function qualifiedTool(call: ToolCallRow): string | null { + return call.vendor === null || call.tool === null ? null : `${call.vendor}/${call.tool}`; } /** @@ -100,13 +105,13 @@ function stringArg(args: string, key: string): string { } /** - * The verb and details of one tool call. `web/search` and `web/fetch` - * read their query and url arguments; the ask tool has no details; any - * other tool reads ` in `, with the call - * name flagged for the verb's color. + * The verb and details of one tool call. runvil's `web/search` and + * `web/fetch` read their query and url arguments; runvil's ask tool has + * no details; any other tool reads ` in `, + * with the call name flagged for the verb's color. */ export function toolLabel(call: ToolCallRow, loading: boolean): ToolLabel { - switch (toolKind(call.tool)) { + switch (toolKind(qualifiedTool(call))) { case "search": return { action: loading ? "Searching web" : "Searched web", diff --git a/crates/workshop/ui/src/parts/agent/transcript/transcript-model.ts b/crates/workshop/ui/src/parts/agent/transcript/transcript-model.ts index 1391c3b5f..8e7f13d9e 100644 --- a/crates/workshop/ui/src/parts/agent/transcript/transcript-model.ts +++ b/crates/workshop/ui/src/parts/agent/transcript/transcript-model.ts @@ -24,6 +24,7 @@ import type { ReasoningItem, ToolCallRow, TranscriptItem } from "../../../servic import { groupSummary, isToolLoading, + qualifiedTool, toolKind, toolLabel, type GroupSummary, @@ -331,7 +332,7 @@ function draftTurn(turn: number, entries: readonly Entry[]): RowDraft[] { const draft: ToolDraft = { key: claim(call.id === "" ? `c:${turn}:${slot}` : `c:${turn}:${call.id}`, slot), call, - family: toolKind(call.tool), + family: toolKind(qualifiedTool(call)), result: null, }; if (call.id !== "") { diff --git a/crates/workshop/ui/src/services/agent-session.ts b/crates/workshop/ui/src/services/agent-session.ts index 873661b41..c1bf51a35 100644 --- a/crates/workshop/ui/src/services/agent-session.ts +++ b/crates/workshop/ui/src/services/agent-session.ts @@ -46,19 +46,26 @@ export interface AgentSessionWire { cancelTurn(): boolean; } -/** One call of a tool-call batch: its id, name, rendered arguments, and bound tool. */ +/** One call of a tool-call batch: its id, name, rendered arguments, bound tool, and vendor. */ export interface ToolCallRow { readonly id: string; readonly name: string; /** The call arguments as compact JSON, or "" when the call had none. */ readonly args: string; /** - * The bound tool id the call resolved to, as the wire's one - * slash-separated string (`web/search`), or null for a Lua-local tool - * or an entry the wire did not stamp. Views render a null tool as a + * The tool id the call resolved to, as the wire's one slash-separated + * string (`web/search`, or `adhoc/grab` for an adhoc tool), or null + * for an entry the wire did not stamp. Views render a null tool as a * generic tool. */ readonly tool: string | null; + /** + * The vendor of the Pack that answers `tool` (`runvil`), or null for an + * adhoc tool or an entry the wire did not stamp. `vendor/tool` is the + * vendor-qualified name (`runvil/web/search`), and a call without a + * vendor is never special. + */ + readonly vendor: string | null; } /** Text the operator sent, as the durable `user_message` event recorded it. */ @@ -171,9 +178,10 @@ function parseToolCalls(content: string): readonly ToolCallRow[] { id: typeof record.id === "string" ? record.id : "", name: typeof record.name === "string" ? record.name : "", args: "arguments" in record ? JSON.stringify(record.arguments) : "", - // A tool id is never empty, so an empty string is as malformed as a - // number: both read as the generic tool. + // A tool id or vendor is never empty, so an empty string is as + // malformed as a number: both read as absent. tool: typeof record.tool === "string" && record.tool !== "" ? record.tool : null, + vendor: typeof record.vendor === "string" && record.vendor !== "" ? record.vendor : null, }); } return rows; diff --git a/crates/workshop/ui/test/agent-session-service.mjs b/crates/workshop/ui/test/agent-session-service.mjs index a41604beb..5b048191d 100644 --- a/crates/workshop/ui/test/agent-session-service.mjs +++ b/crates/workshop/ui/test/agent-session-service.mjs @@ -150,7 +150,7 @@ await assertNoLeaks(lifecycle, () => { check( "the tool-call batch parses into one row per call", isDeepStrictEqual(service.items[3].calls, [ - { id: "call_1", name: "read", args: '{"path":"a"}', tool: null }, + { id: "call_1", name: "read", args: '{"path":"a"}', tool: null, vendor: null }, ]), ); check( @@ -555,7 +555,7 @@ await assertNoLeaks(lifecycle, () => { const service = new AgentSessionService(wire); wire.fire.session("s1"); wire.fire.event("user_message", "first turn"); - wire.fire.event("tool_call", '[{"id":"c1","name":"ask","arguments":{},"tool":"user-input/ask"}]', { + wire.fire.event("tool_call", '[{"id":"c1","name":"ask","arguments":{},"tool":"user-input/ask","vendor":"runvil"}]', { reply: 0, }); wire.fire.inputRequired("tok1"); @@ -604,8 +604,8 @@ await assertNoLeaks(lifecycle, () => { wire.fire.event( "tool_call", JSON.stringify([ - { id: "c1", name: "ask", arguments: {}, tool: "user-input/ask" }, - { id: "c2", name: "fetch", arguments: {}, tool: "web/fetch" }, + { id: "c1", name: "ask", arguments: {}, tool: "user-input/ask", vendor: "runvil" }, + { id: "c2", name: "fetch", arguments: {}, tool: "web/fetch", vendor: "runvil" }, ]), { reply: 0 }, ); @@ -640,7 +640,7 @@ await assertNoLeaks(lifecycle, () => { service.dispose(); } - // --- ToolCallRow.tool: the batch entry's tool string, or null --------------- + // --- ToolCallRow.tool and vendor: the batch entry's strings, or null -------- { const wire = makeWire(); @@ -648,19 +648,28 @@ await assertNoLeaks(lifecycle, () => { wire.fire.event( "tool_call", JSON.stringify([ - { id: "a", name: "search", arguments: { query: "x" }, tool: "web/search" }, - { id: "b", name: "local", arguments: {} }, - { id: "c", name: "odd", arguments: {}, tool: 5 }, - { id: "d", name: "empty", arguments: {}, tool: "" }, - { id: "e", name: "nul", arguments: {}, tool: null }, + { id: "a", name: "search", arguments: { query: "x" }, tool: "web/search", vendor: "runvil" }, + { id: "b", name: "grab", arguments: {}, tool: "adhoc/grab" }, + { id: "c", name: "odd", arguments: {}, tool: 5, vendor: 5 }, + { id: "d", name: "empty", arguments: {}, tool: "", vendor: "" }, + { id: "e", name: "nul", arguments: {}, tool: null, vendor: null }, + { id: "f", name: "bare", arguments: {}, tool: "runvil/user-input/ask" }, + { id: "g", name: "unstamped", arguments: {} }, ]), { reply: 0 }, ); check( - "a batch entry's tool string parses onto its row", + "a batch entry's tool string parses onto its row, an adhoc id included", isDeepStrictEqual( service.items[0].calls.map((row) => row.tool), - ["web/search", null, null, null, null], + ["web/search", "adhoc/grab", null, null, null, "runvil/user-input/ask", null], + ), + ); + check( + "a batch entry's vendor string parses onto its row", + isDeepStrictEqual( + service.items[0].calls.map((row) => row.vendor), + ["runvil", null, null, null, null, null, null], ), ); wire.fire.event("tool_call", "[1, null, [], \"x\"]", { reply: 1 }); diff --git a/crates/workshop/ui/test/agent-session-view.mjs b/crates/workshop/ui/test/agent-session-view.mjs index 315b511e9..385a3193d 100644 --- a/crates/workshop/ui/test/agent-session-view.mjs +++ b/crates/workshop/ui/test/agent-session-view.mjs @@ -1351,8 +1351,8 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.event( "tool_call", JSON.stringify([ - { id: "c1", name: "ask", arguments: {}, tool: "user-input/ask" }, - { id: "c2", name: "fetch", arguments: {}, tool: "web/fetch" }, + { id: "c1", name: "ask", arguments: {}, tool: "user-input/ask", vendor: "runvil" }, + { id: "c2", name: "fetch", arguments: {}, tool: "web/fetch", vendor: "runvil" }, ]), { reply: 0 }, ); diff --git a/crates/workshop/ui/test/tool-line.mjs b/crates/workshop/ui/test/tool-line.mjs index 94d6e2f2c..55297bbf3 100644 --- a/crates/workshop/ui/test/tool-line.mjs +++ b/crates/workshop/ui/test/tool-line.mjs @@ -57,14 +57,14 @@ function check(name, condition) { if (!condition) failures.push(name); } -/** A tool step the way the model builds one. */ -function step({ name, tool, args = {}, result = null, loading = false }) { - const call = { id: "c1", name, tool, args: args === "" ? "" : JSON.stringify(args) }; +/** A tool step the way the model builds one, for a tool of a Pack from `vendor`. */ +function step({ name, tool, vendor = "runvil", args = {}, result = null, loading = false }) { + const call = { id: "c1", name, tool, vendor, args: args === "" ? "" : JSON.stringify(args) }; return { step: "tool", key: "c:0:c1", call, - toolKind: toolKind(tool), + toolKind: toolKind(`${vendor}/${tool}`), result, loading, label: toolLabel(call, loading), diff --git a/crates/workshop/ui/test/transcript-model.mjs b/crates/workshop/ui/test/transcript-model.mjs index d2016c61d..0298d2d98 100644 --- a/crates/workshop/ui/test/transcript-model.mjs +++ b/crates/workshop/ui/test/transcript-model.mjs @@ -60,7 +60,14 @@ const thought = (text, n, { pending = false, startedAt = 0, endedAt = null } = { }); // A thought that ran `ms` milliseconds and settled. const thoughtFor = (text, n, ms) => thought(text, n, { startedAt: 1000, endedAt: 1000 + ms }); -const call = (id, name, tool, args = {}) => ({ id, name, tool, args: JSON.stringify(args) }); +// A call to `tool` of a Pack from `vendor`; a bound tool's vendor is runvil unless named. +const call = (id, name, tool, args = {}, vendor = tool === null ? null : "runvil") => ({ + id, + name, + tool, + vendor, + args: JSON.stringify(args), +}); const batch = (...calls) => ({ kind: "tool-call", calls, text: "", model: null }); const result = (id, text = "ok") => ({ kind: "tool-result", toolCallId: id, text }); const failure = (message) => ({ kind: "error", message, title: null }); @@ -274,6 +281,28 @@ const keysOf = (model, turn = 0) => rowsOf(model, turn).map((row) => row.key); "thought", "tool", ]); + + const foreign = buildTranscript( + [ + user("q"), + batch( + call("c1", "ask", "user-input/ask", {}, "acme"), + call("c2", "ask", "runvil/user-input/ask", {}, null), + call("c3", "ask", "adhoc/ask", {}, null), + ), + ], + false, + false, + ); + same("an ask from another vendor, without a vendor, or adhoc groups as ordinary tools", kindsOf(foreign), [ + "human", + "group", + ]); + same( + "each such ask is an ordinary tool step", + rowsOf(foreign)[1].steps.map((step) => step.toolKind), + ["other", "other", "other"], + ); } // --- Keys: stable across pending and durable, unique within a turn ---------------- @@ -394,13 +423,35 @@ const keysOf = (model, turn = 0) => rowsOf(model, turn).map((row) => row.key); // --- Tool verbs and details ------------------------------------------------------------ { - same("tool kinds", [toolKind("web/search"), toolKind("web/fetch"), toolKind("user-input/ask"), toolKind("fs/read"), toolKind(null)], [ - "search", - "fetch", - "ask", - "other", - "other", - ]); + same( + "tool kinds of vendor-qualified names", + [ + "runvil/web/search", + "runvil/web/fetch", + "runvil/user-input/ask", + "runvil/fs/read", + "acme/user-input/ask", + "acme/web/search", + "adhoc/ask", + null, + ].map(toolKind), + ["search", "fetch", "ask", "other", "other", "other", "other", "other"], + ); + same( + "a call is special only under the runvil vendor", + [ + call("a", "ask", "user-input/ask", {}, "acme"), + call("a", "search", "web/search", { query: "q" }, "acme"), + call("a", "ask", "runvil/user-input/ask", {}, null), + call("a", "ask", "adhoc/ask", {}, null), + ].map((row) => toolLabel(row, false)), + [ + { action: "Ran", details: "ask in user-input", callName: "ask" }, + { action: "Ran", details: "search in web", callName: "search" }, + { action: "Ran", details: "ask in runvil", callName: "ask" }, + { action: "Ran", details: "ask in adhoc", callName: "ask" }, + ], + ); same("web/search running", toolLabel(search("a", "rust async"), true), { action: "Searching web", details: "rust async", @@ -427,18 +478,23 @@ const keysOf = (model, turn = 0) => rowsOf(model, turn).map((row) => row.key); callName: "read", }); same("a generic tool running", toolLabel(generic("a", "read", "fs/read"), true).action, "Running"); - same("a call with no tool id shows its name alone", toolLabel(call("a", "lua_helper", null), false), { + same("an adhoc call names the call and the adhoc Pack", toolLabel(call("a", "grab", "adhoc/grab", {}, null), false), { + action: "Ran", + details: "grab in adhoc", + callName: "grab", + }); + same("a call the wire did not stamp shows its name alone", toolLabel(call("a", "unstamped", null), false), { action: "Ran", - details: "lua_helper", - callName: "lua_helper", + details: "unstamped", + callName: "unstamped", }); same( "malformed or missing arguments give empty details", [ - toolLabel({ id: "a", name: "search", tool: "web/search", args: "not json" }, false).details, - toolLabel({ id: "a", name: "search", tool: "web/search", args: "" }, false).details, - toolLabel({ id: "a", name: "search", tool: "web/search", args: '{"query": 5}' }, false).details, - toolLabel({ id: "a", name: "fetch", tool: "web/fetch", args: "[]" }, false).details, + toolLabel({ id: "a", name: "search", tool: "web/search", vendor: "runvil", args: "not json" }, false).details, + toolLabel({ id: "a", name: "search", tool: "web/search", vendor: "runvil", args: "" }, false).details, + toolLabel({ id: "a", name: "search", tool: "web/search", vendor: "runvil", args: '{"query": 5}' }, false).details, + toolLabel({ id: "a", name: "fetch", tool: "web/fetch", vendor: "runvil", args: "[]" }, false).details, ], ["", "", "", ""], ); diff --git a/vibe/2026-10-20-1-library-plugin-loading.md b/vibe/2026-10-20-1-library-plugin-loading.md index c632472b9..e5753f9f7 100644 --- a/vibe/2026-10-20-1-library-plugin-loading.md +++ b/vibe/2026-10-20-1-library-plugin-loading.md @@ -981,9 +981,7 @@ The loader's unit tests drive the whole contract with fake entry points inserted - make `packs.offer('subagents')` show the four task tools, rendered for the section's model - `uses: subagents` means "required" - Revisit as its own plan. - - **Offering only Packs.** - - Remove `tools.offer` by id or tool object, and the lists that feed it: `tools.required()` and `tools.extras()`. - - Keep `tools.call`, `tools.offer_local`, and the `tools.calls` counters. + - **Filtered Pack offers.** - Add filtered views of a Pack's tools by hints such as `readOnlyHint`, which needs annotations on `ToolInfo`. - Revisit as its own plan. - **A config per Plugin,** passed in `RV_PLUGIN_LOAD`'s arguments. Revisit with `insert_pack`. diff --git a/vibe/2026-10-21-1-mcp-script-raw-text.md b/vibe/2026-10-21-1-mcp-script-raw-text.md new file mode 100644 index 000000000..6a0571aee --- /dev/null +++ b/vibe/2026-10-21-1-mcp-script-raw-text.md @@ -0,0 +1,162 @@ +--- +name: MCP script calls return raw text +overview: "plugin-mcp marks its output by caller: a call the section's Lua makes through tools.call gets the raw text back, and a call a model round makes keeps the untrusted envelope. Both of the Pack's tools follow the rule. runvil-plugin's TestCall gains a way to name the caller so the Pack's tests can exercise both paths." +todos: + - id: s1-test-call-caller + content: "S1: add TestCall::with_caller to runvil-plugin's test support, with a test that a call built with ToolCaller::Model lends a model origin" + status: completed + - id: s2-mcp-trust-by-caller + content: "S2: plugin-mcp returns trusted output to ToolCaller::Script and untrusted output to every other caller, for both get_dynamic_tools and call_dynamic_tool; update the crate docs and tests" + status: completed +isProject: false +--- + +# plugin-mcp: No Envelope on Lua Calls + + + +## Product Requirements + +- Ticket: GH-164, "plugin-mcp: no envelope on Lua calls". The ticket's whole text: "Script-issued MCP calls return the raw text string, and the model path keeps its envelope." +- Repository: `runvil` (`/code/runvil`), branch `GH-164-plugin-mcp-remove-envelope`, at `31941a46`. +- Problem: `plugin-mcp` returns every output as `ToolOutput::untrusted`. The shared tool dispatch (`crates/runvil-internal/lua/src/dispatch.rs`, the `match output.trust()` after the call) nonce-wraps untrusted output before it reaches either the next model turn or the calling script. A section's Lua that calls `mcp/call_dynamic_tool` or `mcp/get_dynamic_tools` through `tools.call` therefore gets the envelope, not the server's text. The crate docs already promise that a script reads the listing with `json.decode`, which the envelope breaks. +- Goals + - A call whose origin's caller is `ToolCaller::Script` gets the raw text: the exact string the call renders today before wrapping, with no envelope. + - A call whose origin's caller is `ToolCaller::Model`, or any caller a future version adds, keeps the untrusted envelope exactly as today. + - The rule covers both of the Pack's tools, `get_dynamic_tools` and `call_dynamic_tool`. +- Non-goals: no change to the Engine, the Harness, the dispatch's wrapping, the envelope format, other Packs, error mapping, or the text a call renders. No change to `ToolCallOrigin` or `ToolCaller`. +- Success criteria + - Through the Pack's own tests: a script-origin call of either tool returns `OutputTrust::Trusted` with the same text as before; a model-origin call returns `OutputTrust::Untrusted` with the same text. + - Errors, including an `isError` result, stay `ToolError`s for both callers. + - Every touched test, lint, format, and docs gate passes. + +## Functional Specification + +- `mcp/call_dynamic_tool` called by a script: returns the joined text of the result (text blocks, embedded text resources, and placeholders joined with blank lines, or the `structuredContent` JSON), marked trusted, so the Lua caller receives that string verbatim. +- `mcp/get_dynamic_tools` called by a script: returns the listing JSON text, marked trusted, so `json.decode` on the returned string succeeds. +- Either tool called by a model round: returns the same text marked untrusted, so the dispatch nonce-wraps it as today. +- A caller that is neither `Script` nor `Model` (the enum is `#[non_exhaustive]`) is treated as a model call: untrusted. +- Failures are unchanged for both callers: argument refusals, unknown namespaces or tools, servers not ready, transport errors, and `isError` results are `ToolError`s with the same kinds and messages. + + + + +## Technical Design + +- `runvil-plugin` (test support only, behind the `test-support` feature) + - `crates/runvil-plugin/src/testing.rs`: add `TestCall::with_caller(self, caller: ToolCaller) -> TestCall`, a builder like `with_services`, which replaces the origin's caller and keeps its execution (`plugin-test`) and section (`Test`). `TestCall::new` keeps the `ToolCaller::Script` default. Update the struct doc, which says the origin names "the script of the section `Test`", to say the caller is the script unless `with_caller` names another. + - `ToolCallOrigin`'s fields are public and `#[non_exhaustive]` blocks struct literals outside its crate, so rebuild the origin with `ToolCallOrigin::new("plugin-test", "Test", caller)`. +- `plugin-mcp` + - Trust is decided once, in `Mcp::call` (`crates/plugin-mcp/src/dynamic.rs`), from `cx.origin().caller`. Add a private free function in `dynamic.rs`, `fn marked(caller: ToolCaller, text: String) -> ToolOutput`, that returns `ToolOutput::trusted(text)` for `ToolCaller::Script` and `ToolOutput::untrusted(text)` for every other caller (a `_` arm, because `ToolCaller` is `#[non_exhaustive]`). + - The text path below `Mcp::call` returns plain text instead of `ToolOutput`: `result::render` and `result::output` return `Result`; `server::send`, `server::call`, and `AbortOnDrop`'s join handle carry `Result`; `Mcp::call_tool` returns `Result`. `Mcp::list` already returns text. `Mcp::call` maps both tools' `Ok` text through `marked`. Error construction is untouched. Doc comments that say "tool output" for these functions say "text" instead. + - `crates/plugin-mcp/src/lib.rs` crate docs: replace the invariant "Every output is untrusted, because descriptions, schemas, and results all come from the servers." with one stating that output to a model round is untrusted, because descriptions, schemas, and results all come from the servers, and output to the section's own script is trusted, so the script receives the server's text without the envelope. Add one sentence to the description of the two tools saying the same thing for a reader of the tool list. +- Boundaries: `plugin-mcp` keeps depending only on `runvil-plugin` among workspace crates (`ToolCaller` is re-exported there). No public item of `plugin-mcp` changes; only `install` is public. + + + + +## Testing Plan + +- Step 1 (`runvil-plugin`): in `crates/runvil-plugin/tests/it/test_call.rs`, add a test that `TestCall::new(tool).with_caller(ToolCaller::Model)` lends a context whose `origin().caller` is `ToolCaller::Model`, whose `origin().section` is `Test`, and whose `tool()` is the given tool. It fails to compile before `with_caller` exists. The existing test that `TestCall::new` lends a script origin stays and pins the default. +- Step 2 (`plugin-mcp`) + - `crates/plugin-mcp/tests/it/support.rs`: `invoke` builds its `TestCall` with `.with_caller(ToolCaller::Model)` and keeps asserting `OutputTrust::Untrusted`, with the assertion message changed to say model-round output is untrusted. Every existing test that goes through `invoke`, `list`, or `call` thereby keeps pinning the model path. Add a helper that calls a tool as the script (`TestCall::new`, whose default caller is `Script`) and returns the whole `ToolOutput`. + - New integration tests (in `tests/it/calls.rs` for the call tool, in `tests/it/listing.rs` for the listing), names stating the behavior: + - A script call of `call_dynamic_tool` to the fixture's `Echo` tool with `{ "q": "hello" }` returns `OutputTrust::Trusted` and the text `echo: {"q":"hello"}` exactly. Before the change it is `Untrusted`, so the test fails. + - A script call of `get_dynamic_tools` with no arguments returns `OutputTrust::Trusted`, and its text parses as JSON equal to the model-path listing for the same Pack. + - A script call of `call_dynamic_tool` to the fixture's `fail` tool is a `ToolErrorKind::Backend` error with the message `it failed`, the same as the model path. + - `crates/plugin-mcp/src/result-tests.rs`: `render` and `output` now return text, so drop the trust assertions there (trust is no longer `result.rs`'s concern) and assert on the returned strings. Keep every text-rendering case. + - The focused run must show the new script-path tests failing before the source change and passing after. +- Exit checks at the final step, full scope, from the project survey: format check, both clippy runs, the full test suites, the docs builds, `cargo test -p build-xtask`, and `node --test crates/workshop/ui/test/docs-claims.mjs` (the crate docs change touches an `## Invariants` section). + + + + +## Decision Record + +- User-resolved choices + - Trust boundary: output to the section's own script is trusted, output to a model round stays untrusted. This is the ticket's requirement, settled by the operator. Consequence: a script that forwards MCP text into a prompt does so without the envelope, which is the script author's decision, just as with any other value the script builds. + - Scope: both tools, `get_dynamic_tools` and `call_dynamic_tool`, follow the rule (the operator: "unwrap both"). Reason: a script that lists tools wants to decode the listing JSON, which the crate docs already promise and the envelope breaks. +- Reversible decisions and consequences + - Decide trust in the Pack from `ToolContext::origin`, not in the Engine's dispatch. The Plugin contract already hands each call its origin "to apply different policy to the same tool depending on who called it", and the dispatch honors the output's own trust marking, so no Engine change is needed. Rejected: making the dispatch skip wrapping for every script call, because that changes every Pack's contract, not this ticket's. + - An unknown future `ToolCaller` variant gets untrusted output, the safe default, matching the dispatch's own handling of an unknown `OutputTrust`. + - Trust is decided at one point, `Mcp::call`, and the layers below return plain text. Rejected: passing the caller down into `server` and `result`, because it would thread a policy value through transport code that does not need it. + - Tests reach the model path through a new `TestCall::with_caller` in `runvil-plugin`'s test support. Rejected: building a `ToolContext` by hand in `plugin-mcp`'s tests, because an `Access` needs `runvil-vfs` constructors that `runvil-plugin` does not re-export, and `plugin-mcp` may depend only on `runvil-plugin` among workspace crates. +- Assumptions and risks + - Assumption: a trusted script result is not otherwise treated as model input; the dispatch hands the content to the script and records the result's trust in the tool-result report. + - Risk: none to stored data or wire formats; the change affects only the trust marking of in-run output. + +### Deferred and Out of Scope + +- Any change to how other Packs (`plugin-web`) mark script-issued output - revisit with its own ticket if wanted. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p ` (plain `cargo build` builds only `crates/gateway/app`, the sole default member; the desktop app is `cargo build -p workshop`). +- Focused test command pattern: `cargo nextest run --locked -p -E 'test()'` (nextest config in `.config/nextest.toml`). +- Component test command pattern: `cargo nextest run --locked -p --all-features` (e.g. `-p plugin-mcp`); workshop crates run without `--all-features`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; plus `cargo test -p build-xtask` (structural checks) and `node --test crates/workshop/ui/test/docs-claims.mjs` (wording scan). +- Linter command: `CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` and `CARGO_BUILD_WARNINGS=deny cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`; extra gate `cargo check -p gateway --no-default-features`. +- Formatter check command: `cargo fmt --all --check`. +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` and `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps`; facade surface `cargo + xtask api --check` with `CARGO_BUILD_WARNINGS` unset (checks `crates/runvil/public-api.txt`). +- Test placement and naming conventions: unit tests live in a kebab sibling `foo-tests.rs` wired from `foo.rs` via `#[path = "foo-tests.rs"] mod tests;` (e.g. `crates/plugin-mcp/src/{connect,entry,result,server}-tests.rs`); integration tests are a single target per crate under `tests/it/main.rs` or `tests/suite/main.rs` with topic modules (`calls.rs`, `listing.rs`) plus `support.rs`/`fixture.rs`; prompt fixtures under `tests/prompts/`. Engine tests: no tokio, sleeps, wall clock, or OS entropy; fixed seeds; temp dirs named from test name, pid, counter. +- Directory map: `crates/` top level = public/shared layer (`runvil` Engine facade, `runvil-plugin` Plugin contract, `harness`, `harness-gateway-client`, `plugin-mcp`/`plugin-web`/`plugin-user-input` Plugins, `gateway-api-types`/`gateway-api-discovery`, `shared-*`, `build-*` tooling incl. `build-xtask`); containers of private crates: `crates/runvil-internal/` (types, engine, lua, parser, vfs, model-client), `crates/harness-internal/` (runner, plugin-loader, test-plugin), `crates/gateway/` (app, config, routing, local, stt/*, web-search, ...), `crates/workshop/` (desktop, server, server-api, ui SPA, look CSS, and support crates); `crates/shared-ui` is a TS/CSS package; `guide/` user guide books; `prompts/` sample prompts; `tools/` Node scripts; `vibe/` dated plan docs; `.config/nextest.toml`. +- Component boundaries: Engine (`runvil` → `runvil-internal/*`) depends on no tokio/reqwest/async-trait/getrandom/fastrand and never names the Host; `runvil-plugin` defines Pack/tool contracts; Plugins (e.g. `plugin-mcp` → `runvil-plugin`, `rmcp`, `reqwest`, `tokio`) depend on the contract, not the Harness; `harness` → `harness-runner`, `harness-plugin-loader`, `runvil`, `runvil-plugin`; Hosts (Workshop) → Harness; families reach each other only through top-level crates; boundaries enforced by `cargo test -p build-xtask`. +- Conventions summary: edition 2024, rust-version 1.95, workspace-inherited deps and lints with comments explaining each dependency's purpose; flat source dirs with `parent-label.rs` siblings below three files; capitalized defined terms (Engine, Harness, Host, Pack, Plugin) in docs/comments; crate docs carry `## Invariants`; model-oriented error messages naming required vs actual; recorded JSON round-trips exactly with sorted keys; behavior changes ship with tests; comments only for non-obvious constraints, citing upstream issue URLs for workarounds. + + + + +## Execution Instructions + + + +### Step 1: TestCall::with_caller in runvil-plugin test support [completed] + +- Component: none +- Tests: `cargo nextest run --locked -p runvil-plugin --all-features` + +- Scope: `runvil-plugin` test support only, behind the `test-support` feature. No change to `ToolCallOrigin` or `ToolCaller`. +- `crates/runvil-plugin/src/testing.rs`: + - Add `TestCall::with_caller(self, caller: ToolCaller) -> TestCall`, a builder shaped like `with_services`, which replaces the origin's caller and keeps its execution (`plugin-test`) and section (`Test`). Rebuild the origin with `ToolCallOrigin::new("plugin-test", "Test", caller)`, since `ToolCallOrigin` is `#[non_exhaustive]` and struct literals are blocked outside its crate. + - `TestCall::new` keeps the `ToolCaller::Script` default. + - Update the `TestCall` struct doc, which says the origin names "the script of the section `Test`", to say the caller is the script unless `with_caller` names another. +- `crates/runvil-plugin/tests/it/test_call.rs`: add a test (e.g. `a_test_call_with_a_caller_lends_that_caller`) that `TestCall::new(tool).with_caller(ToolCaller::Model)` lends a context whose `origin().caller` is `ToolCaller::Model`, whose `origin().section` is `Test`, and whose `tool()` is the given tool. It fails to compile before `with_caller` exists. Keep `a_test_call_lends_its_tool_a_script_origin_and_a_working_filesystem`, which pins the `Script` default. +- Commit: code and test together. + + + + + +### Step 2: plugin-mcp marks output by caller for both tools [completed] + +- Component: none +- Tests: `cargo nextest run --locked -p plugin-mcp --all-features`; exit checks: `cargo fmt --all --check`; `CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`; `CARGO_BUILD_WARNINGS=deny cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`; `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`; `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`; `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`; `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps`; `cargo test -p build-xtask`; `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Behavior (ticket GH-164: "Script-issued MCP calls return the raw text string, and the model path keeps its envelope."): + - `mcp/call_dynamic_tool` called with origin caller `ToolCaller::Script` returns the joined text of the result (text blocks, embedded text resources, and placeholders joined with blank lines, or the `structuredContent` JSON), marked trusted, exactly the string rendered today before wrapping. + - `mcp/get_dynamic_tools` called by `ToolCaller::Script` returns the listing JSON text, marked trusted, so `json.decode` succeeds. + - Either tool called by `ToolCaller::Model`, or any other caller (`ToolCaller` is `#[non_exhaustive]`), returns the same text marked untrusted, so the dispatch nonce-wraps it as today. + - Failures unchanged for both callers: argument refusals, unknown namespaces or tools, servers not ready, transport errors, and `isError` results stay `ToolError`s with the same kinds and messages. + - No change to the Engine, the Harness, the dispatch's wrapping, the envelope format, other Packs (`plugin-web` is out of scope), error mapping, or rendered text. `plugin-mcp` keeps depending only on `runvil-plugin` among workspace crates (`ToolCaller` is re-exported there); no public item changes (only `install` is public). +- Source, `crates/plugin-mcp/src/`: + - `dynamic.rs`: add private `fn marked(caller: ToolCaller, text: String) -> ToolOutput` returning `ToolOutput::trusted(text)` for `ToolCaller::Script` and `ToolOutput::untrusted(text)` in a `_` arm. `Mcp::call` reads `cx.origin().caller` once and maps both tools' `Ok` text through `marked`. `Mcp::call_tool` returns `Result`; `Mcp::list` already returns text. + - `result.rs`: `result::render` and `result::output` return `Result`. + - `server.rs`: `server::send`, `server::call`, and `AbortOnDrop`'s join handle carry `Result`. + - Error construction untouched. Doc comments that say "tool output" for these functions say "text" instead. + - `lib.rs` crate docs: replace the `## Invariants` line "Every output is untrusted, because descriptions, schemas, and results all come from the servers." with one stating that output to a model round is untrusted, because descriptions, schemas, and results all come from the servers, and output to the section's own script is trusted, so the script receives the server's text without the envelope. Add one sentence to the description of the two tools saying the same for a reader of the tool list. Use capitalized defined terms (Pack, Engine, Harness) per the wording scan. +- Tests: + - `crates/plugin-mcp/tests/it/support.rs`: `invoke` builds its `TestCall` with `.with_caller(ToolCaller::Model)` and keeps asserting `OutputTrust::Untrusted`, with the message changed to say model-round output is untrusted; existing tests through `invoke`, `list`, and `call` thereby keep pinning the model path. Add a helper that calls a tool as the script (`TestCall::new`, default caller `Script`) and returns the whole `ToolOutput`. + - `tests/it/calls.rs`: a script call of `call_dynamic_tool` to the fixture's `Echo` tool with `{ "q": "hello" }` returns `OutputTrust::Trusted` and the text `echo: {"q":"hello"}` exactly. A script call of `call_dynamic_tool` to the fixture's `fail` tool is a `ToolErrorKind::Backend` error with message `it failed`, same as the model path. + - `tests/it/listing.rs`: a script call of `get_dynamic_tools` with no arguments returns `OutputTrust::Trusted`, and its text parses as JSON equal to the model-path listing for the same Pack. + - Test names state the behavior. + - `src/result-tests.rs`: drop the trust assertions (trust is no longer `result.rs`'s concern), assert on returned strings, keep every text-rendering case. + - Run the new script-path tests focused (`cargo nextest run --locked -p plugin-mcp --all-features -E 'test()'`) before the source change to show them failing (the `Echo` and listing tests report `Untrusted`), then after to show them passing. +- Commit: code, docs, and tests together. + + + + diff --git a/vibe/2026-10-23-1-one-offer-api.md b/vibe/2026-10-23-1-one-offer-api.md new file mode 100644 index 000000000..ea7e7f74a --- /dev/null +++ b/vibe/2026-10-23-1-one-offer-api.md @@ -0,0 +1,1146 @@ +--- +name: One offer API on tools +overview: One offer verb, tools.offer (plus a tools.offer_dynamic scaffold), takes tools and Packs by id or object; tools.adhoc(attrs, f) defines a Lua tool without offering it; tools.packs is a read-only list of Pack objects with get and extras. The packs global, tools.required/extras, and tools.offer_local are deleted with no trace. Offers are checked when made, stored lazily, and rendered in call order at the first loop round; tool objects gain parameters; adhoc tools get a ToolInfo under the always-present adhoc Pack. The local-tool concept is renamed adhoc everywhere in the code. Tool events record each catalog tool's vendor, and the Workshop names its special tools by vendor-qualified name. +todos: + - id: adhoc-rename + content: "Step 1: rename every local-tool identifier, string, comment, and file name to adhoc across runvil/crates, internal protocol names and the two local-tool test files included, except the tools.offer_local family that step 3 deletes; a pure rename that leaves the workspace passing" + status: completed + - id: tool-parameters + content: "Step 2: tool.parameters on every tool object: LuaToolHandle keeps the schema; each read converts it with lua.to_value into a fresh table; tested for catalog tools" + status: completed + - id: event-vendor + content: "Step 2: vendors in events: the Harness passes each usable Pack's vendor to RunContext::packs (UsablePack); catalog ToolBindings (with_vendor) and the rendered dispatch map (Advertised) hold it; ToolCallEvent and ToolResult gain vendor; the Workshop server and UI match runvil/user-input/ask, runvil/web/search, and runvil/web/fetch by vendor and id, and never treat a call without a vendor as special" + status: completed + - id: listing-trust + content: "Step 2: tool listings reach scripts unwrapped: ToolInfo.describes_tools, set on mcp/get_dynamic_tools; prepare_dispatch passes a script's listing verbatim (still untrusted) and ToolDispatch::listing reports it, while a model's call stays wrapped; an adhoc call whose own function fetched a listing during that call returns wrapped and untrusted" + status: completed + - id: adhoc-reservation + content: "Step 2: the adhoc Pack id is reserved: a new InstallError variant in the plugin registry, a new PackListError variant in the parser contract with its parse.rs arm, and a refusal in RunContext::prepare reported through Requirements" + status: completed + - id: adhoc-storage + content: "Step 3: AdhocTools (in lua/src/vm/adhoc.rs) holds a ToolBinding (ToolBinding::from_info) and ToolInfo per adhoc tool, looked up by wire name and by id, and reaches the Engine through SectionVm::adhoc_tools; the tool-object registry gains an insert" + status: completed + - id: core-offers + content: "Step 3: ordered offers: ToolRuntime holds one list of Offer {target, mode} with add_tool and add_pack; current_tool_bindings is one append-and-dedupe pass over static entries in call order, catalog and adhoc tools alike, Packs expanded at render, absorbing the merge and shadowing that prepare_scoped_tools does today; current_dynamic_tools fills RenderedTools.dynamic, which nothing reads" + status: completed + - id: offer-verb + content: "Step 3: tools.offer and tools.offer_dynamic from one installer in lua/src/tools/offer.rs: ids by string shape, tool and Pack objects, strict arrays read through metamethods; explicit nil raises; render refusal first; every entry checked before any is recorded; specified errors; delete packs.offer and tools.offer_local" + status: completed + - id: packs-surface + content: "Step 3: tools.packs proxy (integer index, #, ipairs, ordered pairs, get, extras, other keys nil, raising writes, protected metatable); Pack objects with required; always-present adhoc Pack; install_tools wiring; drop install_packs; RESERVED_NAMES 63 to 62; delete tools.required/extras (install_tool_get)" + status: completed + - id: adhoc-tools + content: "Step 3: tools.adhoc(attrs, f): id adhoc/, wire name = name, parameters in short form or as a JSON Schema object, validated when defined; registered not offered; allowed after render; collisions refused; tool_route takes the call site and routes model calls through the section's rendered list; calls, counts, and events by id; Workshop labels adhoc calls as in adhoc" + status: completed + - id: lua-tests + content: "Step 3: rewrite packs-tests.rs and update tools/tests-offering.rs and every other Lua crate test that uses an old form to the new surface" + status: completed + - id: engine-harness-tests + content: "Step 3: update engine, harness runner, workshop server and UI, and runvil suite tests, every test step 2 wrote with tools.offer_local included, plus chat.md and the vibe note, to the new surface" + status: completed + - id: docs-site + content: "Step 4: exactly one runvil-docs commit on master after step 3, not pushed: the new surface; the removal of tools.allow_tasks, the task built-ins, model tasks, and task origin; every other place where the guide or plans/build-language-guide.md disagrees with the code; fixed inbound links to renamed and deleted headings; and the dash scan, anchor check, error-string comparison with step 3's code, and site build" + status: completed + - id: verify + content: "Each step's verification scope from the Execution Instructions: the survey's gates at steps 1 to 3, the removed-form, rename, and history-wording greps at step 3, and the docs greps, history-wording grep, anchor check, error-string comparison, site build, and commit checks at step 4" + status: completed +isProject: false +--- + +# One offer API on tools + + + +## Product Requirements + +Prompt authors give a section's model its tools from Lua. This plan collapses that surface to one offer verb on the `tools` table, removes the `packs` global, and turns prompt-written Lua tools into real tools with ids and tool objects. Rendering stays once per section, at the first loop round, but follows the order the offers were made. A dynamic-offer verb is added as an unconnected scaffold. + +- Problem and users: prompt authors (section Lua) and the Engine. Today offering is split across `tools.offer`, `packs.offer` (`runvil/crates/runvil-internal/lua/src/packs.rs`), and `tools.offer_local` (`runvil/crates/runvil-internal/lua/src/tools.rs`). The `packs` global costs a reserved name (`runvil/crates/runvil-internal/lua/src/globals.rs`). `tools.required()`/`tools.extras()` (`lua/src/tools/objects.rs`) list tool objects, which cannot represent model-shaped Packs (Packs such as the core file tools and `subagents`, whose tools take a different form for each model family). Local tools have no id or tool object, and the render groups tools by verb instead of by line order (`lua/src/vm/state.rs`). +- Goals: + - as few globals and verbs as possible: `tools.offer`, `tools.offer_dynamic`, `tools.adhoc`; + - one name form in Lua: tool ids and objects for tools, Pack ids and objects for Packs; + - adhoc tools that behave like catalog tools (id, tool object, `parameters`, `pack`); + - a deterministic rendered list in call order that never changes after the first render; + - tool events that say which vendor's Pack answered, so a client can tell `runvil/user-input/ask` from any look-alike. +- Non-goals: filters, connecting dynamic offers to discovery tools, shaped Packs, Pack-chosen wire names for catalog tools, and structured output from adhoc tools (all under Deferred). +- Success criteria: + - a section's `tools` table holds exactly `adhoc`, `call`, `get`, `offer`, `offer_dynamic`, and `packs`, with `calls` appearing at the section's first tool call; + - every form in the Functional Specification behaves as written; + - the Testing Plan's exit criteria hold: the survey's gates pass and its greps come back empty. +- Constraints: + - **No history.** Removed forms are deleted outright. No shim, migration path, compatibility code, hint, or note recognizes or explains an old form. No test checks an old form, including that an old name is gone. No comment or doc says what changed or what something used to be. Tests and docs that used an old form are rewritten to the new form or deleted. + - Offers hold ids, never wire names. + - Nothing is hash-ordered. + - Any offer after the section's first loop round still raises, as it does now. + - **Metadata is never wrapped.** What Lua reads about tools and Packs (`tool.id`, `tool.description`, `tool.parameters`, `tool.pack`, and a Pack's `name`, `required`, and `tools`) is the text and schema as the Pack or the `tools.adhoc` attrs declared them, never in the untrusted envelope, whatever the Pack's output trust. The envelope is for tool results only, and a script's call to a tool that lists tools, such as `mcp/get_dynamic_tools`, receives its listing unwrapped as well (see Tool listings). +- Open questions: None. + +## Functional Specification + +A section defines adhoc tools, offers tools and Packs, reads Packs and tools, and calls tools, all through the `tools` table. Each offer is checked when made and only remembered. The section's first loop round renders the remembered static offers in call order and computes the dynamic list, which nothing reads yet. Adhoc tools live in the always-present adhoc Pack and are called and counted by id. + +- Actors and workflows: + - The prompt author defines adhoc tools with `tools.adhoc`, offers with `tools.offer` or `tools.offer_dynamic` before the first loop round, and reads Packs and tools at any time, the H1 pass included. + - The Engine renders once, at the section's first loop round. In `prepare_chat` the model is resolved first (`runvil/crates/runvil-internal/engine/src/execute/scheduler/chat.rs`, lines 116-123), then the list is built (lines 129-137). + - The model calls rendered tools by wire name. Lua calls any tool by id or object, offered or not. +- Inputs and outputs: every form of the `tools` table. + +```lua +-- DEFINE. tools.adhoc(attrs, f) creates a Lua tool and returns its tool object. It does not offer it. +local grab = tools.adhoc({ + name = 'grab', -- the wire name; the id is adhoc/grab + description = 'Grab a value', + parameters = { value = 'string' }, -- optional; a type name or { type, description } per parameter, or a JSON Schema object +}, function(args) + return 'got ' .. args.value +end) +local find = tools.adhoc({ name = 'find', description = 'Find notes', parameters = { query = 'string', limit = { 'integer', 'maximum hits' } } }, function(args) + return 'found ' .. args.query +end) +local none = tools.adhoc({ name = 'none', description = 'No parameters' }, function() end) +local shout = tools.adhoc({ name = 'Shout', description = 'Uppercase text', parameters = { text = 'string' } }, function(args) -- id adhoc/shout, wire name Shout + return args.text:upper() +end) +local t = tools.get('web/search') -- attrs are plain data, so a catalog tool can be wrapped; t.parameters is a JSON Schema object +local search = tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, function(args) + return tools.call(t, args) +end) + +-- OFFER. Checked when made, remembered, rendered in call order at the first loop round. +tools.offer('web/search') -- a tool by id: a string with a slash +tools.offer('web') -- a Pack by id: a string without a slash +tools.offer(grab) -- a tool object +tools.offer(tools.packs.get('web')) -- a Pack object +tools.offer(tools.get('web/search').pack) -- the Pack a tool belongs to +tools.offer({ 'web/search', grab, 'subagents', tools.packs.get('user-input') }) -- an array mixing every form +tools.offer(tools.packs.get('web').tools) -- a Pack's tools, one by one +tools.offer(tools.packs.get('adhoc')) -- the adhoc Pack: every adhoc tool defined by the render +tools.offer(tools.packs.extras()) -- the opt-in extras: every Pack not declared under uses:, never adhoc +tools.offer(tools.packs) -- every Pack the section knows, adhoc included +tools.offer() -- no argument, offers nothing +tools.offer({}) -- empty array, offers nothing + +-- OFFER DYNAMIC. The same forms and checks; recorded into the dynamic list at render, which nothing reads yet. +tools.offer_dynamic('user-input') +tools.offer_dynamic(tools.packs.extras()) +tools.offer_dynamic({ 'web', grab }) + +-- READ +for _, pack in ipairs(tools.packs) do end -- every Pack, in name order, adhoc included +#tools.packs -- how many Packs +local web = tools.packs.get('web') -- a Pack object, or nil when the run has no such Pack +local extras = tools.packs.extras() -- a fresh array of the extra Pack objects, in name order +web.name -- 'web' +web.required -- true when declared under uses:, always false for adhoc +web.tools -- a fresh array of the Pack's tool objects, in id order +#web.tools > 0 -- "does this Pack have tools" +local tool = tools.get('web/search') -- a tool object, or nil; tools.get('adhoc/grab') == grab +tool.id -- 'web/search', or 'adhoc/grab' +tool.description -- the catalog text, or the attrs description +tool.parameters -- a fresh table holding the parameter schema +tool.pack -- tools.packs.get('web'), or the adhoc Pack + +-- CALL AND COUNT +tools.call('web/search', { query = 'lua' }) -- by id +tools.call(grab, { value = 'hi' }) -- by tool object, offered or not +tools.call('adhoc/grab', { value = 'hi' }) +tools.calls['adhoc/grab'] -- calls so far, by id; tools.calls appears at the section's first tool call + +-- REFUSED. Each raises. +tools.offer('wbe') -- no Pack named wbe +tools.offer('web_search') -- no Pack named web_search; suggests 'web/search' +tools.offer('web/serch') -- not a tool in this run +tools.offer('mcp') -- a Pack that cannot serve: the Host's reason +tools.offer('web/search', 'web/fetch') -- one argument only +tools.offer(42) -- not a tool, a Pack, or an array of them +tools.offer(nil) -- nil is not a tool or a Pack; a lookup that may miss is guarded with if +tools.offer(tools.get('web/serch')) -- the same: tools.get returned nil +tools.offer({ 'web', nil, 'mcp' }) -- an array has no gaps +tools.offer({ name = 'web' }) -- an array has only the keys 1 to #t +tools.offer({ { 'web' } }) -- arrays do not nest +tools.packs.get({ 'web', 'subagents' }) -- get takes one Pack id +tools.packs.get = nil -- tools.packs is read-only +tools.adhoc('grab', function() end) -- attrs must be a table +tools.adhoc({ name = 'x', description = 'y' }) -- the second argument must be a function +tools.adhoc({ name = 'x', description = 'y' }, function() end, 'z') -- two arguments only +tools.adhoc({ name = 'grab', description = 'again' }, function() end) -- the name grab is taken +tools.adhoc({ name = 'GRAB', description = 'again' }, function() end) -- its id, adhoc/grab, is taken +tools.adhoc({ name = 'x', description = 'y', run = 1 }, function() end) -- unknown key run +tools.adhoc({ description = 'y' }, function() end) -- name is required +tools.adhoc({ name = 'x', description = 'y', parameters = 'string' }, function() end) -- parameters must be a table +tools.call('grab', { value = 'hi' }) -- a string without a slash is not a tool id +-- any tools.offer or tools.offer_dynamic after the section's first models.loop round: the tool list is fixed +``` + +- States and validation: + - **Offer verbs.** `tools.offer` and `tools.offer_dynamic` take one argument: a string with a slash (a tool id), a string without one (a Pack id, since Pack ids are one segment and tool ids two or more, `runvil/crates/runvil-internal/types/src/names.rs`), a tool object, a Pack object, or an array mixing them. The render refusal comes before every other check. Every entry is checked before any is recorded. A tool id must name a catalog tool in the offering (the run's offerable catalog tools, `ToolSet::offered` in `runvil/crates/runvil-internal/lua/src/handles.rs`) or a defined adhoc tool. A Pack id must name a Pack with an object. An entry identical to one already recorded (same target and mode) is skipped. No argument offers nothing; an explicit `nil` is a wrong type, so a lookup that may miss (`tools.packs.get`, `tools.get`) is guarded with `if`. A table is an array of `#t` entries (honoring `__len`), each read through `__index`; it raises when an entry in that range is nil or a table (arrays do not nest), or when it holds a raw key outside `1..#t`. + - **Static render, at the first loop round.** Walk the static entries in order and append to a list, skipping tools already in it. A tool entry adds that tool. A Pack entry adds its tools in id order, expanded at render, so `tools.offer(tools.packs.get('adhoc'))` includes adhoc tools defined before the render while `tools.offer(tools.packs.get('adhoc').tools)` takes only those defined at the call. Example: `tools.offer('web')` then `tools.offer('web/search')` renders `web_fetch`, `web_search`; reversed, `web_search`, `web_fetch`; `tools.offer(grab)` then `tools.offer('web')` renders `grab`, `web_fetch`, `web_search`. + - **Shadowing.** When an offered adhoc tool and an offered catalog tool share a wire name, the adhoc tool is rendered and the catalog tool is dropped wherever it was offered. A defined but unoffered adhoc tool shadows nothing. + - **Dynamic list (scaffold).** The same render walks the dynamic entries the same way and keeps every named tool not in the static list, deduped, in offer order. A tool whose id or wire name is in the static list is skipped, so a catalog tool that shadowing dropped never sits in the dynamic list beside the adhoc tool that holds its wire name. Static therefore wins in either order: `tools.offer('web/search')` with `tools.offer_dynamic('web')` gives static `web_search` and dynamic `web/fetch`. Dynamic entries never change the static list. The list is stored with the rendered list and nothing reads it: no discovery tools, no routing, no change the model can see. + - **`tools.packs`.** A frozen proxy. Integer keys, `#`, `ipairs`, and `pairs` walk the Pack objects in name order, the adhoc Pack included, and `pairs` yields only Packs. Its only members are `get(name)`, which returns a Pack object or nil and is the "offer it only if the run has it" guard, and `extras()`, a fresh array of the usable Packs not declared under `uses:`, in name order, never the adhoc Pack. Any other key reads nil. Writes raise, and the metatable is protected the way `sys` and `var` protect theirs. + - **Extras** are a per-section opt-in through `tools.offer(tools.packs.extras())` or its dynamic twin, off by default. A Pack the Host reports unable to serve has no object, so it is never an extra. + - **Pack objects** have exactly `name`, `tools` (a fresh array of tool objects, id order), and `required` (declared under `uses:`). **Tool objects** have exactly `id`, `description`, `parameters`, and `pack`. Packs are reached only through `tools.packs.get`, integer indexes, `tool.pack`, or Pack id strings, and tools only by id string, a Pack's `tools`, or the object `tools.adhoc` returns, so no author-chosen name (a Pack `get`, a tool `tools`) ever meets a field. + - **`tool.parameters`** returns a fresh Lua table on every read, converted the way `json.decode` converts (lists marked, so `json.encode` gives back `[]`; nulls read `json.null`). Like `tool.description`, it is never in the untrusted envelope: every string in it is the schema's text as declared. + - **Adhoc tools.** `tools.adhoc(attrs, f)` takes exactly two arguments. `attrs` is plain data with exactly the keys `name` (required, alias grammar `[A-Za-z][A-Za-z0-9_-]{0,63}`, `runvil/crates/runvil-internal/lua/src/alias.rs`), `description` (required), and `parameters` (optional, a table in one of two forms told apart by its `type` key: `'object'` means a JSON Schema object, converted by the same function `json.encode` uses and used as the tool's schema as written; anything else is the existing short format, one type name or `{type, description}` per parameter. The rule is unambiguous because `object` is never a short-form type, so `{ type = 'string' }` is a short-form parameter named `type`). With no `parameters`, the schema is `{"type":"object","properties":{},"required":[]}`. `f` must be a function. The name and schema are checked when the tool is defined, so a bad one raises on the `tools.adhoc` line. The tool gets a `ToolInfo` with id `adhoc/` and plain-text output, and `name` as written is its wire name. It is registered, not offered, and its tool object is returned. Defining works after the render; only offers are refused there. Its results are trusted output, unless its own function fetched a tool listing during the call (see Tool listings). A result the function got from `tools.call` of any other untrusted tool is already nonce-wrapped with the run's shared nonce when the script receives it (`lua/src/dispatch.rs`, `prepare_dispatch`), so it reaches the model byte for byte as the direct call's would; the result event's `trusted` flag still reads true, because the author's function produced the text. + - **The adhoc Pack** exists in every section VM from the start, the H1 pass included, with `required` false and an empty `tools` until the first `tools.adhoc`; then `tools` lists every adhoc tool defined, offered or not, in id order. It is not part of the run's catalog or roster. + - **Calls and counts.** `tools.call` takes a tool id or a tool object. `tools.calls` keys every tool by id. Counts are seeded at 0 for every tool in the section's rendered list, offered adhoc tools included; any other tool, such as a defined but unoffered adhoc tool, gains its count at its first call, and reading its key before then raises the existing `tools.calls: ... has no seeded count` error, with the adhoc hint given under Errors and recovery for a defined adhoc tool's id. Adhoc tool-call and tool-result events carry the tool's id. + - **Vendors in events.** Every tool-call and tool-result event for a catalog tool records `vendor`, the vendor of the Pack that answered, as the Harness picked it: `runvil` for a bare `uses:` entry and for an undeclared Pack, `acme` for an `acme/kit` entry. `vendor` and `tool` together give the vendor-qualified name, such as `runvil/user-input/ask`; a vendor is one segment and always comes first, so a name joined from a present `vendor` and a `tool` is never ambiguous. A reader joins them only when `vendor` is present. A tool id can have three segments, so a bare `tool` such as `runvil/user-input/ask`, offered by a Pack whose id is `runvil`, is never read as a vendor-qualified name. Adhoc tools and names outside the round's scope record no vendor, and neither does a run given no roster. Lua never sees vendors, and tool ids are unchanged. + - **Tool listings.** A catalog tool whose `ToolInfo` sets `describes_tools`, today only the `mcp` Pack's `get_dynamic_tools`, returns its listing to a script's `tools.call` as plain text, while a model's call to it still receives the untrusted envelope. The script call's result event keeps `trusted` false, since the text comes from the servers, and holds the text the script received. An adhoc call whose own function fetched such a listing with `tools.call` while that call was open returns untrusted: its result is wrapped with the run's nonce and its event has `trusted` false. So, for a call the listing tool answers successfully, a wrapper of `get_dynamic_tools` gives the model the same envelope, byte for byte, as a direct model call. That is the whole guarantee. A listing a script keeps (in a variable or the store), or gets back from a called section or a task, and later returns from an adhoc tool or puts in a message, reaches the model as the author wrote it: unwrapped, and with `trusted` true when an adhoc tool returns it. That is the author's responsibility, as all script-authored text is. +- Errors and recovery: messages are part of the contract (tests and docs use them). `` is `tools.offer` or `tools.offer_dynamic`. + - after the render: `: the section's tool list is fixed at its first models.loop round; make every offer before the first loop, or offer in another section`; + - unknown Pack: `: no Pack named "" in this run; declare it under uses: to require it, or offer it only when tools.packs.get("") returns it`, plus ` (did you mean ''?)` when a tool in the offering or a defined adhoc tool has the wire name ``; + - unknown tool: `: "" is not a tool in this run`; + - an offer string that is not a valid id, such as `'Web'`, `'Shout'`, or `'web//x'`, takes the message of an unknown one of its shape: the unknown-Pack message without a slash, the unknown-tool message with one; + - Pack that cannot serve: `: the Pack is unavailable in this run: `; + - wrong type: ` takes tool ids, Pack ids, tool objects, Pack objects, or an array of them, got `; + - extra argument: ` takes one tool or Pack, or an array of them, got extra `; + - array entry: ` takes tool ids, Pack ids, tool objects, or Pack objects in an array, got at index ` (a nil gap or a nested table); + - array key: ` takes an array, got a table with the key `; + - `tools.packs.get takes one Pack id, got `; `tools.packs is read-only`; a metatable change raises Lua's `cannot change a protected metatable`; + - `tools.adhoc takes an attrs table first, got `; `tools.adhoc takes the tool's function second, got `; `tools.adhoc takes two arguments, got extra `; `tools.adhoc: unknown key ""; accepted keys are name, description, parameters`; `tools.adhoc: "" is required`; `tools.adhoc: "" must be a string, got ` for `name` and `description`; `tools.adhoc: invalid name "": expected [A-Za-z][A-Za-z0-9_-]{0,63}`; `tools.adhoc: parameters must be a table, got `; `tools.adhoc: parameters schema cannot be encoded as JSON: `; `tools.adhoc name "" is already registered`; `tools.adhoc name "" has the id adhoc/, which "" already holds`; the existing parameter-type errors under the `tools.adhoc` prefix; + - `tools.call takes a tool id or a tool object, got `; `tools.call: "" is not a tool id`; + - the `tools.calls` hint for a defined adhoc tool's id: ` (an adhoc tool that was neither offered in this section nor called with tools.call)`, beside the existing catalog hint; + - a `jump` inside an adhoc tool's function: `jump is unavailable inside an adhoc tool handler: return a value from the handler and call jump from the block after the tool call returns`; + - a Pack whose id is `adhoc` from any vendor is refused by the Pack registry with ``Pack `` cannot take the Pack id adhoc, which holds a section's own tools``, and a `uses:` entry for `adhoc` or `/adhoc` fails to parse with `invalid frontmatter: adhoc holds a section's own tools and is never declared under uses`; + - a run whose tool catalog holds a tool under the Pack id `adhoc`, or whose roster (usable or unavailable) holds the Pack `adhoc`, is refused when it is prepared: the refusal notice holds the line `- the run's tool catalog or Pack roster names the Pack id adhoc, which holds a section's own tools`. +- Security and privacy behavior: adhoc tools never reach the Harness. Lua never reads wire names, so prompts name tools only by id. Pack and tool objects stay frozen, and `tools.packs` cannot be modified or stripped of its metatable. The Pack id `adhoc` has one meaning in every run, because the Pack registry, the parser, and the run's preparation each refuse it from anywhere else. +- Acceptance criteria: + - every line of the forms listing behaves as its comment says; + - the rendered schemas are byte for byte the same with or without any dynamic offers; + - a model call to a catalog tool's wire name is never answered by a defined but unoffered adhoc tool of the same name; + - the three render-order examples above hold; + - an adhoc tool defined with `description = t.description` and `parameters = t.parameters`, where `t = tools.get('web/search')`, renders the catalog tool's description and schema unchanged; `web` marks its results untrusted, so this also shows that no metadata read arrives in the envelope; + - a model call to that wrapper, whose function returns `tools.call(t, args)`, gives the model, for a call the tool answers successfully, the same result text, byte for byte, as a model call to the catalog tool itself; + - a script's call to `mcp/get_dynamic_tools` returns the listing without the envelope, a model's call returns it wrapped, and, for a call the tool answers successfully, a model call to an adhoc wrapper of it returns the same envelope as the direct model call; + - a listing the section fetched outside any adhoc call and kept in a variable, then returned by an adhoc tool, reaches the model unwrapped, with `trusted` true on the adhoc result event; + - a run whose catalog or roster names the Pack id `adhoc` is refused when it is prepared, with the notice line under Errors and recovery; + - in the Workshop, the ask, search, and fetch views appear only for `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch`: the same id from another vendor's Pack, a call without a vendor whose tool id is `runvil/user-input/ask`, or an adhoc tool named `ask`, shows as an ordinary tool, and a script's call to another vendor's `user-input/ask` does not frame as the operator's message. + + + + +## Technical Design + +The Lua crate records offers as ids with a mode and defines adhoc tools as bindings in the section VM. The Engine's first loop round turns those records into the rendered list and the dynamic list through one shared expansion. The scheduler routes model calls through the section's rendered list and script calls by id, so adhoc tools need no separate rendering or routing path. `RunContext::prepare` refuses a caller-supplied `adhoc` Pack id. Outside the Lua and Engine crates, the changes are the `adhoc` reservation in the plugin registry and parser contract, three doc comments and the `vendor` event fields in the types crate, the Harness handing the Engine each Pack's vendor, two lines of the Workshop chat agent, the Workshop server's and UI's vendor-qualified matching and the UI's labels, and the local-to-adhoc rename. + +- Architecture: + +```mermaid +flowchart LR + verbs["offer verbs"] -->|"Offer entries"| runtime[ToolRuntime] + adhocFn["tools.adhoc"] -->|bindings| adhocTools[AdhocTools] + runtime --> render["first render"] + adhocTools --> render + render -->|"schemas, dispatch"| rendered[RenderedTools] + render -->|"dynamic ids"| rendered + rendered --> route["tool_route"] + adhocTools --> route +``` + +- Modules and interfaces: + - `Offer { target, mode }` in `lua/src/scope.rs`, with `pub enum OfferTarget { Tool(ToolId), Pack(PackId) }` and `pub enum OfferMode { Static, Dynamic }`. `ToolRuntime` holds one ordered `offers: Vec` with `pub fn add_tool(&mut self, id: ToolId, mode: OfferMode)` and `pub fn add_pack(&mut self, pack: PackId, mode: OfferMode)`, each skipping an `Offer` equal to one already held. No filter field yet. + - `current_tool_bindings(set, runtime, adhoc)` in `lua/src/vm/state.rs` returns the static bindings. A sibling `current_dynamic_tools(set, runtime, adhoc, static_bindings)` returns the dynamic ids. Both use one shared entry-expansion helper, so the lists cannot disagree about what an entry names. Catalog ids resolve with `ToolSet::offered_binding`, which already accepts ids. `adhoc/*` ids resolve against the VM's adhoc bindings. Shadowing is applied last in the static pass. Today the merge of the section's Lua tools and the rule that they win over catalog tools of the same wire name live in the Engine's `prepare_scoped_tools` (`engine/src/execute/scope.rs`, lines 44-51 and 70-73); they move into this pass, so the Engine receives one finished list. `AdhocTools` is `pub` and re-exported from `runvil-lua` beside `current_tool_bindings`, and `SectionVm::adhoc_tools(&self) -> &AdhocTools` hands it to the Engine. Every caller moves to the new signature: `scheduler/chat.rs` (line 132), `section_call_counts` (`scheduler/tool_call.rs`, lines 175-181), `lua/src/test_vm.rs` (line 122), the test callers in `lua/src/tests/tool_scoping.rs`, `lua/src/tests/section_vm.rs`, `engine/src/execute/tests.rs`, and `engine/src/execute/tests/tool_scoping.rs`; `prepare_scoped_tools`' test callers in `engine/src/execute/tests/offering.rs` (line 345) and `tool_scoping.rs` (lines 50 and 113) drop their second argument. + - `RenderedTools` (`lua/src/scope.rs`) gains `dynamic: Vec`. Its dispatch map's value is `pub struct Advertised { pub id: ToolId, pub vendor: Option }` in `lua/src/scope.rs`, holding for every advertised name, adhoc included, the tool's id and its Pack's vendor (none for adhoc tools). Step 2 makes the map `dispatch: BTreeMap>`, with `None` for an adhoc tool, which has no id until step 3; step 3 makes it `BTreeMap`. `Chain.advertised` (`scheduler/chain_record.rs`) holds the same map type in each step and keeps serving the round's scope gate and the `ToolCallEvent` stamping; it never routes. + - `AdhocTools` (renamed from `LocalTools`, and moved from `lua/src/vm.rs` to the new `lua/src/vm/adhoc.rs`) stores `ToolBinding` entries built with `ToolBinding::from_info(name, &info)`, with lookups by wire name and by id. `tools.adhoc` validates the name and schema through `tool_schema_new` (as `tools.offer_local` does today, `lua/src/tools.rs` line 283) before registering, raising its error under the `tools.adhoc:` prefix. The JSON Schema form of `parameters` converts through a `pub(crate)` function exposed from `lua/src/json/encode.rs` that `json.encode` also uses. Handlers stay in the VM's handler table under the name. + - `LuaToolHandle` (`lua/src/tools/userdata.rs`) keeps the binding's schema for `parameters`. `LuaPackHandle` (`lua/src/packs.rs`) gains `required`. The adhoc Pack's object reads the VM's adhoc tool objects on each `tools` read. + - The tool-object registry (`lua/src/tools/objects.rs`) gains an insert for `tools.adhoc`. + - **Vendors.** The Harness's `HostRunContext::roster` (`harness-internal/runner/src/host-run.rs`, lines 110-130) reports each usable Pack with the vendor from its `PackInfo` FQN, and `prepare_from_snapshot` (`harness-internal/runner/src/prepare.rs`, lines 362-371) passes it to `RunContext::packs`. That call takes a new `UsablePack` in place of a bare `PackId`: `#[non_exhaustive] pub struct UsablePack { pub pack: PackId, pub vendor: String }` with `UsablePack::new(pack, vendor)`, in `engine/src/execute/requirements.rs` beside `UnavailablePack` and exported from the `runvil` facade the same way. The Engine keeps the vendors beside the roster. `ToolBinding` gains `vendor: Option`, set with `#[must_use] pub fn with_vendor(self, vendor: Option) -> Self` and read with `ToolBinding::vendor()`; `from_info` leaves it `None`, so adhoc bindings have none. `context-bound.rs` stamps each catalog `ToolBinding` with its Pack's vendor. `tool_calls` in `scheduler/chat.rs` (lines 347-349) sets the id and vendor from the advertised map on each `ToolCallEvent`, and `prepare_dispatch` and `prepare_model_dispatch` (`lua/src/dispatch.rs`) pass `binding.vendor()` to `Emitter::tool_result`. Engine doc comments on `UsablePack`, `RunContext::packs`, and `ToolBinding::vendor` say the caller reports each Pack's vendor; only Harness and docs text name the Harness. + - **Tool listings.** `ToolInfo` (`types/src/tools/info.rs`) gains `describes_tools: bool` (`#[serde(default)]`, set with `.describes_tools(true)`, the way `survives_stop` is), set on `get_dynamic_tools` in `plugin-mcp/src/dynamic.rs` (lines 137-153); `runvil/public-api.txt` follows. `ToolBinding` copies the flag. `prepare_dispatch` (`lua/src/dispatch.rs`, lines 132-144) passes the text verbatim when the call is a script's (`script` is `Some`) and the binding describes tools, keeping the trust `Untrusted`; the model path, `prepare_model_dispatch`, is unchanged. `ToolDispatch` gains `pub fn listing(&self) -> bool`, true only when that script rule passed untrusted text verbatim; a `describes_tools` tool that returns trusted output is passed as any trusted output and sets nothing. `accept_tool_call` (`scheduler/apply.rs`, line 189) takes `&mut self`, and for a `CallSite::Script` whose dispatch reports `listing()`, sets `listing = true` on the current frame's innermost open `AdhocCall` (renamed from `LocalCall`, `engine/src/execute/section_context.rs`; `listing` is a new `bool` field). A failed call marks nothing. `dispatch_adhoc_tool_done` (`scheduler/tool_call.rs`, line 395) wraps a marked call's result with the run's nonce and reports it untrusted. A wrapped result handed to an enclosing adhoc function is already enveloped, so only the innermost call needs the mark. The mark is per frame and per open call by design: a listing fetched outside the adhoc call, in a section the adhoc function calls, or in a task marks nothing. + - `tools.packs` is an empty proxy table with a metatable, following the `tools.calls` pattern in `lua/src/tools.rs`: `__index` (integers, `get`, `extras`), `__len`, ordered `__pairs`, raising `__newindex`, and `__metatable`. + - `tool_route` (`engine/src/execute/scheduler/tool_call.rs`) takes the call site from both of its callers; today it sees only the name. The single-call path (line 264) passes the one `dispatch_tool_call` built, and the batch path, `dispatch_tool_calls` (line 125), builds its `CallSite::Batch` before routing, so the route sees it. A model call (it has a call id) looks its wire name up in the rendered list of the current frame's section, never in `Chain.advertised`, which another section's rounds overwrite while an adhoc function's `call` runs. `SectionVm` gains `pub fn rendered_target(&self, wire_name: &str) -> Option`, which reads the entry from the rendered list attached to the VM's `messages` without copying the list. The call goes to the adhoc handler when the name maps to an `adhoc/*` id. With no rendered list, a model call routes as a catalog call by wire name, so the test hook `tools.call_as_model` reaches an adhoc tool only after the section renders; tests that call adhoc tools through it render first (one loop round) or call by id. A script call routes by id. The adhoc arm counts and reports under the id. + - **The `adhoc` reservation in a run.** `RunContext::prepare` (`engine/src/execute/config.rs`, line 326) checks the catalog (each `ToolInfo` id's `ToolId::pack()`) and the roster (usable and unavailable) for the Pack id `adhoc`. `prepare` returns its report rather than an error, so the refusal goes through it: `Requirements` (`engine/src/execute/requirements.rs`) gains `pub reserved_adhoc: bool`, set by that check, merged with `|=`, counted by `is_satisfied`, and written by `notice` as the line given under Errors and recovery. `refusal()` then returns the usual `RequirementsUnmet` run error. `runvil/public-api.txt` follows. +- File and public API changes (paths under `runvil/crates/`): + - `runvil-internal/lua/src/`: + - `scope.rs`: `Offer`, `OfferTarget`, `OfferMode`, `ToolRuntime`, `Advertised`, `RenderedTools.dynamic`; + - `vm/state.rs`: the two render functions; `adhoc_tool_schemas` (line 90) is removed and `has_adhoc_tool` (line 98) takes a wire name or an id; + - `vm.rs` and the new `vm/adhoc.rs`: `AdhocTools` moves to `vm/adhoc.rs`; `SectionVm` gains `adhoc_tools` and `rendered_target`; + - `tools.rs`: `tools.adhoc` replaces `install_offer_local`; `resolve_entries` returns ids; `install_tools` installs tool objects, Pack objects, `tools.packs`, both offer verbs, `tools.adhoc`, and `tools.get`; + - `tools/offer.rs` (new): one offer installer, parameterized by name and mode and installed twice, with its decode: array reads through metamethods and the Pack-id checks moved from `packs.rs`; + - `tools/decode.rs`: `tool_name` accepts ids and objects only; the short-form `parameters` conversion (`local_params_schema` today) becomes `adhoc_params_schema` under the `tools.adhoc` prefix; + - `json/encode.rs`: the `pub(crate)` conversion `tools.adhoc` shares with `json.encode`; + - `tools/objects.rs`: `install_tool_lists` becomes `install_tool_get`; + - `tools/userdata.rs`: `parameters`; + - `packs.rs`: Pack objects, `tools.packs`, `get`, `extras`; `packs.offer` and its decode removed, with the Pack-id checks moved to the offer decode; + - `vm/inject.rs`: no `install_packs` call; + - `globals.rs`: `packs` removed from `RESERVED_NAMES`, length 63 to 62. + - `runvil-internal/engine/src/execute/`: + - `scope.rs`: `prepare_effective_scope` and `prepare_scoped_tools` drop `adhoc_schemas`, and the adhoc merge and shadowing move into the Lua static pass; + - `scheduler/chat.rs` (lines 129-137): pass the adhoc tools, call `current_dynamic_tools`, store the result; + - `scheduler/tool_call.rs`: the call site in `tool_route`, the new render signature in `section_call_counts`, counts by id, and the adhoc `ToolResult` emitted with the id (lines 409-417); the adhoc arm (`tool_route`'s adhoc branch and `dispatch_adhoc_tool_done`) moves to the new `scheduler/tool_call-adhoc.rs`; + - `scheduler/apply.rs`: `accept_tool_call` takes `&mut self` and sets the listing mark; + - `section_context.rs`: `AdhocCall` gains `listing: bool`; + - `config.rs` (`RunContext::prepare`) and `requirements.rs` (`Requirements.reserved_adhoc`): the `adhoc` refusal. + - `runvil-internal/types/src/metrics.rs` (line 31), `types/src/event.rs` (line 405), and `types/src/emitter.rs` (line 355): drop "a Lua-local tool" from what `tool: None` means. + - `runvil-plugin/src/registry.rs`: `adhoc` is reserved through a new `InstallError` variant with its own message. `reserves` and `ReservedName` cover only built-in Packs, and `ReservedName`'s message names a built-in `runvil/` that does not exist for `adhoc`. + - `runvil-internal/parser/src/contract.rs`: a new `PackListError` variant, checked beside `check_distinct_packs`, refuses `adhoc` from any vendor with the message under Errors and recovery, and `parser/src/parse.rs` (lines 101-105) gains its match arm, a `Frontmatter` parse error like `Duplicate`'s. `PackRef` itself lives in `types/src/packs.rs`. + - `workshop/agents/agents/chat.md` (lines 30-31): `tools.offer("web")` and `tools.offer(tools.packs.extras())`. + - Vendor fields: `types/src/metrics.rs` (`ToolCallEvent`) and `types/src/event.rs` (`Event::ToolResult`) gain `vendor: Option` with `tool`'s serde attributes (`default`, `skip_serializing_if = "Option::is_none"`), `ToolCallEvent` gains `with_vendor`, and `Emitter::tool_result` (`types/src/emitter.rs`) takes the vendor. `engine/src/execute/config.rs` (`RunContext::packs`) and `requirements.rs` (`UsablePack`) change with them, and `runvil/public-api.txt` follows. The Harness side is `host-run.rs` and `prepare.rs`. + - Workshop server: `workshop/server/src/agents.rs` (lines 145-158) holds the ask as vendor `runvil` plus id `user-input/ask`, `socket.rs` and `socket_frames.rs` hold that pair, and `agents/wire.rs` (line 275) frames a script's result as the operator's message only when both vendor and id match. `render_tool_calls` serializes `ToolCallEvent` as is, so the UI receives `vendor` with no wire change. + - Workshop UI: `ui/src/parts/agent/transcript/tool-labels.ts` `toolKind` takes the vendor-qualified name (`vendor/tool`) and matches `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch` exactly, so no tool without the `runvil` vendor is ever special, `adhoc/ask` included. Labels stay ` in `, read from `tool`, so adhoc calls read ` in adhoc`. `transcript-model.ts` (line 334) passes `vendor === null ? null : vendor + "/" + tool` to `toolKind`, so a call without a vendor is never special, and `ui/src/services/agent-session.ts` (lines 55-61, 174-176) parses and documents `vendor`. + - Other event readers: `runvil/tests/suite/event.rs` (lines 75-77) prints the vendor-qualified name. `engine/src/test_support/recording/forward.rs` is unchanged: `ToolResultReport` (`engine/src/test_support/recording.rs`, line 100) has no `tool` field, and `forward.rs` destructures the event with `..`. + - **Naming, local to adhoc, everywhere.** Every identifier, string, comment, and file name that names a local tool becomes adhoc: `LocalTools`, `ToolRoute::Local`, `ToolCallOutcome::Local` (to `ToolCallOutcome::Adhoc`), `LocalToolOutcome` (to `AdhocToolOutcome`), `has_local_tool` and `local_tool_schemas` (to `has_adhoc_tool` and `adhoc_tool_schemas`), `LocalCall` with `push_local_call` and `pop_local_call`, `local_calls` (to `adhoc_calls`), `local_handler_depth`, `enter_local_handler`, and `leave_local_handler` (to `adhoc_handler_depth`, `enter_adhoc_handler`, and `leave_adhoc_handler`), the `local_handler` function in `lua/src/tools.rs` (line 71, to `adhoc_handler`), `prepare_scoped_tools`' and `prepare_effective_scope`'s `local_schemas` (to `adhoc_schemas`), `Request::LocalToolDone`, the `local_tool_done` coroutine op with `parse_local_tool_done`, `dispatch_local_tool_done`, and the shim's `run_local_tool`, the `LOCAL_HANDLERS_REGISTRY` key `runvil.tools.local_handlers`, local-tool test function and helper names, and the user-visible jump refusal (`lua/src/vm/install.rs`, line 208), which becomes the text under Errors and recovery and is asserted in `engine/src/lua/tests/shims.rs` and in `JUMP_REFUSAL` in the renamed `adhoc_tools.rs`. The test files `local_tools.rs` and `tool_call_arm-local-handlers.rs` are renamed `adhoc_tools.rs` and `tool_call_arm-adhoc-handlers.rs`, with their `mod` lines. Lua `local` variable declarations (`local tool =`, `local tools =`, `local tool_ok`, `local tools_list`) are variables, not this concept, and stay. The Lua-visible `tools.offer_local` family is not renamed, because step 3 deletes it: `tools.offer_local`, `install_offer_local`, `local_params_schema`, every error that begins `tools.offer_local`, and the `tools.call` type error that names "a local alias". Files with hits today: + - `lua/src/`: `vm.rs`, `coro.rs`, `lib.rs`, `scope.rs`, `alias.rs`, `tools.rs`, `__impl_coro.lua`, `__impl_globals.lua`, `vm/inject.rs`, `vm/state.rs`, `vm/install.rs`, `protocol.rs`, `protocol/answer.rs`, `protocol/request.rs`, `protocol/parse.rs`, `protocol/parse/tools.rs`, `protocol/render.rs`, `models_loop.rs`, `models_loop/machine.rs`, and the tests `models_loop/tests.rs`, `models_loop/machine-tests.rs`, `protocol/tests/answer.rs`, `protocol/tests/parse.rs`, `tests/diagnostics.rs`, `tools/tests-offering.rs`, `messages-tests.rs`, `packs-tests.rs`; + - `engine/src/`: `error.rs`, `lua/tests/shims.rs`, and under `execute/`: `protocol.rs`, `scope.rs`, `section_context.rs`, `section_context-construct.rs`, `tests.rs`, `scheduler/chat.rs`, `scheduler/tool_call.rs`, `scheduler/dispatch.rs`, `scheduler/chain_record.rs`, `run/tests-drops.rs`, and the tests `local_tools.rs`, `tool_call_arm.rs`, `tool_call_arm-local-handlers.rs`, `offering.rs`, `models_loop.rs`, `models_loop_contract-rounds.rs`, `chat_scope.rs`, `debug_and_counts.rs`, `batch_turn.rs`, `parallel_tool_calls.rs`, `scheduler/live_h1/control_flow.rs`; + - `types/src/`: `emitter.rs`, `metrics.rs`, `event.rs`; `parser/src/contract/models.rs`; `workshop/ui/src/services/agent-session.ts`. + - Doc comments in every touched file describe only the new API. That includes `lib.rs` (lines 41-45), `tools/decode.rs` (line 60), `test_vm.rs` (line 112), and `plugin-user-input/src/lib.rs` (line 61). +- Data, persistence, failure, security, and privacy constraints: + - Adhoc tool-call and tool-result events now hold `tool: Some(adhoc/)`. Every reader that treated `None` as a local tool is updated: `engine/src/execute/tests/chat_arm.rs` (line 176), `engine/src/execute/tests/offering.rs` (line 353, which asserts `Some(None)`), the Workshop UI's `tool-labels.ts` and `transcript-model.ts`, and the UI tests `test/transcript-model.mjs` (lines 397, 430-434) and `test/agent-session-service.mjs` (lines 650-663). Nothing is added for records written before this change, so a replay of such a run no longer matches its Chat effects, whose advertised order and event `tool` fields change. + - Tool-call and tool-result events gain `vendor`. A log written before this change has no `vendor`, which reads as absent the way any missing optional field does; nothing treats such a log as naming a `runvil` tool. + - `RESERVED_NAMES` loses `packs`. The two-way globals tests in `engine/src/lua/tests/globals.rs` confirm the installed globals match. + - The rendered list, dynamic list included, is attached to `messages` and lives exactly as long as the section. + - Every Rust file under `src` or `tests` stays at or under 500 lines: `build-ceiling` (`crates/build-ceiling/src/lib.rs`, line 14) runs from every crate's `build.rs` and fails the build past that. Files this plan grows are close today: `types/src/event.rs` 447, `engine/src/execute/scheduler/tool_call.rs` 432, `lua/src/vm.rs` 424, `engine/src/execute/config.rs` 424, `types/src/emitter.rs` 421, and `lua/src/packs-tests.rs` 373. Planned splits: `AdhocTools` moves to `lua/src/vm/adhoc.rs`; the offer installer and its decode go in `lua/src/tools/offer.rs`; `tools.packs` and the Pack objects stay in `packs.rs`; the adhoc arm of `tool_call.rs` moves to `scheduler/tool_call-adhoc.rs`, wired with `#[path]`; new tests go in new `-tests` siblings rather than growing a file past the limit. A file that still crosses the limit is split in the step that grows it. + + + + +## Testing Plan + +Lua crate tests pin every form, error, and ordering rule. Engine and other crate tests pin rendering, routing, events, and the reservations. Regression checks are a key-set test of the `tools` table and repository greps for removed forms and history wording. No test checks an old form. + +- Unit (Lua crate; `lua/src/packs-tests.rs` rewritten, `lua/src/tools/tests-offering.rs` and the local-tool tests updated): + - **`tools.packs`:** + - integer indexes, `#`, `ipairs`, and `pairs` in name order with `adhoc`, and `pairs` yields no methods; + - `get` returns shared objects, or nil for unknown names and tool ids, and raises for non-strings, tables included; + - any other key reads nil, writes raise, and `setmetatable(tools.packs, nil)` is refused; + - `extras()` excludes declared Packs, `adhoc`, and Packs that cannot serve; + - the `==` identity of objects across `get`, integer indexes, and `tool.pack`. + - **`required`:** true for declared Packs, including one with no tools, and false for undeclared ones and `adhoc`. `#pack.tools > 0` holds for a Pack with tools and fails for one without. + - **Collisions:** Packs named `get`, `extras`, and `tools`, the last offering `tools/tools`, are reached through `get` and offered by id. Pack and tool objects have no fields beyond the specified ones. + - **Offer verbs, both of them:** + - every accepted form, and the string-shape rule; + - each error message, including the wire-name hint; + - the render refusal first; + - a bad array entry records nothing; + - `tools.offer()` offers nothing, `tools.offer(nil)` raises, and an array with a gap, a stray key, or a nested table raises with the array messages; + - `tools.offer(tools.packs)` and `tools.offer(tools.packs.extras())` offer what they should. + - **Order:** + - the three render-order examples; + - repeated offers neither duplicate nor move a tool; + - a tool followed by its Pack keeps the tool's place; + - Pack expansion at render against `.tools` taken at the call, for the adhoc Pack; + - rename `the_scope_lists_section_tools_then_each_offered_packs_tools_each_name_once` (in `packs-tests.rs`) to describe call order. + - **Dynamic list:** + - static wins in both orders; + - duplicates are listed once, in offer order; + - adhoc tools defined later but before the render are included; + - the rendered schemas are unchanged. + - **`tool.parameters`:** + - it matches the catalog schema, or the schema built from `parameters`, or the empty schema; + - each read is a fresh table; + - it round-trips through `json.encode` with `[]` intact; + - assigning it raises. + - **Tool listings** (`lua/src/dispatch-tests.rs` for the rule, engine tests for the paths, with a test tool that sets `describes_tools` and returns untrusted text): + - a script's call returns the text without the envelope, and its result event has `trusted` false and that text; + - a model's call returns the envelope; + - for a call the tool answers successfully, a model call to an adhoc wrapper of it returns the same envelope byte for byte, with `trusted` false; + - an adhoc function that calls it and returns its own text returns wrapped and untrusted; + - a script call outside any adhoc call marks nothing, and a call to a tool without the flag is wrapped for a script as before; + - the mark's scope: a listing kept from a call made outside the adhoc call and returned by it comes back unwrapped, with `trusted` true on the adhoc result event; + - a failed listing call marks nothing, and a `describes_tools` tool that returns trusted output sets no mark; + - `plugin-mcp`: `get_dynamic_tools` sets `describes_tools` and `call_dynamic_tool` does not. + - **No envelope on metadata:** for a test Pack whose results are untrusted, `tool.description`, every string in `tool.parameters`, and the Pack's `name` equal the `ToolInfo` text exactly and never contain the run's nonce or the envelope's preface; the same holds for an adhoc tool object, whose `description` is the attrs text as given. + - **Adhoc:** + - the returned object equals `tools.get('adhoc/grab')`, with correct fields and `pack`; + - it is not rendered until offered; + - `tools.call` works without an offer, and after the render; + - offering after the render raises; + - `Shout` gets the id `adhoc/shout` and the wire name `Shout`; + - every `tools.adhoc` refusal in the forms listing; + - `parameters = tools.get('web/search').parameters` gives a tool whose `parameters` deep-equals the catalog tool's and whose rendered schema is the same text, and `parameters = { type = 'string' }` is a short-form parameter named `type`; + - for a call the tool answers successfully, a wrapper of an untrusted test tool gives the model the same nonce-wrapped text as a direct model call to that tool; + - a definition without `parameters` gets the schema `{"type":"object","properties":{},"required":[]}`, and a bad name or schema raises on the `tools.adhoc` line with the `tools.adhoc:` prefix; + - `tools.call('grab')` raises; + - `tools.calls['adhoc/grab']` counts, reads 0 for an offered adhoc tool once counts are installed, and raises for an unoffered one until its first call; + - shadowing only among offered tools; + - the adhoc Pack exists in H1 and when nothing is defined. +- Integration and end-to-end: + - `engine/src/execute/tests/offering.rs`: + - `TOOLS_TABLE_KEYS` (line 176) becomes `adhoc:function,call:function,get:function,offer:function,offer_dynamic:function,packs:table`; + - lines 144 and 185 use `tools.offer(tools.packs.extras())`; + - line 214 uses `tools.packs.get('tools').tools[1]`. + - `engine/src/execute/tests/tool_scoping.rs` builds `ToolRuntime` through `add_tool` with the id `tools/concrete`. + - `RenderedTools` struct literals in `lua/src/compactors-tests.rs` (line 176), `lua/src/tools/tests-offering.rs` (line 197), and `lua/src/messages-tests.rs` (line 50) take `Advertised` dispatch values (step 2) and gain `dynamic` (step 3). + - The adhoc-handler tests use these forms. In the engine, under `engine/src/execute/tests/`: `adhoc_tools.rs` and `tool_call_arm-adhoc-handlers.rs` (renamed), `debug_and_counts.rs`, `chat_arm.rs`, `chat_scope.rs`, `batch_turn.rs`, `compaction.rs` (line 260 asserts the `tools.offer_local:` error text), `models_compact.rs`, `models_loop.rs`, `models_loop_contract-rounds.rs`, `section_messages.rs`, and `scheduler/live_h1/control_flow.rs`, plus `engine/src/lua/tests/shims.rs`. In the Lua crate: `tools/tests.rs` (lines 191-197 assert `tools.offer_local` exists), `protocol/tests/answer.rs`, and `tests/diagnostics.rs`. The same goes for every test step 2 added that defines a tool with `tools.offer_local`. The forms: + - `tools.offer(tools.adhoc({ name = ..., description = ..., parameters = ... }, handler))`; + - calls and counts by `adhoc/` or by object; + - call-order expectations; + - events that carry the id. + - Lua strings in `rendered_tools.rs`, `offering-packs.rs`, and `parallel_tool_calls.rs` use `tools.offer`. `rendered_tools.rs` expects `lua:tools.offer: ...`, and its after-render case checks that defining still works while offering raises. + - A model call to an offered catalog `web_search` reaches the catalog tool while an unoffered adhoc `web_search` exists. + - `harness-internal/runner/tests/it/prepare-ready.rs` (lines 76, 309, 317, 347-348) and `harness-internal/runner/tests/it/prepare.rs` (lines 377, 408), `workshop/server/src/agents/tests.rs` (line 115), and `runvil/tests/suite/packs.rs` (line 10) use `tools.packs.get` and `tools.packs.extras()`. + - The `adhoc` reservation: + - a plugin registry test checks that `acme/adhoc` is refused with the new variant's message; + - a parser test checks that `uses: [adhoc]` and `uses: [acme/adhoc]` fail to parse with kind `Frontmatter` and the message under Errors and recovery; + - an Engine test (`engine/src/execute/requirements-tests.rs`) prepares a context whose catalog holds `adhoc/grab`, one whose usable roster holds `adhoc`, and one whose unavailable roster holds `adhoc`: each report has `reserved_adhoc` set, `is_satisfied()` false, and a `refusal()` of kind `RequirementsUnmet` whose notice holds the reserved line once; a context naming no `adhoc` has the flag clear, and `merge` keeps a set flag. + - Vendors: + - Harness (`harness-internal/runner/src/host-tests.rs`, whose fixtures install both `acme/kit` and `runvil/kit`): a run whose `uses:` names `acme/kit` hands the Engine `acme` for `kit` and `runvil` for every undeclared Pack; + - Engine: a model call and a script call to a catalog tool record its vendor on `AssistantToolCalls` and `ToolResult`, and an adhoc call records none; + - Workshop server (`agents/wire-tests.rs`, `agents/tests.rs` line 174): a script's `user-input/ask` result with vendor `runvil` frames as the operator's message, and the same id with vendor `acme`, or with no vendor, stays a tool result. + - Workshop UI (`workshop/ui/test/`): `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch` are `ask`, `search`, and `fetch`; `acme/user-input/ask`, `acme/web/search`, and `adhoc/ask` are `other`; a vendorless call whose tool id is `runvil/user-input/ask` is `other`; an adhoc call labels as ` in adhoc`, replacing the null-tool cases in `transcript-model.mjs` (lines 397, 430-434); `agent-session-service.mjs` (lines 650-663) reads `vendor` and an adhoc id. +- Regression, security, and performance: + - delete tests that check an old name is gone, starting with `packs_is_the_engine_global_and_the_old_plugins_global_is_nil` (`packs-tests.rs`, line 190), and delete the `tools.required()`/`tools.extras()` cases (`tests-offering.rs` lines 112-117, `packs-tests.rs` lines 172-180); + - the two-way globals tests pass with 62 reserved names; + - the render is deterministic: the same offers give the same list in every process. +- Exit criteria: + - the survey's full-suite test commands, linter commands with their companion gates (the facade API check for `public-api.txt` included), formatter check, and docs command pass, and the Workshop UI tests pass; + - these `rg` searches of `runvil/` and `runvil-docs/`, excluding `vibe/` and `target/`, print nothing: + - `(^|[^.\w])packs\.(get|offer|required|extras)\b` (the bare `packs` global; `tools.packs.get` does not match, and today no Rust variable does); + - `tools\.(required|extras)\b`, `offer_local`, `offer_pack`, `offer_extras`, `install_packs`, `install_tool_lists`; + - `--pcre2 'tools\.packs\.(?!get\b|extras\b)'`; + - `tools\.calls\.\w` and `tools\.call\(['"][^'"/]*['"]`, except where a test or the docs show `tools.call('grab')` raising; + - `rg -i "allow_tasks|task_cancel|task_status|await_tasks|task built-in" runvil-docs` prints nothing; + - `rg -i "model[ -]tasks?|task notices?|TaskNotice|Engine built-in|author[ -]tasks?|author-origin|model-origin" runvil-docs` prints nothing, and neither does `rg -i "\borigins?\b"` over `src/language/14-fanout.md`, `15-tasks.md`, and `17-quick-reference.md`; + - `rg -i "local[ _-]tool|lua-local|local[ _-]handler|LocalTool|LocalCall|local_call|::Local\b|LOCAL_HANDLERS" runvil/crates runvil-docs` prints only Lua `local` variable declarations (such as `local tool =`, `local tools =`, `local tool_ok`, `local tools_list`); + - a grep of the diff in both repos for "no longer", "used to", "previously", "formerly", "instead of", "migration", "deprecated", "old ", and "was renamed" leaves only text that describes current behavior; + - the docs anchor check passes, and every error string in the docs matches the code. + + + + +## Decision Record + +- Decisions: + - **One verb family on `tools`; the `packs` global goes.** Fewer globals and one place to offer. User: "I think there should only be one api to offer tools: `tools.offer_*(...)`" and "I want to have as few globals as possible." + - **`tools.offer` takes tools and Packs, telling ids apart by shape; there are no separate Pack verbs.** A slash always means a tool id, and a bare string can only be a Pack id now that local tools have ids. User: "I agree about accepting strings by shape in tools.offer and tools.offer_dynamic". + - **`tools.packs` has the methods `get` and `extras` and no name indexing; `get` takes no array.** Methods mirror `tools.get` and leave room for more members. An array `get` would have to leave holes, drop names silently, or raise. User: "Yes to packs methods: packs.get: "tools.packs.get('web')" and drop name indexing", "I agree about tools.packs.extras()", and "I agree about no array form for get." + - **Extras are a per-section opt-in, off by default.** User: "I reject the "extras" switch. Instead, extras should be a per-section setting. Disabled by default, and opt-in". + - **`tools.required()` and `tools.extras()` are deleted, and Pack objects gain `required`.** The tool lists cannot represent model-shaped Packs, and `required` keeps the one fact they carried. User: "I would like to get rid of required() and extras() if possible" and "I agree with the required boolean." + - **Offers are checked when made, stored lazily, and rendered in call order by appending and skipping duplicates.** The model may not be known at offer time, and line order decides placement. User: "the tool catalog has to be rendered just before we send the first inference API call", "check each offer when it is made", and ""a list that you append to" makes sense. Keeps the order." + - **Static wins over dynamic in either order.** User: "the tools that were already offered statically should stay static, and only the tools that were not offered statically should be offered dynamically." Order independence was proposed during planning and approved: "I agree. State this in the plan". + - **`tools.offer_dynamic` is a scaffold only.** User: "just adding the list, offer_dynamic, and nothing else. Not to connect it" and "Add the scaffold." + - **Local tools become adhoc tools: `tools.adhoc(attrs, f)` defines without offering, under the Pack `adhoc`, with a `ToolInfo`, in this plan.** + - Defining apart from offering gives one offer path, Lua-only helper tools, and a dynamic path later. + - The two-argument form keeps data apart from code and formats neatly. + - User: "remove offer_local. And instead have a function ... which creates the adhoc tool but does not offer it", "Yes, let's do adhoc to replace local", "yes update to the 2 arg form", and "NOT a new plan, put it in the SAME PLAN." + - **The adhoc Pack always exists.** `#tools.packs`, `ipairs`, and `get('adhoc')` stay stable all section long, matching usable Packs with no tools. User's question: "create the local pack when the first local tool is offered, or always have the pack (and just make it empty)?" "Always present" was proposed during planning and approved with the plan. + - **Every tool object has `parameters`.** User: "I think we need tool.parameters". + - **Tool and Pack metadata never arrives in the untrusted envelope.** The envelope marks tool results; a description or schema is declared metadata, and wrapping it would also break wrapping a catalog tool exactly. User: "when Lua asks for tool descriptions and such, it should never arrive in the untrusted envelope". + - **A script receives tool listings unwrapped, and a model's call to the same tool stays wrapped.** The user chose this in review for `mcp/get_dynamic_tools`, carrying the rule above to the MCP listing; the listing tool declares itself with `describes_tools`. + - **Only an adhoc call whose own function fetched a listing during that call returns untrusted.** It keeps an adhoc wrapper of `get_dynamic_tools` byte-identical to a direct model call, which is all the mark promises. A listing a script keeps (in a variable or the store), or gets back from a called section or a task, and later returns from an adhoc tool or puts in a message, reaches the model as the author wrote it, and that is the author's responsibility. The mark was proposed in review; the operator then chose this narrow claim over a run-wide mark, after review showed that caching, section calls, and tasks pass a listing around the mark. + - **Each tool has a Lua-facing id and a separate model-facing wire name, and offers store ids.** Built here for adhoc tools; catalog tools are deferred. User: "every tool should have the lua-facing name and the wire name (model-facing name)". + - **No author-chosen name ever meets a field.** User: ""tools.packs.web.tools" conflicts if web provides a "tools" tool" and "yeah add that". + - **A shaped Pack's `tools` never raises** (rule recorded for the deferred shaped Packs). User: "a shaped pack's `tools` field cannot raise, or else Lua cannot conveniently check if the pack has any tools." + - **No history.** User: "I want no migration, no supporting of the old way, dont ask me about it just silently drop support for it. do not leave tests which test the old behavior, do not leave comments which describe how it used to be." + - **`tools.adhoc` `parameters` takes the short form or a JSON Schema object, told apart by `type = 'object'`.** It lets a catalog tool be wrapped with `parameters = t.parameters` and keeps attrs at three keys. Chosen by the user in review, over a separate `schema` key and over dropping the wrap example. + - **The local-tool concept is renamed adhoc everywhere in the code**, internal protocol names and test file names included. Chosen by the user in review, over renaming only user-visible text. + - **Explicit `nil` and malformed arrays raise.** `tools.offer(nil)` raises today, and keeping that catches a mistyped `tools.get` id; gaps, stray keys, and nesting raise so no entry is silently lost. Proposed in review. + - **Events record each catalog tool's vendor, and the Workshop names its special tools by vendor-qualified name** (`runvil/user-input/ask`, `runvil/web/search`, `runvil/web/fetch`). A bare id can be taken by another vendor's Pack, and today's last-segment match would also catch an adhoc `ask`. The vendor comes from the Harness, which picked and called the Pack. User: "Workshop UI should always refer to the special tools (like user input) using the pack name and even the vendor name ("runvil/input/ask") since that can't be spoofed", then chose recording the vendor in events over a Workshop-only path. + - **`adhoc` has its own registry error**, because `ReservedName`'s message names a built-in Pack that does not exist. Proposed in review. + - **A caller-supplied `adhoc` Pack id is refused when the run is prepared, with its own notice line.** `RunContext` is public, so the registry and parser checks do not cover an embedder or a test that builds a catalog or roster by hand, and two meanings for `adhoc/*` would make `tools.get`, `tools.packs.get`, and the render ambiguous. `prepare` reports through `Requirements` because it returns a report, not a `Result`. Chosen by the operator in review, over dropping such entries silently or leaving the behavior undefined. + - **The docs commit removes `tools.allow_tasks` and the task built-ins it offered.** The code has neither, the plan rewrites the same headings, and the six-key `tools` table would contradict a members row for it. Chosen by the operator in review, over leaving those passages for a later edit. + - **The docs commit also removes task origin and corrects every other place where `runvil-docs` disagrees with the code.** The code has no task origin: a status table holds exactly `target`, `state`, `ok`, `section`, `blocked`, `turns`, `tasks`, `depth`, and `note`, `tasks.pending` takes no arguments, `TaskStarted` records no origin, and the `tasks_live` message reads `chain ended with tasks still live`. The guide also describes model tasks in passages the plan did not name, and the guide's build plan describes an older surface. Operator: "I want runvil-docs completely updated too, if it is not already in the plan. Fully updated, and committed." So step 4 removes or corrects all of it inside its one `runvil-docs` commit, over leaving the task-origin passages for a later edit. + - **Four commits: the rename, the green groundwork, the offer API with adhoc tools, and the docs.** The operator asked for few steps. The rename alone is mechanical, the groundwork (parameters, vendors, listings, reservations) works against today's code, and the offer API cannot split without a failing commit, so four is the smallest split that keeps each commit tested and reviewable. Chosen by the operator in review, over three steps with the rename folded in. +- Rejected alternatives: + - **A `schema` attrs key beside `parameters`:** one more name to learn, and the `type = 'object'` rule is already unambiguous. + - **Reading arrays until the first nil:** entries after a gap would be dropped without a word. + - **A Harness API only the Workshop reads, or a Workshop server that prefixes `runvil/`:** the first leaves run logs without the vendor, and the second breaks once the Workshop loads another vendor's Packs. + - **A new `OutputTrust` variant chosen per result for listings:** a Pack could vary it per call; the `describes_tools` declaration in `ToolInfo` is fixed and visible in the catalog. + - **No mark on adhoc calls that fetched a listing:** a wrapper of the listing tool would then hand the model raw server text marked trusted, unlike the direct call. + - **A run-wide mark that wraps every adhoc result once any listing has been fetched:** unrelated adhoc tools would change their result bytes mid-run. Revisit: if the listing rule must become a security property. + - **Dropping a caller-supplied `adhoc` Pack or tool silently, or leaving it undefined:** the first hides a caller's mistake, and the second leaves `adhoc/*` with two meanings. + - **The Engine deriving vendors from `uses:`** (a bare entry or an undeclared Pack means `runvil`): it would copy the Harness's pick rule, and the copy could drift from the Pack that actually answered. + - **Grouped render order** (single tools, then Packs, then local tools): a line's position should decide. Revisit: never. + - **Hash-ordered render:** the order varies per process, which breaks prompt-cache hits, replay, and tests. **Sorted render:** drops the author's control of order. Revisit: if authors stop caring about order. + - **Separate `tools.offer_pack`, `tools.offer_extras`, and their dynamic twins:** string shape plus objects make one verb enough. Revisit: if Pack ids and tool ids could ever share a shape. + - **Name indexing (`tools.packs.web`):** collides with methods and with author-chosen Pack names. Revisit: never while `tools.packs` has methods. + - **An error for unknown `tools.packs` keys:** it only serves old code, which the no-history rule forbids. + - **A front-matter `extras:` switch:** rejected by the user in favor of the per-section opt-in. + - **The Pack name `local`:** a Lua keyword, which reads badly as a Pack id in `tools.packs.get('local')`, in `adhoc`'s place in prose, and next to `local` declarations. + - **Creating the adhoc Pack lazily:** the Pack list would change mid-section. + - **A single table with `run` for `tools.adhoc`:** the two-argument form won. + - **`pack.tools` raising for shaped Packs:** Lua could not check emptiness without knowing which Packs are shaped. + - **Narrowing by looping over hints on tool objects instead of a filter argument:** a shaped Pack's tools cannot be offered by id. Revisit: never for shaped Packs. + - **Dotted ids (`web.search`):** `.` is legal inside a segment (`gh/issues.list`), it reads like Lua field access, and ids are persisted in records. Revisit: never. + - **Vendor-qualified tool ids (`runvil/web/search`):** ambiguous with ids of three or more segments. The vendor is already chosen once in `uses:`, and a run holds one Pack per Pack id. Events record the vendor in a field of its own, so ids in Lua stay unqualified. Revisit: if a run could hold two Packs with the same id. + - **Exposing wire names to Lua:** the Engine may rename them, and prose that names them goes stale. + - **Connecting dynamic offers now:** low payoff until MCP servers' tools are catalog tools, and the listing format is unmeasured. Moved to Deferred. +- Assumptions, risks, and notes: + - **Raw array reads.** mlua's `sequence_values` reads arrays raw and skips metamethods, so the offer decode reads `len()` (which honors `__len`) and then `get(1..=n)` (which honors `__index`), and checks raw keys separately for the stray-key rule. Otherwise `tools.offer(tools.packs)` sees an empty list and `tools.offer` silently offers nothing. + - **`lua.to_value` is `json.decode`.** `json.decode` calls `lua.to_value` with default options, and `json.encode` recognizes its array metatable and `lua.null()`, so `tool.parameters` round-trips `[]` and `json.null`, and a JSON Schema `parameters` converts back the same way. + - **`__pairs` under the Engine's `pairs`.** The Engine replaces `pairs` (`lua/src/iteration.rs`, lines 117-141), and the replacement returns a `__pairs` metamethod's results; the `pairs` test confirms the order. + - **`tools.calls` timing.** It is installed at a section's first tool call (`engine/src/execute/section_context.rs`, `install_section_scope`), so it is absent at the top of a section. + - **The Workshop chat agent's order.** It renders `web` first, then the extras' tools. No Workshop test asserts the order. + - **Line numbers are hints** from today's tree. The exit-criteria greps are the real check. + - **Short paths.** + - `lua/...`, `engine/...`, `types/...`, and `parser/...` are under `runvil/crates/runvil-internal/`. + - `harness-internal/...`, `workshop/...`, `runvil-plugin/...`, `plugin-*/...`, and `runvil/tests/...` are under `runvil/crates/`. + - Docs paths such as `src/language/12-tools.md` are under `runvil-docs/`. + +### Deferred and Out of Scope + +- Deferred: + - **Filter.** It is a second argument to `tools.offer` on Pack entries, such as `tools.offer('core', { readOnlyHint = true })`, plus a `hints` key in `tools.adhoc` attrs. + - Keys are combined by AND. + - Hints are advisory, untrusted when they come from MCP servers, and never a safety boundary. + - A hint the tool leaves unset never matches, so the filter fails closed. + - The filter is stored on `Offer` and applied at render. + - It is required for shaped Packs. + - Revisit: when `ToolInfo` gains hint annotations. + - **Two names for catalog tools.** `ToolInfo` gains a Pack-proposed model-facing name. + - Do not call the field `wire_name`. An old serialized `ToolInfo` carries `"wire_name":"fetch"` (`runvil-internal/types/src/tools/tests.rs`, line 139), and old records would feed that value in. + - The registry validates the name at load: letters, digits, `_` and `-`, at most 64 characters, unique within the Pack. A tool with no declared name keeps the name derived from its id, capped at 64 characters with an 8-character hash. + - A cross-Pack wire collision renames the loser with a suffix from a hash of its id, reported in the run record. Today the later tool is silently dropped (`runvil-internal/engine/src/execute/context-bound.rs`). + - An id collision after normalization, such as MCP `GetPaper` and `getpaper`, is refused naming both. + - The wire-to-id map is recorded at first render. + - An offered adhoc tool still shadows rather than being renamed. + - Revisit: together with the filter annotations, so the plugin and wire path changes once. + - **Connecting dynamic offers.** + - Add two Engine-owned discovery tools, shaped like the `mcp` Pack's `get_dynamic_tools` and `call_dynamic_tool` (`runvil/crates/plugin-mcp/src/dynamic.rs`), only when the dynamic list is non-empty. + - The listing goes in the discovery tool's description, capped. + - A third route in `tool_route` re-dispatches `call_dynamic_tool` to a catalog effect or an adhoc handler under the model's call id, and stops a batch for an adhoc target. + - A dynamic call records and counts as its target tool. + - A static tool requested through discovery is refused, naming it. + - The discovery wire names are reserved. + - Revisit: when MCP servers' tools become catalog tools. + - **Shaped Packs (core file tools, subagents).** + - They render per model family at the first loop round, after the model is resolved. + - `pack.tools` returns canonical tool objects. + - An offer of a shaped tool by id or object raises on its line. + - `extras()` and dynamic offers never include them, and narrowing is by filter only. + - Revisit: when the first shaped Pack exists. + - **Structured output from adhoc tools.** A function returning a table fails. No catalog tool has structured output today, so every catalog tool can be wrapped; a structured one would hand its wrapper a table that the wrapper cannot return. Revisit: when a prompt needs it, or when the first catalog tool with structured output exists. +- Out of scope: + - the vendored `wg21-paperflow/vendor/runvil` copy; + - the historical `vibe/` plans, except the one note in the Execution Instructions; + - the `runvil-design` research reports. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked -p gateway` (the workspace's only default member, so plain `cargo build` builds just the gateway). The desktop app is opt-in: `cargo build --locked -p workshop`, or `cargo workshop` to build the gateway, stage it as the Tauri sidecar, and build Workshop. Cargo build scripts bundle the UIs, so run `npm ci --prefix crates/workshop` and `npm ci --prefix crates/gateway/config-ui/ui` once first. +- Focused test command pattern: `cargo nextest run --locked -p --all-features `, adding `--test suite` or `--test it` to target a crate's integration binary, or `-E 'test(//)'` for a filter expression. For `workshop`, `workshop-server`, and `workshop-server-api`, drop `--all-features`. Ignored native tests run with `--run-ignored only`. +- Component test command pattern: `cargo nextest run --locked -p --all-features` (no `--all-features` for `workshop`, `workshop-server`, `workshop-server-api`). For a UI package: `npm test --workspace ` from `crates/workshop`, or `npm test` from `crates/gateway/config-ui/ui`. For root Node tools: `node --test tools/.test.mjs`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. The workspace run includes `build-xtask`, which holds the product-boundary and structural checks. UI half: `npm test --workspaces --if-present` from `crates/workshop` and `npm test` from `crates/gateway/config-ui/ui`. +- Linter command: `CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, then `CARGO_BUILD_WARNINGS=deny cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`. Companion gates: `cargo check -p gateway --no-default-features` (the one allowed standalone check), `node --test crates/workshop/ui/test/docs-claims.mjs` (wording scan), `cargo deny check`, `npm run typecheck --workspaces --if-present` from `crates/workshop`, and `cargo + xtask api --check` for the facade surface (nightly named in `crates/build-xtask/src/api/toolchain.rs`; run with `CARGO_BUILD_WARNINGS` unset). +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`; also the pre-commit hook). +- Docs command: `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, plus `RUSTDOCFLAGS="-D warnings" cargo doc -p runvil --no-deps` for the facade at default features. CI also builds `cargo doc -p harness --no-deps` and private-item docs for `runvil-engine` and `workshop-server`. +- Test placement and naming conventions: + - Unit tests sit in `#[cfg(test)]` modules, usually split into a sibling `foo-tests.rs` wired with `#[cfg(test)] #[path = "foo-tests.rs"] mod tests;`. When a module is a directory, its tests go in `foo/tests.rs` or a `src/.../tests/` subdirectory once there are three or more test files (for example `crates/gateway/config/src/config/tests/`). + - Integration tests are one binary per crate at `tests/suite/main.rs` (`runvil`, `harness`) or `tests/it/main.rs` (`gateway/app`, `harness-internal/runner`, `build-ui`, `gateway-api-discovery`) with one submodule per topic. A few crates use a single `tests/.rs`. There are no custom `[[test]]` targets. + - Prompt fixtures are kebab-case Markdown under `tests/prompts/valid/`, `tests/prompts/invalid/`, and `tests/prompts/execution/`. + - Test functions are snake_case behavior sentences, such as `a_policy_refuses_an_undeclared_path_before_the_watcher_sees_it`. + - Test helpers live in `test_support.rs`, `*-test-support.rs`, or `support.rs` inside a test tree. `build-xtask` checks that test support does not leak into non-test code. + - Engine tests never sleep, read the wall clock, wait on timeouts, or draw OS entropy. Random input comes from a fixed seed that the failure prints, and temp directory names come from the test name, process id, and a counter. + - UI tests use Node's built-in runner: `test/**/*.mjs` and `src/**/*.test.mjs`. +- Directory map: + - `crates/`: every Rust crate plus the TypeScript UI packages. Public and shared crates sit at the top level; each product family keeps its private crates in a manifestless container (`runvil-internal/`, `harness-internal/`, `gateway/`, `workshop/`). + - `vibe/`: dated plan and design notes, filed by month (`vibe/2026-10/`) with the newest loose at the top. + - `guide/`: mdBook configs for the gateway, language, and Workshop books, plus page chrome and the landing page for the docs site. + - `prompts/`: example prompt files. + - `tools/`: Node scripts for live gateway TTS testing and gateway sidecar staging, each with a `.test.mjs`. + - `images/`: README banners. + - `.github/workflows/`: CI (`ci.yml`), nightly, releases, the docs site, STT miri, the whisper library, and installer smoke tests. + - `.githooks/`: pre-commit (fmt check) and pre-push (wording scan, headless gateway check, clippy, cargo deny). + - `.cargo/config.toml`: the `cargo workshop` and `cargo xtask` aliases, plus rust-lld and the static CRT on Windows MSVC. `.config/nextest.toml`: nextest profiles and the heavy STT test group. + - `.cursor/rules/`: Workshop architecture and SPA rules. `AGENTS.md`: definitions, principles, verification gates, structural rules. + - Root config: `Cargo.toml`, `clippy.toml`, `deny.toml`, `dist-workspace.toml`, `rustfmt.toml`, `rust-toolchain.toml` (stable), `gateway.local.example.toml`. + - `local/` and `target/` are untracked (local configs, private prompts, build output). +- Component boundaries: + - Engine: public `runvil` (facade) and `runvil-plugin`; private `runvil-internal/{types,engine,lua,parser,vfs,model-client}`. Depends on no gateway, Workshop, or Harness crate, and never on `tokio`, `tokio-util`, `async-trait`, `reqwest`, `getrandom`, or `fastrand`, even transitively. + - Harness: public `harness` and `harness-gateway-client`; private `harness-internal/{runner,plugin-loader,test-plugin}`. Outside its family it may name only `runvil` and `runvil-plugin`. + - Gateway: private `gateway/*` (app, config, config-ui, local, protocol, routing, cloud-providers, logging, progress, web-search) with the nested `gateway/stt/` subsystem, whose public member is `gateway-stt`. Public `gateway-api-types` and `gateway-api-discovery` at the top level. Depends on no Engine, Harness, or Workshop crate. + - Workshop: `workshop/*`. Reaches the gateway only through `gateway-api-types` and `gateway-api-discovery`. Internally one-way: server, then features, then services, then vocabulary. Same-tier crates meet only through `workshop-protocol` and `workshop-registry` slots, except that `workshop-registry` depends on `workshop-protocol`. The desktop app `workshop` depends only on `workshop-server-api`, which depends only on `workshop-server`. + - Plugins (`plugin-mcp`, `plugin-user-input`, `plugin-web`): depend only on `runvil-plugin`, `shared-*`, and outside libraries. Engine, gateway, and Harness crates never depend on them, except `harness-gateway-client`, which implements `plugin-web`'s `SearchProvider`. + - Shared (`shared-error-source`, `shared-loopback`; `shared-ui` is a TypeScript package): depend on no product crate. + - Build (`build-ceiling`, `build-llama-cuda`, `build-ui`, `build-user-guide`, `build-workshop`, `build-xtask`): bound by the same public-crate rule. + - Across families, crates go only through each family's public crates; containers are private. `cargo test -p build-xtask` enforces all of this. +- Conventions summary: + - Rust 2024 edition on stable, MSRV 1.95 for the Engine crates, BSL-1.0. + - Every crate inherits strict workspace lints: clippy `all` and `pedantic` deny, `unwrap_used` and `expect_used` deny, `allow` attributes banned in favor of reasoned `expect`, `missing_docs` and `unreachable_pub` on, and `unsafe_code` denied outside its allowlisted owned boundary. + - No doctests: doc comments hold no compiled code blocks, only `text`, `json`, or `toml` fences. + - Source directories stay flat until a group reaches three files. Smaller groups are kebab siblings (`foo-bar.rs`) wired with `#[path]`, and these convert in both directions as groups grow or shrink. + - `lib.rs` in Workshop crates is a facade only, opening with a `//!` doc that carries an `## Invariants` marker listing allowed and forbidden dependencies. Workshop subsystems self-register into `workshop-registry` slots from the composition root in `crates/workshop/server/src/app/compose.rs`. + - Engine, Harness, Host, Pack, and Plugin are capitalized defined terms with one meaning each. Engine crates say "the caller" and never mention the Host. The docs-claims scan enforces this. + - Error and status messages are written for a model reader: concise and self-contained, naming required versus actual. + - Comments explain only non-obvious constraints. Workarounds cite an upstream issue URL, and `Cargo.toml` comments justify each version pin and feature choice. + - Behavior changes ship with tests in the same change. Cargo features gate only real constraints such as toolchains or heavy native builds. + - JSON that reaches a recorder round-trips exactly, with sorted keys and finite numbers (`serde_json` `float_roundtrip`). + - The design rule is to reuse an existing facility first, then make the smallest improvement to one, and only then add new machinery. + - Workshop UI CSS uses design tokens only, and every persisted UI value goes through the `ui-storage` adapter to the server. + + + + +## Execution Instructions + + + +### Step 1: Rename local tools to adhoc [completed] + +- Component: adhoc naming +- Tests: run in PowerShell from `c:\Users\Vinnie\cursor\runvil`, in this order, each line as written; every one must exit 0 except the last, whose `rg` must print only Lua `local` variable declarations (such as `local tool =`, `local tools =`, `local tool_ok`, `local tools_list`): + - `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types -p runvil-parser --all-features` + - `cargo fmt --all --check` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `Push-Location crates/workshop; npm test --workspaces --if-present; Pop-Location` + - `Push-Location crates/gateway/config-ui/ui; npm test; Pop-Location` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; rg -n -i 'local[ _-]tool|lua-local|local[ _-]handler|LocalTool|LocalCall|local_call|::Local\b|LOCAL_HANDLERS' crates -g '!crates/gateway/**' -g '!crates/build-xtask/**'` +- Piece: the whole rename, built jointly in one commit, because the renamed names cross crate lines (the Lua crate's protocol types, its coroutine op, and its registry key are read by the Engine and by `__impl_coro.lua`), so a partial rename does not compile. +- Placement: first, because steps 2 and 3 write code against the adhoc names (`AdhocCall`, `adhoc_calls`, `dispatch_adhoc_tool_done`), and a rename with no behavior change stays reviewable only when nothing else rides with it. + +Work in `c:\Users\Vinnie\cursor\runvil` on branch `master`, HEAD `890b41faa` when planned. Line numbers are hints from that tree; the rename grep is the real check. Paths are under `crates/runvil-internal/` unless they start with `crates/`. The commit message title is "Rename local tools to adhoc", with the executor's normal trailers. Do not push in this step. + +- Rename every identifier, string, comment, and file name that names a local tool to adhoc: + - types and variants: `LocalTools` to `AdhocTools` (it stays in `lua/src/vm.rs`; step 3 moves it), `ToolRoute::Local` to `ToolRoute::Adhoc`, `ToolCallOutcome::Local` to `ToolCallOutcome::Adhoc`, `LocalToolOutcome` to `AdhocToolOutcome`, `Input::Local` (`lua/src/models_loop/machine.rs` line 115, built in `lua/src/models_loop.rs` line 307) to `Input::Adhoc`, `LocalCall` to `AdhocCall`, and `Request::LocalToolDone` to `Request::AdhocToolDone`; + - functions, fields, and variables: `has_local_tool` and `local_tool_schemas` to `has_adhoc_tool` and `adhoc_tool_schemas`; `push_local_call`, `pop_local_call`, and `local_calls` to `push_adhoc_call`, `pop_adhoc_call`, and `adhoc_calls`; `local_handler_depth`, `enter_local_handler`, and `leave_local_handler` to `adhoc_handler_depth`, `enter_adhoc_handler`, and `leave_adhoc_handler`; the `local_handler` function in `lua/src/tools.rs` (line 71) to `adhoc_handler`; the `local_schemas` parameter of `prepare_scoped_tools` and `prepare_effective_scope` (`engine/src/execute/scope.rs`) to `adhoc_schemas`; `parse_local_tool_done` to `parse_adhoc_tool_done`; `dispatch_local_tool_done` to `dispatch_adhoc_tool_done`; the shim's `run_local_tool` to `run_adhoc_tool`; and variables such as `local_tools` to `adhoc_tools`; + - strings: the `local_tool_done` coroutine op to `adhoc_tool_done` on both sides (`lua/src/protocol/` and `lua/src/__impl_coro.lua`), and `LOCAL_HANDLERS_REGISTRY` with its key `runvil.tools.local_handlers` to `ADHOC_HANDLERS_REGISTRY` with `runvil.tools.adhoc_handlers`; + - test function and helper names that say local tool or local handler; + - comments and doc comments that say local tool, Lua-local tool, local handler, or local alias for a local tool's alias, such as `types/src/metrics.rs` line 31, `types/src/event.rs` line 405, and `types/src/emitter.rs` line 355 (`None` for an adhoc tool), `lua/src/scope.rs` lines 101 and 106, `lua/src/alias.rs` line 8, `lua/src/coro.rs` line 17, `lua/src/tools.rs` line 168, `lua/src/vm.rs` line 7, `parser/src/contract/models.rs` line 87, and `crates/workshop/ui/src/services/agent-session.ts` line 57 (null for an adhoc tool). "Prompt-local alias" (`lua/src/alias.rs` lines 1 and 6, `lua/src/models-userdata.rs` line 34) names the alias grammar's scope, not a local tool, and stays; + - files: `git mv` `engine/src/execute/tests/local_tools.rs` to `adhoc_tools.rs` and `engine/src/execute/tests/tool_call_arm-local-handlers.rs` to `tool_call_arm-adhoc-handlers.rs`, and update their `mod` and `#[path]` lines. +- The jump refusal in `lua/src/vm/install.rs` (line 208) becomes exactly `jump is unavailable inside an adhoc tool handler: return a value from the handler and call jump from the block after the tool call returns`. The `find` string in `engine/src/lua/tests/shims.rs` (line 197) and `JUMP_REFUSAL` in the renamed `adhoc_tools.rs` (line 194) become `jump is unavailable inside an adhoc tool handler`. The library-load refusal just below it is unchanged. +- Files with hits today: + - `lua/src/`: `vm.rs`, `coro.rs`, `lib.rs`, `scope.rs`, `alias.rs`, `tools.rs`, `__impl_coro.lua`, `__impl_globals.lua`, `vm/inject.rs`, `vm/state.rs`, `vm/install.rs`, `protocol.rs`, `protocol/answer.rs`, `protocol/request.rs`, `protocol/parse.rs`, `protocol/parse/tools.rs`, `protocol/render.rs`, `models_loop.rs`, `models_loop/machine.rs`, and the tests `models_loop/tests.rs`, `models_loop/machine-tests.rs`, `protocol/tests/answer.rs`, `protocol/tests/parse.rs`, `tests/diagnostics.rs`, `tools/tests-offering.rs`, `messages-tests.rs`, `packs-tests.rs`; + - `engine/src/`: `error.rs`, `lua/tests/shims.rs`, and under `execute/`: `protocol.rs`, `scope.rs`, `section_context.rs`, `section_context-construct.rs`, `tests.rs`, `scheduler/chat.rs`, `scheduler/tool_call.rs`, `scheduler/dispatch.rs`, `scheduler/chain_record.rs`, `run/tests-drops.rs`, and the tests `local_tools.rs`, `tool_call_arm.rs`, `tool_call_arm-local-handlers.rs`, `offering.rs`, `models_loop.rs`, `models_loop_contract-rounds.rs`, `chat_scope.rs`, `debug_and_counts.rs`, `batch_turn.rs`, `parallel_tool_calls.rs`, `scheduler/live_h1/control_flow.rs`; + - `types/src/`: `emitter.rs`, `metrics.rs`, `event.rs`; `parser/src/contract/models.rs`; `crates/workshop/ui/src/services/agent-session.ts`. +- Keep, because step 3 deletes them: `tools.offer_local`, `install_offer_local`, `local_params_schema`, every error that begins `tools.offer_local` (`lua/src/tools.rs` lines 269 and 278, `lua/src/tools/decode.rs` lines 106 and 112), the `tools.call` type error `tools.call takes a tool id, a local alias, or a tool object, got ` (`decode.rs` line 28), and every Lua script in a test that calls `tools.offer_local`. A test name keeps its `offer_local` part and renames the rest: `offer_local_registers_a_local_tool_and_names_itself_in_its_errors` becomes `offer_local_registers_an_adhoc_tool_and_names_itself_in_its_errors`. Lua `local` declarations are variables, not this concept, and stay. +- The rename grep leaves out `crates/gateway` and `crates/build-xtask`. Over all of `crates`, its case-insensitive `::Local\b` also matches the gateway's `crate::local::` module paths and `DominionKind::Local`, and the `runvil::Local` fixture type in `crates/build-xtask/src/api/listing/compact-tests.rs`; none of them names a local tool, and this plan touches none of them. `crates/harness-internal/runner/tests/it/harness-stop.rs` line 40 is a Lua declaration and stays. +- Nothing else changes: no behavior, no Lua-visible name, no event shape, and no error text other than the jump refusal. Doc comments describe the code as it is, and no comment or test says that a name changed. Every `.rs`, `.md`, `.lua`, `.toml`, and `.txt` line under `crates/runvil` and `crates/runvil-internal`, comments and test strings included, passes the docs-claims scan: no "host", "workshop", or "papergate" in any case, no lowercase "harness" (except "harness = false" and "test harness"), no "Harness-", and no "RunHarness". No Rust file passes 500 lines. +- Acceptance: the rename grep prints only Lua `local` declarations; the jump refusal reads as above and both of its assertions pass; every existing test passes with only names changed; every Tests command passes. + + + + + +### Step 2: Parameters, vendors in events, tool listings, and the adhoc reservation [completed] + +- Component: tool metadata and provenance +- Tests: run in PowerShell from `c:\Users\Vinnie\cursor\runvil`, in this order, each line as written; every one must exit 0, and `xtask api --check` must report no change: + - `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types -p runvil-parser -p runvil-plugin -p plugin-mcp -p harness-runner -p runvil --all-features` + - `cargo nextest run --locked -p workshop-server` + - `cargo fmt --all --check` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `Push-Location crates/workshop; npm test --workspaces --if-present; Pop-Location` + - `Push-Location crates/workshop; npm run typecheck --workspaces --if-present; Pop-Location` + - `Push-Location crates/gateway/config-ui/ui; npm test; Pop-Location` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + - `cargo check -p gateway --no-default-features` + - `cargo deny check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc -p runvil --no-deps` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc -p harness --no-deps` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps -p workshop-server --document-private-items` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +1.95 build --locked -p runvil -p runvil-engine -p runvil-lua -p runvil-parser -p runvil-types -p runvil-vfs -p runvil-model-client` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check` +- Piece: four pieces, `tool.parameters`, vendors in events, tool listings, and the `adhoc` reservation, built jointly in this one commit because the operator chose four commits for the plan (Decision Record). Inside the step, `tool.parameters` shares no file with the others and can go to its own worker. Vendors come first among the other three. Tool listings follow vendors, because both change `ToolBinding` and `lua/src/dispatch.rs`, and the reservation follows vendors, because both change `engine/src/execute/config.rs` and `requirements.rs`. +- Placement: after step 1, whose names (`AdhocCall`, `adhoc_calls`, `dispatch_adhoc_tool_done`) the listing mark uses, and before step 3, which needs `tool.parameters` for adhoc tool objects, `ToolBinding::with_vendor` and `Advertised` for adhoc bindings that have no vendor, `describes_tools` (adhoc bindings never set it), and the reservations (so the adhoc Pack id has one meaning). + +Work in `c:\Users\Vinnie\cursor\runvil` on branch `master`, on top of step 1's commit. Line numbers are hints from `890b41faa`. Paths are under `crates/runvil-internal/` unless they start with `crates/`. The commit message title is "Add tool parameters, event vendors, and listing trust", with the executor's normal trailers. Do not push in this step. + +Every piece works against step 1's code: adhoc tools are still defined with `tools.offer_local(alias, description, params, handler)`, have no id, and appear in events with `tool` absent. Tests that need an adhoc tool define it that way; step 3 rewrites them. + +- **`tool.parameters`:** + - `LuaToolHandle` (`lua/src/tools/userdata.rs`) keeps the binding's parameter schema and serves `parameters`. Every read converts the schema with `lua.to_value` under default options, the conversion `json.decode` uses, into a fresh Lua table: lists carry the array marker, so `json.encode` gives back `[]`, and nulls read `json.null`. Like `tool.description`, it is never in the untrusted envelope: every string in it is the schema's text as declared. Assigning it raises the way assigning any tool-object field does. Tool objects have exactly `id`, `description`, `parameters`, and `pack`. + - Tests, for catalog tools (adhoc cases come in step 3), in a new `lua/src/tools/tests-parameters.rs` wired the way `tests-offering.rs` is: `parameters` deep-equals the catalog tool's schema; each read is a fresh table (mutating one read leaves the next unchanged); it round-trips through `json.encode` with `[]` intact; assigning it raises. No envelope on metadata: for a test Pack whose results are untrusted, `tool.description`, every string in `tool.parameters`, and the Pack's `name` (read through today's `packs.get`) equal the `ToolInfo` text exactly and never contain the run's nonce or the envelope's preface. Put this case in an Engine test if the run's nonce is needed. +- **Vendors in events:** + - Meaning: every tool-call and tool-result event for a catalog tool records `vendor`, the vendor of the Pack that answered, as the caller picked it: `runvil` for a bare `uses:` entry and for an undeclared Pack, `acme` for an `acme/kit` entry. `vendor` and `tool` together give the vendor-qualified name, such as `runvil/user-input/ask`. A vendor is one segment and always comes first, so a reader joins them only when `vendor` is present. A tool id can have three segments, so a bare `tool` such as `runvil/user-input/ask`, offered by a Pack whose id is `runvil`, is never read as a vendor-qualified name. Adhoc tools and names outside the round's scope record no vendor, and neither does a run given no roster. Lua never sees vendors, and tool ids are unchanged. A log written before this change has no `vendor`, which reads as absent, and nothing treats such a log as naming a `runvil` tool. + - Types: `ToolCallEvent` (`types/src/metrics.rs`) and `Event::ToolResult` (`types/src/event.rs`) gain `vendor: Option` with `tool`'s serde attributes (`default`, `skip_serializing_if = "Option::is_none"`); `ToolCallEvent` gains `with_vendor`; `Emitter::tool_result` (`types/src/emitter.rs`) takes the vendor. + - Engine: `#[non_exhaustive] pub struct UsablePack { pub pack: PackId, pub vendor: String }` with `UsablePack::new(pack, vendor)` in `engine/src/execute/requirements.rs` beside `UnavailablePack`, exported from the `runvil` facade the same way. `RunContext::packs` (`engine/src/execute/config.rs`) takes `UsablePack` in place of a bare `PackId`, and the Engine keeps the vendors beside the roster. `ToolBinding` gains `vendor: Option`, set with `#[must_use] pub fn with_vendor(self, vendor: Option) -> Self` and read with `ToolBinding::vendor()`; `ToolBinding::from_info` leaves it `None`, so adhoc bindings have none. `engine/src/execute/context-bound.rs` stamps each catalog binding with its Pack's vendor. Doc comments on `UsablePack`, `RunContext::packs`, and `ToolBinding::vendor` say the caller reports each Pack's vendor; Engine crates never name the Harness or the Host. + - Advertised map: `pub struct Advertised { pub id: ToolId, pub vendor: Option }` in `lua/src/scope.rs`. `RenderedTools.dispatch` changes from `BTreeMap>` to `BTreeMap>`, still `None` for an adhoc tool, and `Chain.advertised` (`engine/src/execute/scheduler/chain_record.rs` line 275) and the `advertised` parameter in `scheduler/chat.rs` (line 341) hold the same map type. It keeps serving the round's scope gate and the `ToolCallEvent` stamping and never routes. The `RenderedTools` struct literals in `lua/src/compactors-tests.rs` (line 176), `lua/src/tools/tests-offering.rs` (line 197), and `lua/src/messages-tests.rs` (line 50) take `Advertised` dispatch values. + - Emission: `tool_calls` in `scheduler/chat.rs` (lines 347-349) sets the id and vendor from the advertised map on each `ToolCallEvent`; `prepare_dispatch` and `prepare_model_dispatch` (`lua/src/dispatch.rs`) pass `binding.vendor()` to `Emitter::tool_result`. + - Harness: `HostRunContext::roster` (`crates/harness-internal/runner/src/host-run.rs`, lines 110-130) reports each usable Pack with the vendor from its `PackInfo` FQN, and `prepare_from_snapshot` (`crates/harness-internal/runner/src/prepare.rs`, lines 362-371) passes it to `RunContext::packs`. + - Workshop server: `crates/workshop/server/src/agents.rs` (lines 145-158) holds the ask as vendor `runvil` plus id `user-input/ask`; `socket.rs` and `socket_frames.rs` hold that pair; `agents/wire.rs` (line 275) frames a script's result as the operator's message only when both vendor and id match. `render_tool_calls` serializes `ToolCallEvent` as is, so the UI receives `vendor` with no wire change. + - Workshop UI: `toolKind` in `crates/workshop/ui/src/parts/agent/transcript/tool-labels.ts` takes the vendor-qualified name (`vendor/tool`) and matches `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch` exactly, so no tool without the `runvil` vendor is ever special. Labels stay ` in `, read from `tool`. `transcript-model.ts` (line 334) passes `vendor === null ? null : vendor + "/" + tool` to `toolKind`, so a call without a vendor is never special. `crates/workshop/ui/src/services/agent-session.ts` (lines 55-61, 174-176) parses and documents `vendor`. + - Other readers: `crates/runvil/tests/suite/event.rs` (lines 75-77) prints the vendor-qualified name. `engine/src/test_support/recording/forward.rs` is unchanged. + - Tests: + - Harness (`crates/harness-internal/runner/src/host-tests.rs`, whose fixtures install both `acme/kit` and `runvil/kit`): a run whose `uses:` names `acme/kit` hands the Engine `acme` for `kit` and `runvil` for every undeclared Pack. + - Engine: a model call and a script call to a catalog tool record its vendor on `AssistantToolCalls` and `ToolResult`, and an adhoc call records none. + - Workshop server (`agents/wire-tests.rs`, `agents/tests.rs` line 174): a script's `user-input/ask` result with vendor `runvil` frames as the operator's message; the same id with vendor `acme`, or with no vendor, stays a tool result. + - Workshop UI (`crates/workshop/ui/test/`): `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch` are `ask`, `search`, and `fetch`; `acme/user-input/ask`, `acme/web/search`, and `adhoc/ask` are `other`; a vendorless call whose tool id is `runvil/user-input/ask` is `other`. The `toolKind` inputs on `test/transcript-model.mjs` line 397 become vendor-qualified names; step 3 replaces the null-tool label cases (lines 430-434). `test/agent-session-service.mjs` (lines 650-663) reads `vendor`. +- **Tool listings:** + - Meaning: a catalog tool whose `ToolInfo` sets `describes_tools`, today only the `mcp` Pack's `get_dynamic_tools`, returns its listing to a script's `tools.call` as plain text, while a model's call to it still receives the untrusted envelope. The script call's result event keeps `trusted` false, since the text comes from the servers, and holds the text the script received. An adhoc call whose own function fetched such a listing with `tools.call` while that call was open returns untrusted: its result is wrapped with the run's nonce and its event has `trusted` false. So, for a call the listing tool answers successfully, an adhoc wrapper of `get_dynamic_tools` gives the model the same envelope, byte for byte, as a direct model call, and that is the whole guarantee. A listing a script keeps (in a variable or the store), or gets back from a called section or a task, and later returns from an adhoc tool or puts in a message, reaches the model as the author wrote it: unwrapped, and with `trusted` true when an adhoc tool returns it. + - `ToolInfo` (`types/src/tools/info.rs`) gains `describes_tools: bool` (`#[serde(default)]`, set with `.describes_tools(true)` the way `survives_stop` is), set on `get_dynamic_tools` in `crates/plugin-mcp/src/dynamic.rs` (lines 137-153). `ToolBinding` copies the flag. + - `prepare_dispatch` (`lua/src/dispatch.rs`, lines 132-144) passes the text verbatim when the call is a script's (`script` is `Some`) and the binding describes tools, keeping the trust `Untrusted`; `prepare_model_dispatch` is unchanged. `ToolDispatch` gains `pub fn listing(&self) -> bool`, true only when that rule passed untrusted text verbatim; a `describes_tools` tool that returns trusted output is passed as any trusted output and sets nothing. + - `accept_tool_call` (`engine/src/execute/scheduler/apply.rs`, line 189) takes `&mut self`, and for a `CallSite::Script` whose dispatch reports `listing()`, sets `listing = true` on the current frame's innermost open `AdhocCall` (`engine/src/execute/section_context.rs`; `listing` is a new `bool` field). A failed call marks nothing. `dispatch_adhoc_tool_done` (`scheduler/tool_call.rs`, line 395) wraps a marked call's result with the run's nonce and reports it untrusted. A wrapped result handed to an enclosing adhoc function is already enveloped, so only the innermost call needs the mark. The mark is per frame and per open call by design: a listing fetched outside the adhoc call, in a section the adhoc function calls, or in a task marks nothing. + - Tests, with a test tool that sets `describes_tools` and returns untrusted text (`lua/src/dispatch-tests.rs` for the rule, Engine tests for the paths; a model call to an adhoc tool needs a rendered section, so drive it through a scripted model round): + - a script's call returns the text without the envelope, and its result event has `trusted` false and that text; + - a model's call returns the envelope; + - for a call the tool answers successfully, a model call to an adhoc wrapper of it returns the same envelope byte for byte, with `trusted` false; + - an adhoc function that calls it and returns its own text returns wrapped and untrusted; + - a script call outside any adhoc call marks nothing, and a call to a tool without the flag is wrapped for a script; + - the mark's scope: a listing kept from a call made outside the adhoc call and returned by it comes back unwrapped, with `trusted` true on the adhoc result event; + - a failed listing call marks nothing, and a `describes_tools` tool that returns trusted output sets no mark; + - `plugin-mcp`: `get_dynamic_tools` sets `describes_tools` and `call_dynamic_tool` does not. +- **The `adhoc` reservation:** + - `crates/runvil-plugin/src/registry.rs`: a new variant `InstallError::AdhocPackId { fqn: &'static str }`, documented and shaped like `InvalidFqn` and `DuplicatePack`, refuses a Pack whose id is `adhoc` from any vendor with ``Pack `` cannot take the Pack id adhoc, which holds a section's own tools``. `reserves` and `ReservedName` cover only built-in Packs and stay as they are, because `ReservedName`'s message names a built-in `runvil/` that does not exist for `adhoc`. + - `parser/src/contract.rs`: a new variant `PackListError::Adhoc`, whose `#[error]` text is `invalid frontmatter: adhoc holds a section's own tools and is never declared under uses` (the prefix lives in the text, as in `Duplicate`'s), checked beside `check_distinct_packs`, refuses a `uses:` entry for `adhoc` or `/adhoc`; `parser/src/parse.rs` (lines 101-105) gains its match arm, a `Frontmatter` parse error built like `Duplicate`'s. `PackRef` lives in `types/src/packs.rs`. + - `RunContext::prepare` (`engine/src/execute/config.rs`, line 326) checks the catalog (each `ToolInfo` id's `ToolId::pack()`) and the roster (usable and unavailable) for the Pack id `adhoc`. `prepare` returns a report, not a `Result`, so the refusal goes through it: `Requirements` (`engine/src/execute/requirements.rs`) gains `pub reserved_adhoc: bool`, set by that check, merged with `|=`, counted by `is_satisfied`, and written by `notice` as the line `- the run's tool catalog or Pack roster names the Pack id adhoc, which holds a section's own tools`. `refusal()` then returns the usual `RequirementsUnmet` run error. + - Tests: a plugin registry test refuses `acme/adhoc` with the new variant's message; a parser test checks that `uses: [adhoc]` and `uses: [acme/adhoc]` fail to parse with kind `Frontmatter` and the message above; an Engine test in a new `engine/src/execute/requirements-tests.rs` prepares a context whose catalog holds `adhoc/grab`, one whose usable roster holds `adhoc`, and one whose unavailable roster holds `adhoc`: each report has `reserved_adhoc` set, `is_satisfied()` false, and a `refusal()` of kind `RequirementsUnmet` whose notice holds the reserved line once; a context naming no `adhoc` has the flag clear, and `merge` keeps a set flag. +- Facade: `crates/runvil/public-api.txt` follows `UsablePack`, `RunContext::packs`, `ToolInfo::describes_tools`, and `Requirements.reserved_adhoc`. Regenerate it with `cargo +nightly-2026-09-05 xtask api --bless`, run with `CARGO_BUILD_WARNINGS` and `RUSTDOCFLAGS` unset, and read the diff before committing. +- Rules: + - No history: no shim, compatibility code, hint, or note about an old form; no test checks that an old form is gone; no comment or doc says what changed or what something used to be. + - Every `.rs`, `.md`, `.lua`, `.toml`, and `.txt` line under `crates/runvil` and `crates/runvil-internal`, comments and test strings included, passes the docs-claims scan: no "host", "workshop", or "papergate" in any case, no lowercase "harness" (except "harness = false" and "test harness"), no "Harness-", and no "RunHarness". + - Every Rust file under `src` or `tests` stays at or under 500 lines, or `build-ceiling` fails the build. Close today: `types/src/event.rs` 447, `engine/src/execute/scheduler/tool_call.rs` 432, `engine/src/execute/config.rs` 424, `types/src/emitter.rs` 421. New tests go in new `-tests` siblings, and a file this step pushes past the limit is split in this step. + - Doc comments are updated in each file as it is touched. +- Acceptance: catalog tool objects expose `parameters` as specified; tool-call and tool-result events carry the answering Pack's vendor for catalog tools and none for adhoc tools; in the Workshop, the ask, search, and fetch views appear only for `runvil/user-input/ask`, `runvil/web/search`, and `runvil/web/fetch`, while the same id from another vendor's Pack, a call without a vendor whose tool id is `runvil/user-input/ask`, or an adhoc tool named `ask` shows as an ordinary tool, and a script's call to another vendor's `user-input/ask` does not frame as the operator's message; a script's call to `mcp/get_dynamic_tools` returns the listing without the envelope, a model's call returns it wrapped, and, for a call the tool answers successfully, a model call to an adhoc wrapper of it returns the same envelope as the direct model call; a listing the section fetched outside any adhoc call and kept in a variable, then returned by an adhoc tool, reaches the model unwrapped, with `trusted` true on the adhoc result event; a Pack id `adhoc` is refused by the registry, the parser, and run preparation with the messages above; every Tests command passes. + + + + + +### Step 3: One offer API and adhoc tools [completed] + +- Component: offer API +- Tests: run in PowerShell from `c:\Users\Vinnie\cursor\runvil`, in this order, each line as written. Every `cargo`, `npm`, and `node` line must exit 0, and `xtask api --check` must report no change. The first three `rg` lines must print nothing (`rg` then exits 1). The fourth `rg` line may print only lines where a test shows `tools.call` with a string that has no slash raising. The fifth `rg` line must print only Lua `local` variable declarations (such as `local tool =`, `local tools =`, `local tool_ok`, `local tools_list`). The two `git diff` lines print added lines to read: each must describe current behavior, with no word about an old form, a rename, or a migration. + - `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types --all-features` + - `cargo nextest run --locked -p runvil-engine --all-features -E 'test(/a_section_tools_table_holds_only_its_api|every_global_section_setup_leaves_in_g_is_a_reserved_name|every_reserved_global_is_present_after_section_setup/)'` + - `cargo fmt --all --check` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `Push-Location crates/workshop; npm test --workspaces --if-present; Pop-Location` + - `Push-Location crates/workshop; npm run typecheck --workspaces --if-present; Pop-Location` + - `Push-Location crates/gateway/config-ui/ui; npm test; Pop-Location` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + - `cargo check -p gateway --no-default-features` + - `cargo deny check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc -p runvil --no-deps` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc -p harness --no-deps` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps -p workshop-server --document-private-items` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +1.95 build --locked -p runvil -p runvil-engine -p runvil-lua -p runvil-parser -p runvil-types -p runvil-vfs -p runvil-model-client` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check` + - `rg -n '(^|[^.\w])packs\.(get|offer|required|extras)\b' -g '!vibe/**' -g '!target/**'` + - `rg -n 'tools\.(required|extras)\b|offer_local|offer_pack|offer_extras|install_packs|install_tool_lists' -g '!vibe/**' -g '!target/**'` + - `rg -n --pcre2 'tools\.packs\.(?!get\b|extras\b)' -g '!vibe/**' -g '!target/**'` + - `rg -n 'tools\.calls\.\w|tools\.call\(\\?[\x22\x27][^\x22\x27/\\]*\\?[\x22\x27]' -g '!vibe/**' -g '!target/**'` + - `rg -n -i 'local[ _-]tool|lua-local|local[ _-]handler|LocalTool|LocalCall|local_call|::Local\b|LOCAL_HANDLERS' crates -g '!crates/gateway/**' -g '!crates/build-xtask/**'` + - `git diff 890b41faa -U0 -- . ':(exclude)vibe' | Select-String -Pattern '^\+(?!\+\+).*(no longer|used to|previously|formerly|instead of|migration|deprecated|\bold |was renamed)'` + - `git diff 890b41faa -U0 -- vibe/2026-10-20-1-library-plugin-loading.md | Select-String -Pattern '^\+(?!\+\+).*(no longer|used to|previously|formerly|instead of|migration|deprecated|\bold |was renamed)'` +- Piece: six parts (adhoc storage, ordered offers and the render, offer verbs and the Packs surface, adhoc tools, tests, and `chat.md` with the vibe note), built jointly in one commit, because the `ToolRuntime`, `RenderedTools`, and `AdhocTools` changes break the Engine until routing is done, and its tests until every test is moved. Parts 1 to 4 run in order: the render resolves `adhoc/*` ids against part 1's storage, the offer decode checks ids against what part 2's render can resolve, the adhoc Pack reads part 1's objects, and part 4's routing needs the offers and the Pack objects. Part 5 follows part 4; its Lua crate tests and its other crates' tests touch different crates and can go to two workers. Part 6 can run at any point in the step. +- Placement: after step 2, whose `tool.parameters`, `ToolBinding::with_vendor`, `Advertised`, `describes_tools`, and `adhoc` reservations it builds on, and before step 4, whose docs are checked against this step's code. + +Work in `c:\Users\Vinnie\cursor\runvil` on branch `master`, on top of step 2's commit. Line numbers are hints from `890b41faa`; the Tests greps are the real check. Paths are under `crates/runvil-internal/` unless they start with `crates/` or `vibe/`. The commit message title is "Collapse tool offering into tools.offer with adhoc tools", with the executor's normal trailers. Do not push in this step. + +Rules for every part: + +- **No history.** Removed forms are deleted outright. No shim, migration path, compatibility code, hint, or note recognizes or explains an old form. No test checks an old form, including that an old name is gone. No comment or doc says what changed or what something used to be. Tests and docs that used an old form are rewritten to the new form or deleted. +- Offers hold ids, never wire names. Nothing is hash-ordered: the same offers give the same rendered list in every process. Any offer after the section's first loop round raises. +- **Metadata is never wrapped.** What Lua reads about tools and Packs (`tool.id`, `tool.description`, `tool.parameters`, `tool.pack`, and a Pack's `name`, `required`, and `tools`) is the text and schema as the Pack or the `tools.adhoc` attrs declared them, never in the untrusted envelope, whatever the Pack's output trust. +- Lua never reads wire names, so prompts name tools only by id. Adhoc tools never reach the caller's catalog effects. Pack and tool objects stay frozen, and `tools.packs` cannot be modified or stripped of its metatable. Lua never sees vendors. +- Every `.rs`, `.md`, `.lua`, `.toml`, and `.txt` line under `crates/runvil` and `crates/runvil-internal`, comments and test strings included, passes the docs-claims scan: no "host", "workshop", or "papergate" in any case, no lowercase "harness" (except "harness = false" and "test harness"), no "Harness-", and no "RunHarness". +- Every Rust file under `src` or `tests` stays at or under 500 lines (`build-ceiling`). Close today: `engine/src/execute/scheduler/tool_call.rs` 432, `lua/src/vm.rs` 424, `lua/src/packs-tests.rs` 373. Planned splits: `AdhocTools` moves to `lua/src/vm/adhoc.rs`; the offer installer and its decode go in `lua/src/tools/offer.rs`; `tools.packs` and the Pack objects stay in `packs.rs`; the adhoc arm of `tool_call.rs` moves to `scheduler/tool_call-adhoc.rs`, wired with `#[path]`; new tests go in new `-tests` siblings. A file that still crosses the limit is split in this step. +- Doc comments in every touched file describe only the new API, updated as each file is touched, including `lua/src/lib.rs` (lines 41-45), `lua/src/tools/decode.rs` (line 60), `lua/src/test_vm.rs` (line 112), and `crates/plugin-user-input/src/lib.rs` (lines 59-63, which name `packs.offer('user-input')`). + +The surface this step builds. A section defines adhoc tools, offers tools and Packs, reads Packs and tools, and calls tools, all through the `tools` table, which holds exactly `adhoc`, `call`, `get`, `offer`, `offer_dynamic`, and `packs`, with `calls` appearing at the section's first tool call (`install_section_scope`, `engine/src/execute/section_context.rs`). Each offer is checked when made and only remembered. The section's first loop round renders the remembered static offers in call order and computes the dynamic list, which nothing reads yet. In `prepare_chat` the model is resolved first (`engine/src/execute/scheduler/chat.rs`, lines 116-123), then the list is built (lines 129-137). The model calls rendered tools by wire name; Lua calls any tool by id or object, offered or not. Every line of this listing behaves as its comment says: + +````lua +-- DEFINE. tools.adhoc(attrs, f) creates a Lua tool and returns its tool object. It does not offer it. +local grab = tools.adhoc({ + name = 'grab', -- the wire name; the id is adhoc/grab + description = 'Grab a value', + parameters = { value = 'string' }, -- optional; a type name or { type, description } per parameter, or a JSON Schema object +}, function(args) + return 'got ' .. args.value +end) +local find = tools.adhoc({ name = 'find', description = 'Find notes', parameters = { query = 'string', limit = { 'integer', 'maximum hits' } } }, function(args) + return 'found ' .. args.query +end) +local none = tools.adhoc({ name = 'none', description = 'No parameters' }, function() end) +local shout = tools.adhoc({ name = 'Shout', description = 'Uppercase text', parameters = { text = 'string' } }, function(args) -- id adhoc/shout, wire name Shout + return args.text:upper() +end) +local t = tools.get('web/search') -- attrs are plain data, so a catalog tool can be wrapped; t.parameters is a JSON Schema object +local search = tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, function(args) + return tools.call(t, args) +end) + +-- OFFER. Checked when made, remembered, rendered in call order at the first loop round. +tools.offer('web/search') -- a tool by id: a string with a slash +tools.offer('web') -- a Pack by id: a string without a slash +tools.offer(grab) -- a tool object +tools.offer(tools.packs.get('web')) -- a Pack object +tools.offer(tools.get('web/search').pack) -- the Pack a tool belongs to +tools.offer({ 'web/search', grab, 'subagents', tools.packs.get('user-input') }) -- an array mixing every form +tools.offer(tools.packs.get('web').tools) -- a Pack's tools, one by one +tools.offer(tools.packs.get('adhoc')) -- the adhoc Pack: every adhoc tool defined by the render +tools.offer(tools.packs.extras()) -- the opt-in extras: every Pack not declared under uses:, never adhoc +tools.offer(tools.packs) -- every Pack the section knows, adhoc included +tools.offer() -- no argument, offers nothing +tools.offer({}) -- empty array, offers nothing + +-- OFFER DYNAMIC. The same forms and checks; recorded into the dynamic list at render, which nothing reads yet. +tools.offer_dynamic('user-input') +tools.offer_dynamic(tools.packs.extras()) +tools.offer_dynamic({ 'web', grab }) + +-- READ +for _, pack in ipairs(tools.packs) do end -- every Pack, in name order, adhoc included +#tools.packs -- how many Packs +local web = tools.packs.get('web') -- a Pack object, or nil when the run has no such Pack +local extras = tools.packs.extras() -- a fresh array of the extra Pack objects, in name order +web.name -- 'web' +web.required -- true when declared under uses:, always false for adhoc +web.tools -- a fresh array of the Pack's tool objects, in id order +#web.tools > 0 -- "does this Pack have tools" +local tool = tools.get('web/search') -- a tool object, or nil; tools.get('adhoc/grab') == grab +tool.id -- 'web/search', or 'adhoc/grab' +tool.description -- the catalog text, or the attrs description +tool.parameters -- a fresh table holding the parameter schema +tool.pack -- tools.packs.get('web'), or the adhoc Pack + +-- CALL AND COUNT +tools.call('web/search', { query = 'lua' }) -- by id +tools.call(grab, { value = 'hi' }) -- by tool object, offered or not +tools.call('adhoc/grab', { value = 'hi' }) +tools.calls['adhoc/grab'] -- calls so far, by id; tools.calls appears at the section's first tool call + +-- REFUSED. Each raises. +tools.offer('wbe') -- no Pack named wbe +tools.offer('web_search') -- no Pack named web_search; suggests 'web/search' +tools.offer('web/serch') -- not a tool in this run +tools.offer('mcp') -- a Pack that cannot serve: the Host's reason +tools.offer('web/search', 'web/fetch') -- one argument only +tools.offer(42) -- not a tool, a Pack, or an array of them +tools.offer(nil) -- nil is not a tool or a Pack; a lookup that may miss is guarded with if +tools.offer(tools.get('web/serch')) -- the same: tools.get returned nil +tools.offer({ 'web', nil, 'mcp' }) -- an array has no gaps +tools.offer({ name = 'web' }) -- an array has only the keys 1 to #t +tools.offer({ { 'web' } }) -- arrays do not nest +tools.packs.get({ 'web', 'subagents' }) -- get takes one Pack id +tools.packs.get = nil -- tools.packs is read-only +tools.adhoc('grab', function() end) -- attrs must be a table +tools.adhoc({ name = 'x', description = 'y' }) -- the second argument must be a function +tools.adhoc({ name = 'x', description = 'y' }, function() end, 'z') -- two arguments only +tools.adhoc({ name = 'grab', description = 'again' }, function() end) -- the name grab is taken +tools.adhoc({ name = 'GRAB', description = 'again' }, function() end) -- its id, adhoc/grab, is taken +tools.adhoc({ name = 'x', description = 'y', run = 1 }, function() end) -- unknown key run +tools.adhoc({ description = 'y' }, function() end) -- name is required +tools.adhoc({ name = 'x', description = 'y', parameters = 'string' }, function() end) -- parameters must be a table +tools.call('grab', { value = 'hi' }) -- a string without a slash is not a tool id +-- any tools.offer or tools.offer_dynamic after the section's first models.loop round: the tool list is fixed +```` + +Behavior rules: + +- **Offer verbs.** `tools.offer` and `tools.offer_dynamic` take one argument: a string with a slash (a tool id), a string without one (a Pack id, since Pack ids are one segment and tool ids two or more, `types/src/names.rs`), a tool object, a Pack object, or an array mixing them. The render refusal comes before every other check. Every entry is checked before any is recorded, so a bad array entry records nothing. A tool id must name a catalog tool in the offering (`ToolSet::offered`, `lua/src/handles.rs`) or a defined adhoc tool. A Pack id must name a Pack with an object. An entry identical to one already recorded (same target and mode) is skipped. No argument offers nothing; an explicit `nil` is a wrong type. A table is an array of `#t` entries (honoring `__len`), each read through `__index`; it raises when an entry in that range is nil or a table (arrays do not nest), or when it holds a raw key outside `1..#t`. mlua's `sequence_values` reads raw and skips metamethods, so the decode reads `len()` and then `get(1..=n)`, and checks raw keys separately; otherwise `tools.offer(tools.packs)` would see an empty list. +- **Static render, at the first loop round.** Walk the static entries in order and append to a list, skipping tools already in it. A tool entry adds that tool. A Pack entry adds its tools in id order, expanded at render, so `tools.offer(tools.packs.get('adhoc'))` includes adhoc tools defined before the render while `tools.offer(tools.packs.get('adhoc').tools)` takes only those defined at the call. Examples: `tools.offer('web')` then `tools.offer('web/search')` renders `web_fetch`, `web_search`; reversed, `web_search`, `web_fetch`; `tools.offer(grab)` then `tools.offer('web')` renders `grab`, `web_fetch`, `web_search`. Repeated offers neither duplicate nor move a tool, and a tool followed by its Pack keeps the tool's place. +- **Shadowing.** When an offered adhoc tool and an offered catalog tool share a wire name, the adhoc tool is rendered and the catalog tool is dropped wherever it was offered. A defined but unoffered adhoc tool shadows nothing, and a model call to a catalog tool's wire name is never answered by it. +- **Dynamic list (scaffold).** The same render walks the dynamic entries the same way and keeps every named tool not in the static list, deduped, in offer order. A tool whose id or wire name is in the static list is skipped, so static wins in either order: `tools.offer('web/search')` with `tools.offer_dynamic('web')` gives static `web_search` and dynamic `web/fetch`. Dynamic entries never change the static list, and the rendered schemas are byte for byte the same with or without them. The list is stored with the rendered list (`RenderedTools.dynamic`) and nothing reads it: no discovery tools, no routing, no change the model can see. +- **`tools.packs`.** A frozen proxy. Integer keys, `#`, `ipairs`, and `pairs` walk the Pack objects in name order, the adhoc Pack included, and `pairs` yields only Packs. Its only members are `get(name)`, which returns a Pack object or nil (nil for unknown names and for tool ids), and `extras()`, a fresh array of the usable Packs not declared under `uses:`, in name order, never the adhoc Pack. Any other key reads nil. Writes raise, and the metatable is protected the way `sys` and `var` protect theirs. The Engine's replacement `pairs` (`lua/src/iteration.rs`, lines 117-141) returns a `__pairs` metamethod's results. +- **Extras** are a per-section opt-in through `tools.offer(tools.packs.extras())` or its dynamic twin, off by default. A Pack the Host reports unable to serve has no object, so it is never an extra. +- **Pack objects** have exactly `name`, `tools` (a fresh array of tool objects, id order), and `required` (declared under `uses:`). **Tool objects** have exactly `id`, `description`, `parameters`, and `pack`. Packs are reached only through `tools.packs.get`, integer indexes, `tool.pack`, or Pack id strings, and tools only by id string, a Pack's `tools`, or the object `tools.adhoc` returns, so no author-chosen name (a Pack `get`, a tool `tools`) ever meets a field. Objects keep `==` identity across `get`, integer indexes, and `tool.pack`. +- **`tool.parameters`** (built in step 2) returns a fresh Lua table on every read, converted the way `json.decode` converts; for an adhoc tool it is the schema built from `parameters`, or the empty schema. +- **Adhoc tools.** `tools.adhoc(attrs, f)` takes exactly two arguments. `attrs` is plain data with exactly the keys `name` (required, alias grammar `[A-Za-z][A-Za-z0-9_-]{0,63}`, `lua/src/alias.rs`), `description` (required), and `parameters` (optional). `parameters` is a table in one of two forms told apart by its `type` key: `'object'` means a JSON Schema object, converted by the same function `json.encode` uses and used as the tool's schema as written; anything else is the short format, one type name or `{type, description}` per parameter. `object` is never a short-form type, so `{ type = 'string' }` is a short-form parameter named `type`. With no `parameters`, the schema is `{"type":"object","properties":{},"required":[]}`. `f` must be a function. The name and schema are checked when the tool is defined, so a bad one raises on the `tools.adhoc` line. The tool gets a `ToolInfo` with id `adhoc/` and plain-text output, and `name` as written is its wire name. It is registered, not offered, and its tool object is returned. Defining works after the render; only offers are refused there. Its results are trusted output, unless its own function fetched a tool listing during the call (step 2's mark). A result the function got from `tools.call` of another untrusted tool is already nonce-wrapped with the run's shared nonce (`lua/src/dispatch.rs`, `prepare_dispatch`), so it reaches the model byte for byte as the direct call's would; the result event's `trusted` flag still reads true, because the author's function produced the text. +- **The adhoc Pack** exists in every section VM from the start, the H1 pass included, with `required` false and an empty `tools` until the first `tools.adhoc`; then `tools` lists every adhoc tool defined, offered or not, in id order. It is not part of the run's catalog or roster. +- **Calls and counts.** `tools.call` takes a tool id or a tool object. `tools.calls` keys every tool by id. Counts are seeded at 0 for every tool in the section's rendered list, offered adhoc tools included; any other tool, such as a defined but unoffered adhoc tool, gains its count at its first call, and reading its key before then raises the existing `tools.calls: ... has no seeded count` error, with the adhoc hint below for a defined adhoc tool's id. Adhoc tool-call and tool-result events carry `tool: Some(adhoc/)` and no `vendor`. + +Errors, exact (`` is `tools.offer` or `tools.offer_dynamic`): + +- after the render: `: the section's tool list is fixed at its first models.loop round; make every offer before the first loop, or offer in another section`; +- unknown Pack: `: no Pack named "" in this run; declare it under uses: to require it, or offer it only when tools.packs.get("") returns it`, plus ` (did you mean ''?)` when a tool in the offering or a defined adhoc tool has the wire name ``; +- unknown tool: `: "" is not a tool in this run`; +- an offer string that is not a valid id, such as `'Web'`, `'Shout'`, or `'web//x'`, takes the message of an unknown one of its shape: the unknown-Pack message without a slash, the unknown-tool message with one; +- Pack that cannot serve: `: the Pack is unavailable in this run: `; +- wrong type: ` takes tool ids, Pack ids, tool objects, Pack objects, or an array of them, got `; +- extra argument: ` takes one tool or Pack, or an array of them, got extra `; +- array entry: ` takes tool ids, Pack ids, tool objects, or Pack objects in an array, got at index ` (a nil gap or a nested table); +- array key: ` takes an array, got a table with the key `; +- `tools.packs.get takes one Pack id, got `; `tools.packs is read-only`; a metatable change raises Lua's `cannot change a protected metatable`; +- `tools.adhoc takes an attrs table first, got `; `tools.adhoc takes the tool's function second, got `; `tools.adhoc takes two arguments, got extra `; `tools.adhoc: unknown key ""; accepted keys are name, description, parameters`; `tools.adhoc: "" is required`; `tools.adhoc: "" must be a string, got ` for `name` and `description`; `tools.adhoc: invalid name "": expected [A-Za-z][A-Za-z0-9_-]{0,63}`; `tools.adhoc: parameters must be a table, got `; `tools.adhoc: parameters schema cannot be encoded as JSON: `; `tools.adhoc name "" is already registered`; `tools.adhoc name "" has the id adhoc/, which "" already holds`; the existing short-form parameter-type errors under the `tools.adhoc` prefix; +- `tools.call takes a tool id or a tool object, got `; `tools.call: "" is not a tool id`; +- the `tools.calls` hint for a defined adhoc tool's id: ` (an adhoc tool that was neither offered in this section nor called with tools.call)`, beside the existing catalog hint; +- the jump refusal inside an adhoc tool's function stays as step 1 wrote it. + +Work through the parts in order: + +- **Part 1, adhoc storage:** + - `AdhocTools` moves from `lua/src/vm.rs` to the new `lua/src/vm/adhoc.rs`, is `pub`, and is re-exported from `runvil-lua` beside `current_tool_bindings`. It holds, per adhoc tool, a `ToolBinding` built with `ToolBinding::from_info(name, &info)` (so its vendor is `None` and it never describes tools) and its `ToolInfo`, with lookups by wire name and by id. `SectionVm::adhoc_tools(&self) -> &AdhocTools` hands it to the Engine. Handlers stay in the VM's handler table under the name. + - The tool-object registry (`lua/src/tools/objects.rs`) gains an insert for `tools.adhoc`. +- **Part 2, ordered offers and the render:** + - `lua/src/scope.rs`: `Offer { target, mode }`, `pub enum OfferTarget { Tool(ToolId), Pack(PackId) }`, and `pub enum OfferMode { Static, Dynamic }`. `ToolRuntime` holds one ordered `offers: Vec` with `pub fn add_tool(&mut self, id: ToolId, mode: OfferMode)` and `pub fn add_pack(&mut self, pack: PackId, mode: OfferMode)`, each skipping an `Offer` equal to one already held, and no filter field. `RenderedTools` gains `dynamic: Vec`, and its `dispatch` becomes `BTreeMap`, since every advertised name, adhoc included, now has an id (adhoc entries have vendor `None`); `Chain.advertised` (`engine/src/execute/scheduler/chain_record.rs`) and the `advertised` parameter in `scheduler/chat.rs` follow. + - `lua/src/vm/state.rs`: `current_tool_bindings(set, runtime, adhoc)` returns the static bindings, and `current_dynamic_tools(set, runtime, adhoc, static_bindings)` returns the dynamic ids. Both use one shared entry-expansion helper, so the lists cannot disagree about what an entry names. Catalog ids resolve with `ToolSet::offered_binding`; `adhoc/*` ids resolve against `AdhocTools`. Shadowing is applied last in the static pass. `adhoc_tool_schemas` (line 90) is removed, and `has_adhoc_tool` (line 98) takes a wire name or an id. + - `engine/src/execute/scope.rs`: `prepare_effective_scope` and `prepare_scoped_tools` drop `adhoc_schemas`; the merge of the section's adhoc tools (lines 44-51) and their shadowing of catalog tools (lines 70-73) move into the static pass, so the Engine receives one finished list. + - Callers move to the new signatures: `scheduler/chat.rs` (lines 129-137: pass the adhoc tools, call `current_dynamic_tools`, store the result), `section_call_counts` (`scheduler/tool_call.rs`, lines 175-181), `lua/src/test_vm.rs` (line 122), and the test callers in `lua/src/tests/tool_scoping.rs`, `lua/src/tests/section_vm.rs`, `engine/src/execute/tests.rs`, and `engine/src/execute/tests/tool_scoping.rs`; `prepare_scoped_tools`' test callers in `engine/src/execute/tests/offering.rs` (line 345) and `tool_scoping.rs` (lines 50 and 113) drop their second argument. The `RenderedTools` literals in `lua/src/compactors-tests.rs` (line 176), `lua/src/tools/tests-offering.rs` (line 197), and `lua/src/messages-tests.rs` (line 50) gain `dynamic` and take plain `Advertised` values. + - The rendered list, dynamic list included, is attached to `messages` and lives exactly as long as the section. +- **Part 3, offer verbs and the Packs surface,** together because both rewrite `packs.rs` and the offer decode: + - `lua/src/tools/offer.rs` (new): one installer, parameterized by name and mode and installed twice, as `tools.offer` (`OfferMode::Static`) and `tools.offer_dynamic` (`OfferMode::Dynamic`), with its decode (the string-shape rule, tool and Pack objects, strict arrays read through `len()` and `get(1..=n)` with a separate raw-key check, the render refusal first, every entry checked before any is recorded) and the Pack-id checks moved out of `packs.rs`. `resolve_entries` (`lua/src/tools.rs`) returns ids. + - `lua/src/packs.rs`: Pack objects (`LuaPackHandle` gains `required`), the adhoc Pack object, whose `tools` reads the VM's adhoc tool objects on each read, and `tools.packs` with `get` and `extras`. `tools.packs` is an empty proxy table with a metatable, following the `tools.calls` pattern in `lua/src/tools.rs`: `__index` (integers, `get`, `extras`), `__len`, ordered `__pairs`, raising `__newindex`, and `__metatable`. `packs.offer` and its decode are deleted. + - `lua/src/tools/objects.rs`: `install_tool_lists` becomes `install_tool_get`, which installs only `tools.get`; `tools.required` and `tools.extras` are deleted. `install_tools` (`lua/src/tools.rs`) installs tool objects, Pack objects, `tools.packs`, both offer verbs, `tools.adhoc`, and `tools.get`. `lua/src/vm/inject.rs` drops the `install_packs` call, and `install_packs` is deleted. + - `lua/src/globals.rs`: `packs` leaves `RESERVED_NAMES`, whose length goes from 63 to 62; the two-way globals tests in `engine/src/lua/tests/globals.rs` confirm the installed globals match. +- **Part 4, adhoc tools:** + - `lua/src/tools.rs`: `tools.adhoc` replaces `install_offer_local`. It checks arity and attrs, validates the name and schema through `tool_schema_new` (as `tools.offer_local` does today, line 283) under the `tools.adhoc:` prefix before registering, registers the tool in `AdhocTools`, the handler table, and the tool-object registry, and returns the tool object. `tools.offer_local`, `install_offer_local`, and their error texts are deleted. + - `lua/src/tools/decode.rs`: `tool_name` accepts ids and objects only, with the `tools.call` errors above; `local_params_schema` becomes `adhoc_params_schema` under the `tools.adhoc` prefix. `lua/src/json/encode.rs` exposes the `pub(crate)` conversion that `json.encode` and the JSON Schema form of `parameters` share. + - Routing: `tool_route` (`engine/src/execute/scheduler/tool_call.rs`) takes the call site from both of its callers: the single-call path (line 264) passes the one `dispatch_tool_call` built, and the batch path, `dispatch_tool_calls` (line 125), builds its `CallSite::Batch` before routing. A model call (it has a call id) looks its wire name up in the rendered list of the current frame's section through the new `SectionVm::rendered_target(&self, wire_name: &str) -> Option`, which reads the entry from the rendered list attached to the VM's `messages` without copying the list, and never in `Chain.advertised`, which another section's rounds overwrite while an adhoc function's `call` runs. The call goes to the adhoc handler when the name maps to an `adhoc/*` id. With no rendered list, a model call routes as a catalog call by wire name, so the test hook `tools.call_as_model` reaches an adhoc tool only after the section renders; tests that call adhoc tools through it render first (one loop round) or call by id. A script call routes by id. The adhoc arm counts and reports under the id and emits its `ToolResult` (lines 409-417) with the id and no vendor. The adhoc arm (`tool_route`'s adhoc branch and `dispatch_adhoc_tool_done`) moves to the new `scheduler/tool_call-adhoc.rs`. + - Events: the doc comments in `types/src/metrics.rs` (line 31), `types/src/event.rs` (line 405), and `types/src/emitter.rs` (line 355) drop the adhoc tool from what `tool: None` means. Every reader that treated `None` as an adhoc tool reads the id: `engine/src/execute/tests/chat_arm.rs` (line 176), `engine/src/execute/tests/offering.rs` (line 353, which asserts `Some(None)`), and the Workshop UI's `tool-labels.ts`, `transcript-model.ts`, and `agent-session.ts` (line 57), where an adhoc call labels as ` in adhoc`, read from `tool`, and `toolKind` never matches `adhoc/ask`. Nothing is added for records written before this change, so a replay of such a run does not match its Chat effects, whose advertised order and event `tool` fields change. +- **Part 5, tests:** + - Lua crate (`lua-tests`): rewrite `lua/src/packs-tests.rs`, update `lua/src/tools/tests-offering.rs` and the adhoc tests, and put new tests in new `-tests` siblings: + - `tools.packs`: integer indexes, `#`, `ipairs`, and `pairs` in name order with `adhoc`, and `pairs` yields no methods; `get` returns shared objects, or nil for unknown names and tool ids, and raises for non-strings, tables included; any other key reads nil, writes raise, and `setmetatable(tools.packs, nil)` is refused; `extras()` excludes declared Packs, `adhoc`, and Packs that cannot serve; objects keep `==` identity across `get`, integer indexes, and `tool.pack`. + - `required`: true for declared Packs, including one with no tools, and false for undeclared ones and `adhoc`; `#pack.tools > 0` holds for a Pack with tools and fails for one without. + - Collisions: Packs named `get`, `extras`, and `tools`, the last offering `tools/tools`, are reached through `get` and offered by id; Pack and tool objects have no fields beyond the specified ones. + - Offer verbs, both of them: every accepted form and the string-shape rule; each error message, including the wire-name hint; the render refusal first; a bad array entry records nothing; `tools.offer()` offers nothing, `tools.offer(nil)` raises, and an array with a gap, a stray key, or a nested table raises with the array messages; `tools.offer(tools.packs)` and `tools.offer(tools.packs.extras())` offer what they should. + - Order: the three render-order examples; repeated offers neither duplicate nor move a tool; a tool followed by its Pack keeps the tool's place; Pack expansion at render against `.tools` taken at the call, for the adhoc Pack; rename `the_scope_lists_section_tools_then_each_offered_packs_tools_each_name_once` (in `packs-tests.rs`) to describe call order. + - Dynamic list: static wins in both orders; duplicates are listed once, in offer order; adhoc tools defined later but before the render are included; the rendered schemas are unchanged. + - `tool.parameters`: it matches the catalog schema, the schema built from `parameters`, or the empty schema; each read is a fresh table; it round-trips through `json.encode` with `[]` intact; assigning it raises. An adhoc tool object's `description` is the attrs text as given and never holds the run's nonce or the envelope's preface. + - Adhoc: the returned object equals `tools.get('adhoc/grab')`, with correct fields and `pack`; it is not rendered until offered; `tools.call` works without an offer, and after the render; offering after the render raises; `Shout` gets the id `adhoc/shout` and the wire name `Shout`; every `tools.adhoc` refusal in the listing; `parameters = tools.get('web/search').parameters` gives a tool whose `parameters` deep-equals the catalog tool's and whose rendered schema is the same text, and `parameters = { type = 'string' }` is a short-form parameter named `type`; for a call the tool answers successfully, a wrapper of an untrusted test tool gives the model the same nonce-wrapped text as a direct model call to that tool; a definition without `parameters` gets the schema `{"type":"object","properties":{},"required":[]}`, and a bad name or schema raises on the `tools.adhoc` line with the `tools.adhoc:` prefix; `tools.call('grab')` raises; `tools.calls['adhoc/grab']` counts, reads 0 for an offered adhoc tool once counts are installed, and raises for an unoffered one until its first call; shadowing only among offered tools; the adhoc Pack exists in H1 and when nothing is defined. + - `lua/src/tools/tests.rs` (lines 191-197 assert that `tools.offer_local` exists), `lua/src/protocol/tests/answer.rs`, and `lua/src/tests/diagnostics.rs` use the new forms. + - Delete `packs_is_the_engine_global_and_the_old_plugins_global_is_nil` (`packs-tests.rs`, line 190) and the `tools.required()`/`tools.extras()` cases (`tests-offering.rs` lines 112-117, `packs-tests.rs` lines 172-180). + - Other crates (`engine-harness-tests`): + - `engine/src/execute/tests/offering.rs`: `TOOLS_TABLE_KEYS` (line 176) becomes `adhoc:function,call:function,get:function,offer:function,offer_dynamic:function,packs:table`; lines 144 and 185 use `tools.offer(tools.packs.extras())`; line 214 uses `tools.packs.get('tools').tools[1]`. + - `engine/src/execute/tests/tool_scoping.rs` builds `ToolRuntime` through `add_tool` with the id `tools/concrete`. + - The adhoc-handler tests under `engine/src/execute/tests/` (`adhoc_tools.rs`, `tool_call_arm-adhoc-handlers.rs`, `debug_and_counts.rs`, `chat_arm.rs`, `chat_scope.rs`, `batch_turn.rs`, `compaction.rs`, whose line 260 asserts the `tools.offer_local:` error text, `models_compact.rs`, `models_loop.rs`, `models_loop_contract-rounds.rs`, `section_messages.rs`, and `scheduler/live_h1/control_flow.rs`), `engine/src/lua/tests/shims.rs`, and every test step 2 wrote with `tools.offer_local` use `tools.offer(tools.adhoc({ name = ..., description = ..., parameters = ... }, handler))`, calls and counts by `adhoc/` or by object, call-order expectations, and events that carry the id. + - Lua strings in `rendered_tools.rs`, `offering-packs.rs`, and `parallel_tool_calls.rs` use `tools.offer`. `rendered_tools.rs` expects `lua:tools.offer: ...`, and its after-render case checks that defining still works while offering raises. + - `engine/src/execute/tests/scheduler/failures-script-tools.rs` (line 53) calls `tools.call('missing', {})`, which now raises `tools.call: "missing" is not a tool id` before resolution; make it call an unknown id such as `tools/missing`, so it still pins `Error::UnboundToolCall` with the offered ids. + - A model call to an offered catalog `web_search` reaches the catalog tool while an unoffered adhoc `web_search` exists. + - `crates/harness-internal/runner/tests/it/prepare-ready.rs` (lines 76, 309, 317, 347-348), `crates/harness-internal/runner/tests/it/prepare.rs` (lines 377, 408), `crates/workshop/server/src/agents/tests.rs` (line 115), and `crates/runvil/tests/suite/packs.rs` (line 10) use `tools.packs.get` and `tools.packs.extras()`. + - Workshop UI (`crates/workshop/ui/test/`): an adhoc call labels as ` in adhoc`, replacing the null-tool cases in `transcript-model.mjs` (lines 430-434); `agent-session-service.mjs` (lines 650-663) reads an adhoc id; `adhoc/ask` stays `other`. + - The two-way globals tests pass with 62 reserved names, and the same offers give the same list in every process. +- **Part 6, `chat.md` and the vibe note:** + - `crates/workshop/agents/agents/chat.md` (lines 30-31): `tools.offer("web")` and `tools.offer(tools.packs.extras())`. It renders `web` first, then the extras' tools; no Workshop test asserts the order. + - `vibe/2026-10-20-1-library-plugin-loading.md` (lines 984-988): replace the deferred "Offering only Packs" item, from its bold line through its "Revisit as its own plan." line, with exactly these three lines, so only the filter and annotations remain: + +````markdown + - **Filtered Pack offers.** + - Add filtered views of a Pack's tools by hints such as `readOnlyHint`, which needs annotations on `ToolInfo`. + - Revisit as its own plan. +```` + +- Acceptance: the section's `tools` table holds exactly `adhoc`, `call`, `get`, `offer`, `offer_dynamic`, and `packs`, with `calls` appearing at the first tool call; every line of the listing behaves as its comment says; the rendered schemas are byte for byte the same with or without any dynamic offers; a model call to a catalog tool's wire name is never answered by a defined but unoffered adhoc tool of the same name; the three render-order examples hold; an adhoc tool defined with `description = t.description` and `parameters = t.parameters`, where `t = tools.get('web/search')`, renders the catalog tool's description and schema unchanged, and since `web` marks its results untrusted, this shows that no metadata read arrives in the envelope; a model call to that wrapper, whose function returns `tools.call(t, args)`, gives the model, for a call the tool answers successfully, the same result text, byte for byte, as a model call to the catalog tool itself; step 2's listing and vendor tests still pass with their adhoc tools defined through `tools.adhoc`; every Tests command passes. + + + + + +### Step 4: Docs [completed] + +- Component: language guide +- Tests: run in PowerShell, in this order, each line as written. The first eleven lines must print nothing (`rg` and `git grep` exit 1 when nothing matches, which passes). The twelfth compares every documented error string with step 3's product code and must print nothing, except a fragment that is a value the code fills in at run time, such as a Host's reason or a JSON encoder's reason, which the step's report then names. The thirteenth may print only lines that show `tools.call` with a string that has no slash raising. The fourteenth must print only Lua `local` variable declarations (such as `local tool =`, `local tools =`, `local tool_ok`, `local tools_list`). The fifteenth prints added lines to read: each must describe current behavior, with no word about an old form, a rename, or a migration. The sixteenth, the site build that stages `runvil-docs` through `RUNVIL_DOCS`, must exit 0. The last four check the commit and run once it exists, as the provisional `[WIP] Step 4:` commit or in its final form: the seventeenth must print exactly one line, the eighteenth must print nothing, the nineteenth must print `master`, and the twentieth must print nothing, because no remote branch holds the commit. + - `Get-ChildItem c:\Users\Vinnie\cursor\runvil-docs\src, c:\Users\Vinnie\cursor\runvil-docs\plans -Recurse -Filter *.md | Select-String -Pattern ('[' + [char]0x2014 + [char]0x2013 + ']')` + - `rg -n '(^|[^.\w])packs\.(get|offer|required|extras)\b' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n 'tools\.(required|extras)\b|offer_local|offer_pack|offer_extras|install_packs|install_tool_lists' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n --pcre2 'tools\.packs\.(?!get\b|extras\b)' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n -i 'allow_tasks|task_cancel|task_status|await_tasks|task built-in' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n -i 'model[ -]tasks?|task notices?|TaskNotice|Engine built-in|author[ -]tasks?|author-origin|model-origin' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n -i '\borigins?\b' c:\Users\Vinnie\cursor\runvil-docs\src\language\14-fanout.md c:\Users\Vinnie\cursor\runvil-docs\src\language\15-tasks.md c:\Users\Vinnie\cursor\runvil-docs\src\language\17-quick-reference.md` + - `rg -n -e '\x60packs\x60 global' -e '\x60packs\x60, a read-only' -e '\x60models\x60, \x60packs\x60, \x60prompt\x60' -e '\x60packs\x60 in \[' -e '21 Engine globals' -e 'beyond these 21' -e '63 names' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n -e 'messages\.new' -e 'tool slots?' -e 'Plugin (activation|ids|tools)' -e '\x60plugins:\x60' -e '\x60runvil/web\x60 Plugin' -e '\): \x60fanout\x60, ' c:\Users\Vinnie\cursor\runvil-docs` + - `git -C c:\Users\Vinnie\cursor\runvil-docs grep -n -F -e '#the-name-grammar-for-role-labels-arg-names-and-local-tool-aliases' -e '#the-local-tool-handler-refusal' -e '#calling-from-tasks-and-local-tool-handlers' -e '#task-notices-and-task-tools-in-the-list' -e '#tool-scope-and-the-task-built-ins' -e '#reserved-task-built-in-names' -e '#toolsallow_tasks-errors' -e '#listing-tools-with-toolsrequired-and-toolsextras' -e '#pack-objects-and-the-packs-global' -e '#finding-a-pack-with-packsget' -e '#listing-packs-with-packsrequired-and-packsextras' -e '#offering-tools-with-toolsoffer-and-packsoffer' -e '#offering-a-whole-pack-with-packsoffer' -e '#errors-from-packsoffer' -e '#defining-local-tools-with-toolsoffer_local' -e '#calling-a-local-tool-from-lua' -e '#a-local-tool-as-a-decision-channel' -e '#local-tool-aliases' -e '#a-local-tool-that-shadows-an-offered-tool' -e '#errors-from-toolsoffer_local' -e '#how-local-tool-handlers-run' -e '#events-from-local-tool-calls' -e '#cancelling-a-model-task' -e '#model-tasks-at-the-owners-end' -e '#giving-the-model-task-tools' -e '#the-four-task-built-ins' -e '#starting-a-model-task-with-task' -e '#how-a-model-task-runs' -e '#collecting-and-cancelling-model-tasks-from-lua' -e '#the-models-task_status-and-task_cancel' -e '#calling-task_status-and-task_cancel-with-an-id' -e '#cancelling-with-task_cancel' -e '#refused-task_status-and-task_cancel-calls' -e '#await_tasks-and-task-notices' -e '#what-a-task-notice-says' -e '#waiting-with-await_tasks' -e '#giving-await_tasks-a-timeout' -e '#every-await_tasks-answer' -e '#what-runs-while-await_tasks-waits' -e '#task-ids-handles-and-origins' -e '#what-target-and-origin-say' -e '#author-and-model-origins' -e '#notice-and-tool-call-events' -- .` + - `Set-Location c:\Users\Vinnie\cursor\runvil-docs; $slug = { param($h) ($h -replace '^#+\s+', '').Trim().ToLower() -replace '[^\p{L}\p{N}\s_-]', '' -replace '\s', '-' }; $now = Get-ChildItem src -Recurse -Filter *.md | Select-String '^#{1,6}\s' | ForEach-Object { & $slug $_.Line }; Get-ChildItem src -Recurse -Filter *.md | Select-String '\]\([^)#\s]*#([^)\s]+)\)' -AllMatches | ForEach-Object { foreach ($m in $_.Matches) { $f = $m.Groups[1].Value; if ($f -notin $now) { "$($_.Path):$($_.LineNumber): #$f" } } }; git diff e1a469c -U0 -- . | Select-String '^-#{1,6}\s' | ForEach-Object { & $slug $_.Line.Substring(1) } | Where-Object { $_ -notin $now } | ForEach-Object { git grep -n -E ('#' + $_ + '([^a-z0-9_-]|$)') -- . }` + - `Set-Location c:\Users\Vinnie\cursor\runvil; $code = ((Get-ChildItem crates\runvil-internal, crates\runvil-plugin\src -Recurse -File | Where-Object { $_.Extension -in '.rs', '.lua' -and $_.FullName -notmatch '\\tests?\\|[-_]tests?\.rs$|\\tests\.rs$' } | ForEach-Object { [IO.File]::ReadAllText($_.FullName) }) -join [char]10) -replace '\\\r?\n\s*', '' -replace '\\"', '"'; Get-ChildItem c:\Users\Vinnie\cursor\runvil-docs\src -Recurse -Filter *.md | Select-String -Pattern '(tools\.(offer_dynamic|offer|adhoc|packs|calls|call)\b[^\x60|]*|\((an adhoc|a catalog) tool that[^\x60|]*|tasks\.pending takes[^\x60|]*|chain ended with[^\x60|]*|jump is unavailable[^\x60|]*|invalid frontmatter: adhoc[^\x60|]*|- the run.s tool catalog[^\x60|]*|Pack \x60[^\x60]*\x60 cannot take[^\x60|]*)' -AllMatches | ForEach-Object { foreach ($m in $_.Matches) { $s = $m.Value.Trim(); if ($s -notmatch ' ' -or $s -match '\(\{| = |function\(|^\S+\(' -or $s -notmatch '\b(takes|got|must|is not|no Pack|unavailable|fixed at|read-only|already|unknown|required|invalid|cannot|neither|holds|still live|seeded)\b') { continue }; $t = $s -replace '\bgot\b.*$', '' -replace 'the \S+ Pack is unavailable in this run: .*$', ' Pack is unavailable in this run: '; $miss = @($t -split '<[^>]*>|\{[^}]*\}|\[|\]|"[^"\s]*"|''[^''\s]*''|\x60[^\x60]*\x60|\badhoc/\w+|\btools\.(offer_dynamic|offer|adhoc|packs\.get|packs|calls|call)\b|\d+' | ForEach-Object { $_.Trim(' :;,.()'.ToCharArray()) } | Where-Object { $_.Length -ge 8 -and -not $code.Contains($_) }); if ($miss.Count) { "$($_.Path):$($_.LineNumber): $($miss -join ' | ')" } } }` + - `rg -n 'tools\.calls\.\w|tools\.call\(\\?[\x22\x27][^\x22\x27/\\]*\\?[\x22\x27]' c:\Users\Vinnie\cursor\runvil-docs` + - `rg -n -i 'local[ _-]tool|lua-local|local[ _-]handler|LocalTool|LocalCall|local_call|::Local\b|LOCAL_HANDLERS' c:\Users\Vinnie\cursor\runvil-docs` + - `git -C c:\Users\Vinnie\cursor\runvil-docs diff e1a469c -U0 | Select-String -Pattern '^\+(?!\+\+).*(no longer|used to|previously|formerly|instead of|migration|deprecated|\bold |was renamed)'` + - `Set-Location c:\Users\Vinnie\cursor\runvil; Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; $env:RUNVIL_DOCS='c:\Users\Vinnie\cursor\runvil-docs'; cargo xtask site --books-only` + - `git -C c:\Users\Vinnie\cursor\runvil-docs log --format=%s e1a469c..HEAD` + - `git -C c:\Users\Vinnie\cursor\runvil-docs status --porcelain` + - `git -C c:\Users\Vinnie\cursor\runvil-docs branch --show-current` + - `git -C c:\Users\Vinnie\cursor\runvil-docs branch -r --contains HEAD` +- Piece: four parts in one commit: this plan's surface (part A), the removal of `tools.allow_tasks`, the task built-ins, and model tasks (part B), the removal of task origin (part C), and the guide's build plan (part D). They are built jointly because renamed and deleted headings are linked across chapters, one anchor check covers them all, and the operator asked for one docs commit. Work splits by file, never by part: several files take edits from more than one part (`12-tools.md` and `05-lua-environment.md` from A and B, `15-tasks.md` from B and C, `17-quick-reference.md` from all three), so one worker owns each file and applies every part's edits to it. +- Placement: last, because its error strings and examples are checked against step 3's code, and it lives in a separate repository. + +Run mechanics, because this step's commit lives in a second repository: + +- The code, review, fix, and commit-message dispatches name `c:\Users\Vinnie\cursor\runvil-docs` as the repository and the `runvil` vibe copy as the plan file. The provisional `[WIP] Step 4:` commit and its final message live in `runvil-docs`. +- The review's component base is the `runvil-docs` parent of this step's commit. +- Verify runs with `c:\Users\Vinnie\cursor\runvil` as the repository, at scope `FULL` with diff base `none`; the Tests commands above name `runvil-docs` explicitly. +- This step has no `runvil` commit to amend, so the ` [completed]` marker on this heading lands with the run's close in `runvil`. On resume, a `runvil-docs` HEAD subject starting with `[WIP] Step 4:` is this step's provisional commit, and a finalized step 4 commit in `runvil-docs` under an unmarked heading means only the mark and the close remain. + +Work in `c:\Users\Vinnie\cursor\runvil-docs` on its current branch, `master`, clean at `e1a469c` when planned and one commit ahead of `origin/master` (`git status --short --branch` printed `## master...origin/master [ahead 1]`), with chapters in `src/language/` and the guide's build plan in `plans/`; line numbers below are approximate at that HEAD, so find each passage by its content. Start only after step 3 is committed. The step ends with exactly one commit in `runvil-docs`, on `master`, whose title is "Document the one offer API and bring the guide up to date", with the executor's normal trailers. It makes no commit in `runvil`. Commit locally and push neither repository. The repository has no CI of its own: `runvil`'s `cargo xtask site --books-only` stages it from the directory named by `RUNVIL_DOCS` and builds it with mdBook, and the site build only checks that a linked file exists, so only the anchor checks above catch a broken anchor. + +House rules: a single dash, never an em dash or a double dash, in prose; four-backtick code fences; one line per paragraph. Write only the new API. No passage mentions an old form, says what changed, or explains a migration ("no longer", "used to", "previously", "formerly", "instead of", "deprecated", "was renamed"). Every documented error string and example matches step 3's code exactly: copy each error from the code (`crates/runvil-internal/lua/src/tools/offer.rs`, `lua/src/tools.rs`, `lua/src/tools/decode.rs`, `lua/src/packs.rs`, `lua/src/vm/install.rs`, `lua/src/__impl_tasks.lua`, `crates/runvil-plugin/src/registry.rs`, `parser/src/contract.rs`, `parser/src/parse.rs`, `engine/src/error.rs`, and `engine/src/execute/requirements.rs`) and use the lists below as a cross-check. + +The surface to document: + +- A section's `tools` table holds exactly `adhoc`, `call`, `get`, `offer`, `offer_dynamic`, and `packs`, and `calls` appears at the section's first tool call. There is no `packs` global; the Engine has 20 globals, and 62 names are reserved. +- The forms, each behaving as its comment says (this is also the reference listing the chapter shows): + +````lua +-- DEFINE. tools.adhoc(attrs, f) creates a Lua tool and returns its tool object. It does not offer it. +local grab = tools.adhoc({ + name = 'grab', -- the wire name; the id is adhoc/grab + description = 'Grab a value', + parameters = { value = 'string' }, -- optional; a type name or { type, description } per parameter, or a JSON Schema object +}, function(args) + return 'got ' .. args.value +end) +local find = tools.adhoc({ name = 'find', description = 'Find notes', parameters = { query = 'string', limit = { 'integer', 'maximum hits' } } }, function(args) + return 'found ' .. args.query +end) +local none = tools.adhoc({ name = 'none', description = 'No parameters' }, function() end) +local shout = tools.adhoc({ name = 'Shout', description = 'Uppercase text', parameters = { text = 'string' } }, function(args) -- id adhoc/shout, wire name Shout + return args.text:upper() +end) +local t = tools.get('web/search') -- attrs are plain data, so a catalog tool can be wrapped; t.parameters is a JSON Schema object +local search = tools.adhoc({ name = 'search', description = t.description, parameters = t.parameters }, function(args) + return tools.call(t, args) +end) + +-- OFFER. Checked when made, remembered, rendered in call order at the first loop round. +tools.offer('web/search') -- a tool by id: a string with a slash +tools.offer('web') -- a Pack by id: a string without a slash +tools.offer(grab) -- a tool object +tools.offer(tools.packs.get('web')) -- a Pack object +tools.offer(tools.get('web/search').pack) -- the Pack a tool belongs to +tools.offer({ 'web/search', grab, 'subagents', tools.packs.get('user-input') }) -- an array mixing every form +tools.offer(tools.packs.get('web').tools) -- a Pack's tools, one by one +tools.offer(tools.packs.get('adhoc')) -- the adhoc Pack: every adhoc tool defined by the render +tools.offer(tools.packs.extras()) -- the opt-in extras: every Pack not declared under uses:, never adhoc +tools.offer(tools.packs) -- every Pack the section knows, adhoc included +tools.offer() -- no argument, offers nothing +tools.offer({}) -- empty array, offers nothing + +-- OFFER DYNAMIC. The same forms and checks; recorded into the dynamic list at render, which nothing reads yet. +tools.offer_dynamic('user-input') +tools.offer_dynamic(tools.packs.extras()) +tools.offer_dynamic({ 'web', grab }) + +-- READ +for _, pack in ipairs(tools.packs) do end -- every Pack, in name order, adhoc included +#tools.packs -- how many Packs +local web = tools.packs.get('web') -- a Pack object, or nil when the run has no such Pack +local extras = tools.packs.extras() -- a fresh array of the extra Pack objects, in name order +web.name -- 'web' +web.required -- true when declared under uses:, always false for adhoc +web.tools -- a fresh array of the Pack's tool objects, in id order +#web.tools > 0 -- "does this Pack have tools" +local tool = tools.get('web/search') -- a tool object, or nil; tools.get('adhoc/grab') == grab +tool.id -- 'web/search', or 'adhoc/grab' +tool.description -- the catalog text, or the attrs description +tool.parameters -- a fresh table holding the parameter schema +tool.pack -- tools.packs.get('web'), or the adhoc Pack + +-- CALL AND COUNT +tools.call('web/search', { query = 'lua' }) -- by id +tools.call(grab, { value = 'hi' }) -- by tool object, offered or not +tools.call('adhoc/grab', { value = 'hi' }) +tools.calls['adhoc/grab'] -- calls so far, by id; tools.calls appears at the section's first tool call + +-- REFUSED. Each raises. +tools.offer('wbe') -- no Pack named wbe +tools.offer('web_search') -- no Pack named web_search; suggests 'web/search' +tools.offer('web/serch') -- not a tool in this run +tools.offer('mcp') -- a Pack that cannot serve: the Host's reason +tools.offer('web/search', 'web/fetch') -- one argument only +tools.offer(42) -- not a tool, a Pack, or an array of them +tools.offer(nil) -- nil is not a tool or a Pack; a lookup that may miss is guarded with if +tools.offer(tools.get('web/serch')) -- the same: tools.get returned nil +tools.offer({ 'web', nil, 'mcp' }) -- an array has no gaps +tools.offer({ name = 'web' }) -- an array has only the keys 1 to #t +tools.offer({ { 'web' } }) -- arrays do not nest +tools.packs.get({ 'web', 'subagents' }) -- get takes one Pack id +tools.packs.get = nil -- tools.packs is read-only +tools.adhoc('grab', function() end) -- attrs must be a table +tools.adhoc({ name = 'x', description = 'y' }) -- the second argument must be a function +tools.adhoc({ name = 'x', description = 'y' }, function() end, 'z') -- two arguments only +tools.adhoc({ name = 'grab', description = 'again' }, function() end) -- the name grab is taken +tools.adhoc({ name = 'GRAB', description = 'again' }, function() end) -- its id, adhoc/grab, is taken +tools.adhoc({ name = 'x', description = 'y', run = 1 }, function() end) -- unknown key run +tools.adhoc({ description = 'y' }, function() end) -- name is required +tools.adhoc({ name = 'x', description = 'y', parameters = 'string' }, function() end) -- parameters must be a table +tools.call('grab', { value = 'hi' }) -- a string without a slash is not a tool id +-- any tools.offer or tools.offer_dynamic after the section's first models.loop round: the tool list is fixed +```` + +- Offers: one argument, told apart by shape (a slash means a tool id, since Pack ids are one segment and tool ids two or more); checked when made and only remembered; the render refusal comes first; every entry is checked before any is recorded; an identical entry is skipped; no argument offers nothing, while an explicit `nil` raises, so a lookup that may miss (`tools.packs.get`, `tools.get`) is guarded with `if`; an array is `#t` entries with no gaps, no nesting, and no keys outside `1..#t`. +- Ordering, with this worked example: the first loop round walks the static offers in order, appends each tool, and skips tools already in the list; a Pack adds its tools in id order, expanded at render, so `tools.offer(tools.packs.get('adhoc'))` includes adhoc tools defined before the render while `tools.offer(tools.packs.get('adhoc').tools)` takes only those defined at the call. `tools.offer('web')` then `tools.offer('web/search')` renders `web_fetch`, `web_search`; reversed, `web_search`, `web_fetch`; `tools.offer(grab)` then `tools.offer('web')` renders `grab`, `web_fetch`, `web_search`. The list never changes after the first render. +- Shadowing: an offered adhoc tool and an offered catalog tool with the same wire name render the adhoc tool, and the catalog tool is dropped wherever it was offered; a defined but unoffered adhoc tool shadows nothing. +- `tools.offer_dynamic`: the same forms and checks, kept in a dynamic list that the model cannot reach yet; static wins in either order (`tools.offer('web/search')` with `tools.offer_dynamic('web')` gives static `web_search` and dynamic `web/fetch`), and dynamic offers never change what the model sees. +- `tools.packs`: integer walking, `#`, `ipairs`, and `pairs` in name order, the adhoc Pack included; `get(name)` returns a Pack object or nil; `extras()` returns a fresh array of usable Packs not declared under `uses:`, in name order, never the adhoc Pack; extras are a per-section opt-in, off by default, and a Pack that cannot serve is never an extra; any other key reads nil, writes raise, and the metatable is protected. Pack objects have exactly `name`, `tools`, and `required`; tool objects have exactly `id`, `description`, `parameters`, and `pack`, so no author-chosen name ever meets a field. +- `tool.parameters`: a fresh table on every read, converted the way `json.decode` converts, never wrapped. +- Adhoc tools: `tools.adhoc(attrs, f)` with exactly `name` (required, `[A-Za-z][A-Za-z0-9_-]{0,63}`), `description` (required), and `parameters` (optional: `type = 'object'` means a JSON Schema object used as written, anything else is the short form, so `{ type = 'string' }` is a parameter named `type`; none gives `{"type":"object","properties":{},"required":[]}`); checked on the `tools.adhoc` line; id `adhoc/`, wire name `name` as written; registered, not offered; definable after the render; trusted output unless its own function fetched a tool listing during the call; a result its function got from another untrusted tool is already wrapped and reaches the model byte for byte as the direct call's would. The adhoc Pack exists in every section from the start, the H1 pass included, with `required` false. `tools.call` takes an id or a tool object; `tools.calls` counts by id, seeded at 0 for every rendered tool, and a defined but unoffered adhoc tool gains its count at its first call. The jump refusal inside an adhoc tool's function reads `jump is unavailable inside an adhoc tool handler: return a value from the handler and call jump from the block after the tool call returns`. +- Errors (`` is `tools.offer` or `tools.offer_dynamic`): `: the section's tool list is fixed at its first models.loop round; make every offer before the first loop, or offer in another section`; `: no Pack named "" in this run; declare it under uses: to require it, or offer it only when tools.packs.get("") returns it`, plus ` (did you mean ''?)` when a tool in the offering or a defined adhoc tool has the wire name ``; `: "" is not a tool in this run`; an offer string that is not a valid id takes the message of an unknown one of its shape; `: the Pack is unavailable in this run: `; ` takes tool ids, Pack ids, tool objects, Pack objects, or an array of them, got `; ` takes one tool or Pack, or an array of them, got extra `; ` takes tool ids, Pack ids, tool objects, or Pack objects in an array, got at index `; ` takes an array, got a table with the key `; `tools.packs.get takes one Pack id, got `; `tools.packs is read-only`; Lua's `cannot change a protected metatable`; `tools.adhoc takes an attrs table first, got `; `tools.adhoc takes the tool's function second, got `; `tools.adhoc takes two arguments, got extra `; `tools.adhoc: unknown key ""; accepted keys are name, description, parameters`; `tools.adhoc: "" is required`; `tools.adhoc: "" must be a string, got `; `tools.adhoc: invalid name "": expected [A-Za-z][A-Za-z0-9_-]{0,63}`; `tools.adhoc: parameters must be a table, got `; `tools.adhoc: parameters schema cannot be encoded as JSON: `; `tools.adhoc name "" is already registered`; `tools.adhoc name "" has the id adhoc/, which "" already holds`; the short-form parameter-type errors under the `tools.adhoc` prefix; `tools.call takes a tool id or a tool object, got `; `tools.call: "" is not a tool id`; the `tools.calls` hint ` (an adhoc tool that was neither offered in this section nor called with tools.call)`; ``Pack `` cannot take the Pack id adhoc, which holds a section's own tools`` from the Pack registry; `invalid frontmatter: adhoc holds a section's own tools and is never declared under uses` for a `uses:` entry `adhoc` or `/adhoc`; and the refusal notice line `- the run's tool catalog or Pack roster names the Pack id adhoc, which holds a section's own tools`. +- Vendors: tool-call and tool-result events for a catalog tool carry `vendor`, the vendor of the Pack that answered (`runvil` for a bare `uses:` entry and for an undeclared Pack, `acme` for an `acme/kit` entry); `vendor` plus `tool` gives the vendor-qualified name, joined only when `vendor` is present; adhoc tools record no vendor and carry their id in `tool`; Lua never sees vendors. +- Tool listings: tool and Pack metadata is never wrapped; a script receives a tool listing such as `mcp/get_dynamic_tools` unwrapped while a model's call stays wrapped; an adhoc tool whose own function fetched a listing during its call returns untrusted, so a wrapper of the listing tool gives the model the same envelope as a direct call; a listing a script keeps (in a variable or the store), or gets back from a called section or a task, and later returns from an adhoc tool or puts in a message, reaches the model as the author wrote it, which is the author's responsibility. + +Edits come in four labeled parts. Line hints are approximate at `e1a469c`, so find each passage by its content. + +**Part A, this plan's surface (steps 1 to 3).** + +- `src/language/12-tools.md`: + - the `tools` members table and its prose (about lines 620-631) list exactly `adhoc`, `call`, `get`, `offer`, `offer_dynamic`, and `packs`, plus `calls` from the section's first tool call; + - one `tools.packs` section (`get`, `extras()`, integer walking, the adhoc Pack, Pack fields with `required`); + - a `parameters` row in the Tool object table; + - one "Offering tools and Packs with `tools.offer`" section with the string-shape rule and every error; + - a short `tools.offer_dynamic` section saying the model cannot reach dynamic tools yet; + - the ordering rules and the worked example; + - the adhoc chapter, replacing the local tools chapter (about lines 1418-1992, plus the passages near lines 561 and 1154), with both `parameters` forms; + - the forms listing as a reference; + - delete the `tools.required`/`tools.extras` section and the `packs` global sections; + - where wire names are explained, say the Engine chooses them, and tell authors not to name tools in prose by wire name; + - in "Events from the model's tool calls" (about lines 2190-2196), the `vendor` field on tool calls and tool results, with `runvil` for the `web_search` example, and no vendor for adhoc tools, whose calls and results carry `tool` as `adhoc/` where line 2192 says a local call names no tool id; + - in "What a tool returns" (about line 1283), the tool-listing rules above, and in "Tool objects" and "The untrusted envelope" (lines 537 and 1363), the rule that `tool.id`, `tool.description`, `tool.parameters`, `tool.pack`, and a Pack's `name`, `required`, and `tools` are never wrapped, whatever the Pack's output trust; + - in "Tools, Packs, and the `uses:` key": line 56 links the members section, and line 125's `packs.extras()` and `tools.extras()` sentence becomes `tools.packs.extras()`, whose Pack objects list their tools in `tools`; + - "Pack ids" (line 218) says the Pack id `adhoc` holds a section's own tools, so no Pack of any vendor takes it and the Pack registry refuses one with ``Pack `` cannot take the Pack id adhoc, which holds a section's own tools``; "Errors in the `uses:` key" (lines 131-165) adds, beside the repeated-entry message, that a `uses:` entry `adhoc` or `/adhoc` fails to parse with `invalid frontmatter: adhoc holds a section's own tools and is never declared under uses`; + - the "Owner check" example (lines 255-262) walks `for _, pack in ipairs(tools.packs) do for _, tool in ipairs(pack.tools) do ... end end` in place of `tools.required()` and `tools.extras()`; + - line 290: a string without a slash is a Pack id, so `tools.offer('web_search')` raises the unknown-Pack message with its ` (did you mean 'web/search'?)` hint, and a malformed id takes the message of an unknown one of its shape; + - "Name errors and their reasons" (lines 292-318): delete line 317's `packs.offer` message; no offer message carries an id reason, so drop the Pack-id row of the reasons table (line 306) and the sentences about it (lines 302 and 312), unless step 3's `lua/src/tools/offer.rs` or `lua/src/packs.rs` still reports that reason to a prompt; + - the kept parts of "Tool scope" (lines 356-374): line 356 names only `tools.offer`; line 360 drops "no task allowlist", `packs.offer`, and `tools.allow_tasks`; "What the scope holds" (lines 362-368) becomes one ordered list of offers, each a tool or a Pack with its mode, in call order; line 370 says `tools.offer` and `tools.offer_dynamic` raise after the render while `tools.adhoc` still defines; line 372 says a section that makes no static offer sends its rounds with no tool list, since a defined but unoffered adhoc tool adds nothing; + - the rest of "Tool objects and Pack objects" (lines 508-785): the "Look up" example (line 526) and line 535 use `tools.packs.get`; line 539's "exactly three fields" becomes four, and the table covers adhoc tools too; line 547 says an adhoc tool's `pack` is the adhoc Pack; lines 553-561 say `tools.get` also finds a defined adhoc tool by `adhoc/`, replace line 558's example (a tool left out because its wire name is a task built-in's name) with a tool whose parameter schema no model round can advertise or whose wire name an earlier tool in id order already holds (`offer_refusal` in `engine/src/execute/context-bound.rs`), and turn line 561 into `tools.get('grab')` reading nil while `tools.get('adhoc/grab')` returns the adhoc tool's object; line 584's error block reads `tools.call takes a tool id or a tool object, got {type}`; line 606 names `tools.adhoc`, `tools.offer_dynamic`, and `tools.packs.get` beside `tools.get` and `tools.offer`; line 610 compares `tools.packs.get('web').tools[1] == tools.get('web/fetch')`; "Which Packs have objects" (lines 741-752) uses `tools.packs.extras()`, `pack.required`, and `tools.packs.get`, quotes line 748's error as `tools.offer: the web Pack is unavailable in this run: web needs runvil/search-provider, and this host provides none`, and adds the always-present adhoc Pack, which is not part of the run's roster; "Reading tools and Packs in the H1 pass" (lines 754-785) and its example use `tools.packs`, as in `assert(tools.packs.get('web').required)` and `assert(tools.get('web/fetch').pack == tools.packs.get('web'))`; + - "Calling tools from Lua and counting calls" (lines 1100-1282): line 1132 says `tools.call` reaches a catalog tool or an adhoc tool; "Naming the tool to call" (lines 1151-1157) has two forms, a tool id string (a catalog id or `adhoc/`) and a tool object, with no alias form, and line 1157 lists the four fields; line 1163 takes the same left-out example as line 558; line 1165 says an id that names neither a defined adhoc tool nor a catalog tool fails as `unbound_tool`, while a string without a slash, a wire name such as `web_fetch` or a bare name such as `fetch` included, raises `tools.call: "web_fetch" is not a tool id` at the call, and line 2344 says the same; line 1175 quotes `tools.call takes a tool id or a tool object, got {type}`; "How tool calls are recorded" (lines 1189-1196) adds that a script call's `tool_result` event for a catalog tool carries `vendor` too; lines 1199, 1203, and 1244 count adhoc tools by id, as `tools.calls['adhoc/grab']`; lines 1235 and 1241 say counts are seeded at 0 for every tool the section's offers render to, offered adhoc tools and the tools of a Pack offered with `tools.offer('web')` included, and a defined adhoc tool that was not offered gets its count at its first call; lines 1277-1281 add the ending ` (an adhoc tool that was neither offered in this section nor called with tools.call)` for a defined adhoc tool's id; + - "Trust levels" (line 1361): an adhoc tool's result is trusted unless its own function fetched a tool listing during the call, and the example uses `tools.adhoc`; + - "How the model's tool calls run" (lines 1993-2203): delete line 2044, since no wire name is reserved for a task built-in; rewrite line 2048 so prose names tools by id and never by wire name; lines 2052-2056 describe the render in call order with shadowing, replacing "bound tools first, in scope order, followed by every local tool"; line 2119 says a model call resolves through the section's rendered list, an advertised name mapped to an `adhoc/*` id running the adhoc function and any other running its catalog tool; "Calls that run alone" (lines 2139-2188) says a batch stops at the first call to an adhoc tool, and its `Mixed batch` example defines `grab` with `tools.offer(tools.adhoc(...))` and drops `tools.allow_tasks()` and the `task_status` call; + - "Tool errors" (lines 2204-2422): the table rows at lines 2244-2246 split a string without a slash (the `tools.call: "" is not a tool id` Lua error) from an id that names no tool (`unbound_tool`) and say adhoc; line 2294 says adhoc; lines 2331-2335 drop the four task built-in names and say `unbound_tool` comes from an id that is neither a defined adhoc tool nor a tool in the run's catalog; the `scope-check` example (line 2371) defines `echo` with `tools.offer(tools.adhoc(...))`; line 2391 drops its `task` sentence; line 2400 calls `echo` an adhoc tool; + - "Global names a prelude cannot take" (line 2634) lists the 20 Engine global names, without `packs`. +- `src/language/17-quick-reference.md`: rows 133-137 and 217 and the `tools.required`/`tools.extras` rows, plus rows for `tools.adhoc`, `tools.offer_dynamic`, `tools.packs`, `tools.packs.get`, `tools.packs.extras`, `tool.parameters`, and `pack.required`; the link text and targets on rows 22 and 27 (the renamed name-grammar heading) and rows 131-132 and 208-210 (the renamed Tool object heading); row 101 (`unbound_tool`) reads "A script `tools.call` names an id that is neither a defined adhoc tool nor a catalog tool"; row 213 (`tools.call`) says it takes a tool id or a tool object; row 214 (`tools.calls`) says "keyed by tool id". +- `src/language/05-lua-environment.md`: lines 172, 2439, 2496-2503, and 2633 (and the same link's three other uses); line 174's count of Engine globals, 21 to 20; line 176 drops "`packs` in [Tool objects and Pack objects](12-tools.md#tool-objects-and-pack-objects)" and links `tools` to the section that lists its members; line 178's "no global beyond these 21", 21 to 20; and line 199's reserved-name count, 63 names to 62 and 21 Engine globals to 20. +- `src/language/04-how-a-prompt-runs.md`: line 359 uses `tools.packs.get(id).required` and `tools.packs.extras()` in place of `tools.required()` and `tools.extras()`; lines 361, 397, and 1225 use `tools.offer`, line 397 quoting `tools.offer: the {name} Pack is unavailable in this run: {reason}`; the notice table (lines 545-552) gains the row `- the run's tool catalog or Pack roster names the Pack id adhoc, which holds a section's own tools`, produced by a catalog tool or a roster Pack under the Pack id `adhoc`, and "Pack lines in the notice" (line 554) says so. +- `src/language/02-file-structure.md` (lines 685-819): the heading and lines 687-732 say adhoc tool names follow the grammar (a name has no slash, unlike the tool's id `adhoc/`), with `tools.adhoc`, for its `name`, in the list at line 732; lines 799-811 keep `invalid alias "{alias}": ...` for a role label and give a bad adhoc name's error as `tools.adhoc: invalid name "{name}": expected [A-Za-z][A-Za-z0-9_-]{0,63}`, with the example `pcall(tools.adhoc, { name = 'web/search', description = 'x' }, function() end)`; lines 813-819 become `tools.adhoc name "{name}" is already registered` and add `tools.adhoc name "{name}" has the id adhoc/{lower}, which "{other}" already holds`. +- `src/language/13-web-fetch-and-search.md` (lines 126-135): `tools.offer('web')` and `tools.offer: the web Pack is unavailable in this run: web needs runvil/search-provider, and this host provides none`. +- `src/language/08-jump-and-call.md` (lines 767-778): the refusal reads `jump is unavailable inside an adhoc tool handler: return a value from the handler and call jump from the block after the tool call returns`, and line 778's quoted start reads `jump is unavailable inside an adhoc tool handler`. +- `src/language/01-what-a-prompt-is.md`: the `shout` examples (lines 311-314 and 406-409) become `local shout = tools.adhoc({ name = 'shout', description = 'Returns the text in capital letters.', parameters = { text = 'string' } }, function(args) return string.upper(args.text) end)` and `return tools.call(shout, { text = reply })`, with no offer, since only Lua calls the tool; line 611 says adhoc; line 636 links the new offer section. +- `src/language/16-limits-and-errors.md` line 833: "a `tools.call` names an id that is neither a defined adhoc tool nor a tool in the run's catalog". +- `src/language/06-arguments.md` (line 152 links to the renamed name-grammar heading), `11-conversations.md`, `13-web-fetch-and-search.md`, `04-how-a-prompt-runs.md`, `01-what-a-prompt-is.md`, `02-file-structure.md`, `08-jump-and-call.md`, `09-the-store.md`, `10-models.md`, `15-tasks.md`, `16-limits-and-errors.md`, and `plans/build-language-guide.md`: "local tool" prose becomes adhoc throughout, headings included (`02-file-structure.md` line 685, `08-jump-and-call.md` lines 767 and 1732, and the `12-tools.md` subheadings between lines 648 and 1981). + +**Part B, `tools.allow_tasks`, the task built-ins, and model tasks.** + +- Remove `tools.allow_tasks` and the task built-ins it offers the model (`task`, `task_cancel`, `task_status`, and `await_tasks`), with model tasks and their task notices; the code has none of them: + - `src/language/12-tools.md`: the `tools.allow_tasks` and task built-in passages of "Tool scope and the task built-ins" (line 319), whose heading drops the task built-ins; "Reserved task built-in names" (line 462); "`tools.allow_tasks` errors" (line 480); the members-table row and its prose (lines 625 and 631); line 1055; and the example at line 2160; + - `src/language/15-tasks.md`: line 3; "Cancelling a model task" (line 790); "Model tasks at the owner's end" (line 1117); "Giving the model task tools" (line 2052) through the `await_tasks` sections that end near line 2600; and the model-task sentences elsewhere in the chapter that point at them; + - `src/language/17-quick-reference.md`: lines 212 and 250; + - `plans/build-language-guide.md`: line 181, as part D rewrites it; + - `05-lua-environment.md` (line 1937), `11-conversations.md` (lines 796, 803, 1209, 1215, 1385, 1557, 1567, 1575, 1577, and 2038, and the heading "Task notices and task tools in the list" at line 1573, whose whole section describes model-task notices and task tools), and `16-limits-and-errors.md` (line 833): each such sentence loses the removed part and keeps the rest. + - `11-conversations.md` line 1551 as well: drop "any delivered task notices" and the task tool's answer at `messages[5].content`, and give the message roles without the notice's `user` record; + - `12-tools.md`, beyond the passages above: lines 558 and 1163 (part A's replacement example), line 2044 (deleted in part A), the batch passage at lines 2141-2176 (part A), line 2331, and line 2391; + - `15-tasks.md`, the model-task sentences outside the ranges above: line 93 drops its last sentence, about the model's task built-ins; delete line 552; lines 1089 and 1105 drop the model's notices, and line 1105 says the reason's phrase is what the `TaskAbandoned` trace line renders (`AbandonReason::why` in `types/src/ids.rs`); the `tool_loop_exhausted` row (line 1111) reads "while the task was live", and line 1115 says it is kept apart from `owner_failed` so a report can name the round cap as the cause; line 1333 drops its `await_tasks` sentence; delete lines 1840-1841, and line 1844 drops its model task id sentence; delete line 1996; in "Task events and task errors", lines 2665, 2671-2672, 2684, 2686, and 2690 lose their model-task parts, and "Notice and tool call events" (lines 2692-2700) is deleted, since the code has no `TaskNotice` event and no task built-ins; + - `17-quick-reference.md`: delete the four `Engine built-in` rows (lines 254-257), and row 101 reads as part A gives it; + - the vendored `wg21-paperflow/vendor/runvil` copy and the `runvil-design` reports are out of scope. + +**Part C, task origin.** The code has no task origin, so every passage that gives a task an origin, or counts only author tasks, is removed or corrected. Facts from the code, which this part documents: `tasks.pending()` takes no arguments, and any non-nil argument raises `tasks.pending takes no arguments, got {type}` (`tasks_pending` in `lua/src/__impl_tasks.lua`); a status table holds exactly `target`, `state`, `ok`, `section`, `blocked`, `turns`, `tasks`, `depth`, and `note` (`TaskStatus` in `lua/src/protocol/answer.rs`); `TaskStarted` records `task`, `target`, `input`, `item`, `index`, and `var` (`types/src/event.rs`); and every live task a chain spawned counts toward `tasks_live`, whose message is `chain ended with tasks still live: {ids}; wait on or cancel every task a chain spawns before it ends` (`engine/src/error.rs`, line 218). + +- `src/language/15-tasks.md`: + - line 75: the call form `tasks.pending()`, with no `filter?`; + - "Listing live tasks with `tasks.pending`" (lines 794-815): `tasks.pending()` in line 796, and the filter paragraph, list, and table (lines 802-815) give way to the one error, `tasks.pending takes no arguments, got {type}`; + - lines 904, 1030, 1068, and 1076: `chain ended with tasks still live: ...`; + - line 997 reads "while a task it spawned is still live" and "A leaked task"; delete line 1079 ("Only author-origin tasks count"); line 1081 drops "author" twice; + - "The status table" (lines 1208-1279): delete the `origin` row (line 1215), drop `s.origin` from the example (line 1251), print `Child running nil Child chat 0 0 1 working | Child done true nil nil 1 0 1 working` (line 1275), and drop "`origin` `author`" from line 1278; + - "What `target` and `origin` say" (line 1280) becomes "What `target` says", and line 1282 keeps only its `target` sentences; + - "Task ids, handles, and origins" (line 1744) becomes "Task ids and handles", its opening sentence (line 1746) drops the clause about telling your tasks from the model's, and its links (lines 97, 1171, and 2647) use `#task-ids-and-handles`; + - delete "Author and model origins" (lines 1907-1916); + - in "Task events and task errors", lines 2640, 2652, and 2654 drop the origin; + - line 2714: `tasks.pending` takes no arguments and raises `tasks.pending takes no arguments, got {type}` for any argument. +- `src/language/14-fanout.md` line 1342: delete the sentence, since an arm has no origin. +- `src/language/17-quick-reference.md`: delete rows 164 (`status.origin`) and 200 (`tasks.pending (origin)`); row 98 (`tasks_live`) reads "A chain ends normally while a task it spawned is live"; row 199 (`tasks.pending`) adds that it takes no arguments. + +**Part D, the guide's build plan (drift outside this plan's surface).** `plans/build-language-guide.md` describes the language surface in its `chapter-buckets` block and checklist instructions, and these lines name forms the code does not have. Correct them and leave the rest of the file, since line 137's grep list and line 160's design note name old forms as things to check for, not as the surface: + +- line 171: "the name grammar for role labels, arg names, and adhoc tool names"; +- line 173: "prepare and requirements (Packs at prepare, filling model roles, the requirements notice)"; +- line 180: "the `messages` global and its builder methods", since `messages.new` does not exist and `messages['new']` reads nil; +- line 181: "`uses:` declarations and Pack ids, tool ids and wire names, Tool objects and Pack objects, `tools.packs`, `tools.get`, `tools.offer` and `tools.offer_dynamic` with offer order and shadowing, `tools.call`, `tools.calls`, adhoc tools with `tools.adhoc`, tool dispatch inside the loop, tool errors, trusted versus untrusted tool output and tool listings, and Pack preludes"; +- line 182: "the `runvil/web` Pack and its `web/fetch` and `web/search` tools"; +- line 183: "`tasks.fanout`, collections and their order", with the rest of the line kept; +- line 184: drop "the task tools a model can be given, notices delivered to the model"; +- lines 242 and 250: "Pack tools" in place of "Plugin tools"; the four source paths on line 250 exist. + +**All parts.** + +- Inbound links: fix every link to a renamed or deleted heading. Today about 70 links point at five renamed ones: `#defining-local-tools-with-toolsoffer_local` (16), `#offering-tools-with-toolsoffer-and-packsoffer` (20), `#tool-objects-and-pack-objects` (23), `#how-local-tool-handlers-run` (4), and `#the-name-grammar-for-role-labels-arg-names-and-local-tool-aliases` (7). The removed task headings add `#tool-scope-and-the-task-built-ins` (14), `#giving-the-model-task-tools` (7), and `#await_tasks-and-task-notices` (9), and the task-origin headings add `#task-ids-handles-and-origins` (6, all in `15-tasks.md`), `#author-and-model-origins` (1), `#notice-and-tool-call-events` (1), and `#what-target-and-origin-say` (0). Every other renamed or deleted heading's slug is checked the same way: the explicit `git grep` in Tests lists the headings that name removed forms, and the generic check after it finds every heading removed since `e1a469c` and fails on any remaining link to it, and on any link in `src` whose fragment names no heading. +- Commit: the step ends with exactly one commit in `c:\Users\Vinnie\cursor\runvil-docs`, on its current branch `master` (HEAD `e1a469c` when planned, one commit ahead of `origin/master`), titled "Document the one offer API and bring the guide up to date", with the executor's normal trailers. Every change from parts A to D is in that commit, and nothing is left uncommitted. Do not push `runvil-docs` or `runvil`. +- Acceptance: the guide documents exactly the surface above, with every error string and example matching step 3's code; no passage names a removed form, `tools.allow_tasks`, a task built-in, a model task, a task notice, or a task origin; a status table lists exactly `target`, `state`, `ok`, `section`, `blocked`, `turns`, `tasks`, `depth`, and `note`; the counts read 20 Engine globals and 62 reserved names; `plans/build-language-guide.md` names only the current surface; `runvil-docs` holds exactly one new commit on `master`, unpushed; every Tests command passes. + + + + diff --git a/vibe/2026-10-24-1-one-offer-api-debt.md b/vibe/2026-10-24-1-one-offer-api-debt.md new file mode 100644 index 000000000..29ad193b4 --- /dev/null +++ b/vibe/2026-10-24-1-one-offer-api-debt.md @@ -0,0 +1,445 @@ +--- +name: One offer API debt removal +overview: "Remove the two debts the one-offer-API run (runvil 890b41faa..8c0d7e4bf) introduced: a script-sized allocation in the offer decoder that can panic or abort the Host, and call counts that re-seed from a live adhoc expansion after the render. Also make tools.adhoc register all or nothing, give a bypassed adhoc catalog tool a clear offer error, make the gateway config UI tests always build their bundle first, and correct their stale notes." +todos: + - id: offer-bound + content: "D1-1: refuse an offer array whose #t exceeds HELD_KEY_LIMIT before allocating, with fail-first tests in a new tools/tests-offer-limits.rs" + status: completed + - id: rendered-seeding + content: "D1-2: once a section is rendered, seed tools.calls from the rendered list's ids; counts take ids; fix the false doc invariant; engine regression test" + status: completed + - id: adhoc-atomic + content: "D1-10: tools.adhoc writes the handler and tool object first and registers last, clearing both if registering fails; fault-injection test" + status: completed + - id: adhoc-object-offer + content: "D1-7: offering a tool object under the adhoc Pack that no adhoc tool holds raises the unknown-tool error at the offer line instead of an internal error at render" + status: completed + - id: config-ui-pretest + content: "F-1: add a pretest build script to crates/gateway/config-ui/ui/package.json; prove npm test passes with dist/ deleted" + status: completed + - id: config-ui-notes + content: "F-2: correct the three stale 'debug cargo build' notes in the config UI tests" + status: completed + - id: docs-offer-limit + content: "D1-1 docs: one runvil-docs commit adding the new refusal to 12-tools.md and a limits row to 17-quick-reference.md" + status: completed + - id: verify + content: Run the focused, workspace, clippy, fmt, API, docs-claims, config UI, and runvil-docs checks in the Testing Plan + status: completed +isProject: false +--- + +# One offer API debt removal + + + +## Product Requirements + +- Scope and target work: + - Repository `c:\Users\Vinnie\cursor\runvil`, branch `master`. Baseline `890b41faa` ("Trim the public API of the Engine"), which is `upstream/master` (`git@github.com:cppalliance/runvil.git`) and the merge base with HEAD. Endpoint and disposition `8c0d7e4bf` (HEAD, clean worktree, not pushed). + - Target commits, all under plan `vibe/2026-10-23-1-one-offer-api.md` except the lockfile commit: `e0e43803a` "Rename local tools to adhoc", `96272d510` "Update tokio and ctutils in lockfile", `f08778d2f` "Add tool parameters, vendors, listing trust; reserve adhoc", `b8bd93528` "Collapse tool offering into tools.offer with adhoc tools", `8c0d7e4bf` "Close plan: one-offer-api". + - Operator-expanded scope: two pre-existing items in `crates/gateway/config-ui/ui` (F-1, F-2), found while verifying that run, and two candidates the analysis rejected, D1-10 and D1-7, which the operator chose to fix. + - Docs: `c:\Users\Vinnie\cursor\runvil-docs`, branch `master`, HEAD `24e8143` ("Document the one offer API and bring the guide up to date"). It gains one commit for the new refusal D1-1 adds. +- Cleanup goals: + - Section Lua can no longer turn a `tools.offer` or `tools.offer_dynamic` call into a Rust panic or a process abort (D1-1). + - `tools.calls` follows its stated contract after the render: a tool outside the rendered list gets no count until its first call (D1-2). + - `tools.adhoc` either defines a tool completely or leaves no trace of it (D1-10). + - A run that ignored the `adhoc` refusal never reaches an internal error by offering a catalog `adhoc/*` tool object; the offer line raises the ordinary unknown-tool error (D1-7). + - The config UI tests never run against a stale bundle, and their notes say how the bundle is built (F-1, F-2). +- Non-goals: no change to the offer forms, the render order, routing, vendors, listings, or any persisted or wire format; no new public Rust API; no change to the pre-render seeding pattern (D1-19); no streaming offer decode. +- Success criteria: + - every regression test in the Testing Plan fails at `8c0d7e4bf` and passes after its fix; + - `cargo nextest run --locked --workspace` (with the Workshop crates run separately, as the project does), both clippy runs, `cargo fmt --all --check`, `xtask api --check`, and the docs-claims scan pass; + - `npm test` in `crates/gateway/config-ui/ui` passes with `dist/` deleted; + - the runvil-docs checks in the Testing Plan pass. + +## Functional Specification + +### Debt Inventory + +- Debt added: + - **D1-1 (introduced by `b8bd93528`): the offer decoder sizes a buffer from a script-chosen `__len` and holds one Lua reference per entry.** + - Evidence: `crates/runvil-internal/lua/src/tools/offer.rs`, `array` (lines 180-209 at `8c0d7e4bf`): `let count = usize::try_from(table.len()?).unwrap_or(0); let mut entries = Vec::with_capacity(count);` runs before any entry is read, and every entry is pushed as a live `mlua::Value` until `targets` converts them (lines 93-98). `Table::len` honors `__len` (mlua 0.12.0). Section Lua can set `__len`: `tools/tests-offering.rs` already does. + - Impact: `__len` returning `math.maxinteger` makes `Vec::with_capacity` panic ("capacity overflow"). `__len` returning `1 << 40` requests at least 16 TiB and aborts the process on Windows and under Linux default overcommit. A plain array of about 1,000,000 or more strings or tool objects exhausts mlua's reference stack and panics ("cannot create a Lua reference, out of auxiliary stack space"); `lua/src/tests/json/limits.rs` already proves that panic for `json.encode` at 1,200,000 entries. In the Workshop, a panic ends one conversation's run (each runs in its own tokio task, `workshop/server/src/agents.rs` line 308); the abort kills the server and every conversation. + - Recurrence: the same cause as the 2026-10-19 JSON plan's C-1, fixed by `6c3d44718` ("Limit held keys and read encoder values lazily"), which introduced `crate::walk_budget::HELD_KEY_LIMIT`. + - Reversal cost: cheap. `array` and `targets` are private; the fix adds one refusal message to the documented error list. + - Target state: an array whose `#t` is greater than `HELD_KEY_LIMIT` (100,000) is refused with ` takes an array of at most 100000 entries, got ` before anything is allocated or read, where `` is `tools.offer` or `tools.offer_dynamic` and `` is the `#t` value as the script reported it. + - **D1-2 (introduced by `b8bd93528`): after the render, counts re-seed from the live adhoc Pack expansion.** + - Evidence: `section_call_counts` (`crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs` lines 189-196) calls `current_tool_bindings(set, runtime, adhoc)` on every catalog and adhoc dispatch, and `script_call_counts` (`crates/runvil-internal/engine/src/execute/section_context.rs` lines 201-222) calls `counts.ensure` for every binding returned. `expanded` (`crates/runvil-internal/lua/src/vm/state.rs`) maps an offered adhoc Pack to `adhoc.bindings()`, the live registry, and `tools.adhoc` works after the render. + - Impact: in a section that offered `tools.packs.get('adhoc')` before its first loop round, an adhoc tool defined after that round is not in the rendered list (the model cannot call it), yet after any later tool dispatch `tools.calls['adhoc/late']` reads `0`. The plan's "Calls and counts" contract says it raises `tools.calls: ... has no seeded count` with the adhoc hint. The doc comment on `current_tool_bindings` ("the bindings never change once the list is rendered") is false in the same case. Routing is unaffected: model calls route through the rendered list. + - Related: D1-19 (pre-render seeding at each dispatch) has the same mechanism, predates the target, and stays as it is. + - Reversal cost: cheap; internal to `runvil-lua` and `runvil-engine`. + - Target state: once the section's list is rendered, counts seed only from the rendered list's ids. +- Cheap fixes: none from the analysis. +- Operator-expanded pre-existing items (not debt added by the target): + - **F-1: the config UI tests can run against a stale bundle.** `crates/gateway/config-ui/ui/package.json` defines `build` (`node build.mjs`) and `test` (`node --test "src/**/*.test.mjs"`) but no `pretest`. The tests import the gitignored `dist/app.js` and `dist/app.css`. During the run's final verification a `dist/` built on 10/16, before `ceae9ad0f` (10/21, "Move the gateway nav back into the header"), failed two `tab-bar.test.mjs` tests; a rebuild passed 211 of 211. + - **F-2: three notes say a debug `cargo build` produces `dist/`.** `crates/gateway/config-ui/build.rs` writes the bundle to `$OUT_DIR/ui-dist/` "(never into the repository)". The stale notes: `ui/src/test-support.mjs` line 19, `ui/src/styles/tokens.test.mjs` lines 6-7, `ui/src/main.test.mjs` lines 5-6. `ui/build.mjs` lines 1-5 and `src/assets.rs` line 36 describe the crate's own bundle correctly and stay. +- Operator-promoted rejected candidates (introduced by `b8bd93528`; rejected by the analysis, fixed at the operator's choice): + - **D1-10: `tools.adhoc` registers in three stores without atomicity.** `install_adhoc` (`crates/runvil-internal/lua/src/tools.rs` lines 264-270) calls `adhoc.register(&name, &info)` (the Rust `AdhocTools` push), then `handlers.raw_set(name, handler)`, then `insert_tool_object(lua, &binding)`. The last two allocate in the Lua heap. If either fails after `register` succeeded, for example from a memory error an author's `pcall` survives, the tool has a binding but no handler or object. A redefinition then raises "already registered", and a call to it reaches `Error::Internal("an adhoc tool answer names a registered handler")`. Target state: the handler and the tool object are written first, `register` runs last, and a failed `register` clears both, so a failed `tools.adhoc` leaves no trace. + - **D1-7: a bypassed `adhoc/*` catalog tool reaches an internal error.** `RunContext::prepare` refuses a catalog or roster naming the Pack id `adhoc` through `Requirements::reserved_adhoc`, but the refusal is advisory, and `runvil::Run::new` accepts an unprepared context. Then `install_tool_objects` (`crates/runvil-internal/lua/src/tools/objects.rs` lines 40-52) gives the catalog tool `adhoc/x` an object. `Offering::target` (`crates/runvil-internal/lua/src/tools/offer.rs` lines 105-125) records any `LuaToolHandle` unchecked, and the render fails in `expanded` (`crates/runvil-internal/lua/src/vm/state.rs` lines 178-187) with `Error::Internal("offered tool \"adhoc/x\" has no binding")`. Offering the same id as a string already raises `tools.offer: "adhoc/x" is not a tool in this run` (`offer.rs` `named`, lines 130-144). Target state: the object path checks an `adhoc/*` id against `AdhocTools` the way the string path does, so the offer line raises that same unknown-tool error and the render never sees the id. +- Exposed pre-existing debt: none. +- Rejected candidates (21 remain; the analysis kept 2 of 21, the challenger rejected 4 more of its own, and the operator promoted 2): + - residual-but-acceptable (4): the reserved dynamic-offer scaffold (D1-3); a listing kept outside an adhoc call, which the spec states (D1-4); older records lacking `vendor`, which the spec states (D1-5); pre-render model calls, reachable only through a test hook (D1-6). + - weak/speculative (7): no `runvil` vendor reservation (D1-8); `UsablePack` vendor as a bare string (D1-9); the `(ToolSet, ToolRuntime, AdhocTools)` parameter cluster (D1-11); `AskTool` mirroring `Advertised` (D1-12); the `adhoc` id defined in four crates (D1-13); `insert_tool_object`'s install-order dependency (C-2); `AdhocTools::binding` taking an id or a wire name (C-3). None showed a consequence. + - false (7): stale `Chain.advertised` misrouting (D1-14); a mismatched `MessageList` (D1-15); wrong-call listing marks (D1-16); wrapper schema drift (D1-17); unchecked tool objects (D1-18); case-colliding adhoc ids (C-1); other `__len`-sized buffers (C-4). + - unrelated pre-existing (3): pre-render count seeding (D1-19); Pack objects for unavailable declared Packs (D1-20); the rename commit's labels on unchanged mechanisms (D1-21). + + + + +## Technical Design + +- **D1-1, the offer array bound** (`crates/runvil-internal/lua/src/tools/offer.rs`, 214 lines): + - In `array`, read `let len = table.len()?;` first. If `len` is greater than `HELD_KEY_LIMIT` (import `crate::walk_budget::HELD_KEY_LIMIT`, which is `pub(crate)` and 100,000), return `mlua::Error::external(format!("{verb} takes an array of at most {HELD_KEY_LIMIT} entries, got {len}"))` before `Vec::with_capacity` and before any `get`. Keep the existing behavior for every `len` up to the limit, including a negative `len` reading as an empty array through `usize::try_from(..).unwrap_or(0)`. + - Keep the per-entry checks, the raw-key check, and the rule that every entry is checked before any is recorded. A refusal records nothing. The render refusal still comes first, because `install_offer` checks it before decoding. + - Update `array`'s doc comment to state the bound. + - The new message is part of the documented error contract, so `runvil-docs` gains it (see Docs below). +- **D1-2, seeding from the rendered list:** + - `crates/runvil-internal/lua/src/messages.rs` (454 lines): add `pub(crate) fn rendered_ids(&self) -> Option>`, next to `rendered_target` (lines 223-226). It returns the ids in the rendered list's `dispatch` values, or `None` before the render. It does not include `RenderedTools.dynamic`. + - `crates/runvil-internal/lua/src/vm/state.rs` (221 lines): add `pub fn rendered_ids(&self) -> Option>` on `SectionVm`, delegating to `self.messages`. Change `install_tool_call_counts(&self, bindings: &[ToolBinding])` (line 83) to take `ids: &[ToolId]`. Rewrite the `current_tool_bindings` doc comment (lines 102-111) so it no longer claims the bindings never change once the list is rendered. It should say an offered adhoc Pack expands to the adhoc tools defined when the function runs, and that after the render the Engine reads the rendered list instead. + - `crates/runvil-internal/lua/src/tools.rs` (343 lines): `install_tool_call_counts(lua, bound_tools, adhoc, ids: &[ToolId])` seeds `ToolCallCounts::new(ids.iter().map(ToString::to_string))`. The unseeded-key hints, ` (a catalog tool that was neither offered in this section nor called with tools.call)` and ` (an adhoc tool that was neither offered in this section nor called with tools.call)` (lines 127-130), are unchanged. + - `crates/runvil-internal/engine/src/execute/section_context.rs` (294 lines): `script_call_counts(&mut self, ctx, ids: &[ToolId])` and `install_section_scope(.., ids: &[ToolId])` take ids instead of bindings. Their doc comments say the seeded set is the section's live scope before its first render and its rendered list after. + - `crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs` (358 lines): `section_call_counts` uses `vm.rendered_ids()` when it is `Some`. Otherwise it maps `current_tool_bindings(..)` to ids, as today. Both callers (`prepare_catalog_call`, line 312, and `prepare_adhoc_call` in `tool_call-adhoc.rs`, line 32) are unchanged. + - Test callers of `install_tool_call_counts` pass ids: `crates/runvil-internal/lua/src/tools/tests.rs` line 230 and `crates/runvil-internal/lua/src/tools/tests-adhoc.rs` line 267. + - No public API change: these are internal crates, and `runvil/public-api.txt` must not change. +- **D1-10, all-or-nothing `tools.adhoc`:** + - `crates/runvil-internal/lua/src/vm/adhoc.rs` (71 lines): `AdhocTools::register` takes the prebuilt `ToolBinding` (`pub(crate) fn register(&self, binding: ToolBinding) -> Result<()>`) instead of building it from `name` and `info`. That way the tool object and the registry hold the same binding. + - `crates/runvil-internal/lua/src/tools.rs` `install_adhoc` (lines 264-270), after `adhoc_info`: + - `let binding = ToolBinding::from_info(&name, &info);` + - then `handlers.raw_set(name.as_str(), handler)?;` + - then `let object = insert_tool_object(lua, &binding)?;` + - then `adhoc.register(binding)`. If it fails, set the handler entry and the tool-object entry for the id back to `nil`, and return the error. + - return `object`. + - A failure in the handler write or the object insert leaves `AdhocTools` untouched. A handler entry left behind by a failed object insert is unreachable, because dispatch and the collision checks read only `AdhocTools`, and a redefinition overwrites it. + - Update `install_adhoc`'s doc comment to say the definition is all or nothing. +- **D1-7, the object path checks adhoc ids:** + - `crates/runvil-internal/lua/src/tools/offer.rs` `Offering::target` (lines 105-125): in the `LuaToolHandle` arm, when the handle's id is under the `adhoc` Pack (`is_adhoc(&id.pack())`) and `self.adhoc.binding(&id.to_string())` is `None`, return `mlua::Error::external(format!("{verb}: {name:?} is not a tool in this run"))`. That is the string path's message, with `name` the id text. Every other handle is recorded as before. + - The internal error in `expanded` stays as the invariant check it now is; no offer can reach it. + - No new message, so no docs change. The `adhoc` reservation in the registry, the parser, and `RunContext::prepare` is unchanged. +- **F-1:** add `"pretest": "npm run build"` to the `scripts` in `crates/gateway/config-ui/ui/package.json`, so `npm test` always tests a fresh `dist/`. CI (`.github/workflows/ci.yml` lines 311-317) keeps its separate "Build config UI" step, so a build failure is reported under its own name. +- **F-2:** reword the three notes to say `npm test` builds `dist/` first through the `pretest` script: + - `crates/gateway/config-ui/ui/src/test-support.mjs` line 19 becomes ``/** Imports the built bundle once; `npm test` builds it first through the `pretest` script. */``. + - `crates/gateway/config-ui/ui/src/styles/tokens.test.mjs` lines 6-7 and `crates/gateway/config-ui/ui/src/main.test.mjs` lines 5-6: replace "Run after `npm run build` (a debug `cargo build` also produces dist/)." with "Run with `npm test`, whose `pretest` script builds dist/ first." +- **Docs (`runvil-docs`, one commit on `master`, not pushed):** + - `src/language/12-tools.md`, in "Errors from `tools.offer`" (heading at line 901), after the array-key bullet (line 964), add: ``- An array longer than 100,000 entries raises `tools.offer takes an array of at most 100000 entries, got {n}` before any entry is read, so an array whose `#t` is 100,001 raises `tools.offer takes an array of at most 100000 entries, got 100001`.`` + - `src/language/17-quick-reference.md`, in the limits table beside the `json.encode` key limit row (line 263), add: ``| `tools.offer` array length | 100,000 entries per `tools.offer` or `tools.offer_dynamic` call | fixed | [Errors from `tools.offer`](12-tools.md#errors-from-toolsoffer) |``. + - No em dashes or double dashes. +- Constraints: + - Every Rust file under `src` or `tests` stays at or under 500 lines (`build-ceiling`). + - Under `crates/runvil` and `crates/runvil-internal`, every line passes the docs-claims scan: no "host", "workshop", or "papergate" in any case, no lowercase "harness" except "harness = false" and "test harness", no "Harness-", and no "RunHarness". This covers comments and test strings. + - No comment or doc says what changed or what something used to be. + + + + +## Testing Plan + +- **D1-1 regression tests,** in a new `crates/runvil-internal/lua/src/tools/tests-offer-limits.rs`, wired the way `tests-offering.rs` is and using the same test VM helper over a set that offers `web`: + - Fail first: evaluating `tools.offer(setmetatable({}, { __len = function() return math.maxinteger end }))` returns an error whose text contains `tools.offer takes an array of at most 100000 entries, got 9223372036854775807`, and the same VM then evaluates `tools.offer('web')` without error. At `8c0d7e4bf`, `Vec::with_capacity` panics with "capacity overflow" before any assertion runs. + - Fail first: `local t = {} for i = 1, 1200000 do t[i] = 'web' end tools.offer(t)` returns an error containing `got 1200000`. At `8c0d7e4bf`, holding 1,200,000 references panics with "cannot create a Lua reference, out of auxiliary stack space". This is the size `lua/src/tests/json/limits.rs` uses. + - Boundary: an array of exactly 100,000 `'web'` entries is accepted and offers `web` once, and `tools.offer_dynamic` with 100,001 entries raises `tools.offer_dynamic takes an array of at most 100000 entries, got 100001`. + - Only after the bound lands: `__len` returning `1 << 40` raises the bound error with `got 1099511627776`. Never run this case against `8c0d7e4bf`: there it aborts the test binary, which neither `#[should_panic]` nor `catch_unwind` can observe. + - The existing offer tests in `tools/tests-offering.rs` still pass. They cover the array forms, gaps, stray keys, nested tables, `__len` proxies, and `tools.offer(tools.packs)`. +- **D1-2 regression test,** in `crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs` (282 lines). It drives one `models.loop` round with a scripted model reply that makes no tool call, the way `engine/src/execute/tests/chat_scope.rs` drives a rendered round. Section Lua, in order: + - `local grab = tools.adhoc({ name = 'grab', description = 'Grab' }, function() return 'got' end)`, then `tools.offer(tools.packs.get('adhoc'))`; + - one loop round, which renders the list; + - `tools.adhoc({ name = 'late', description = 'Late' }, function() return 'late' end)`, then `tools.call(grab, {})`; + - `local ok, err = pcall(function() return tools.calls['adhoc/late'] end)`; + - assert `not ok`, and that `tostring(err)` contains both `has no seeded count` and `(an adhoc tool that was neither offered in this section nor called with tools.call)`; + - assert `tools.calls['adhoc/grab'] == 1`. + - At `8c0d7e4bf`, `section_call_counts` re-expands the adhoc Pack to include `late`, so `tools.calls['adhoc/late']` reads `0`, `pcall` succeeds, and `assert(not ok)` fails. + - The existing counts tests still pass: `tool_call_counts_seed_by_id_and_explain_an_unseeded_key` (`lua/src/tools/tests.rs` lines 219-280) and the adhoc-hint assertion in `debug_and_counts.rs` (line 268). +- **D1-10 fault-injection test,** in `crates/runvil-internal/lua/src/tools/tests-adhoc.rs` (272 lines), on the test VM's Lua state: + - save the table stored under the named registry value `runvil.tools.objects` (`TOOL_OBJECTS_REGISTRY` in `tools/objects.rs`; make it `pub(super)` or `pub(crate)` for the test), then set that value to `false`, so `insert_tool_object` fails; + - `pcall(tools.adhoc, { name = 'x', description = 'y' }, function() return 'z' end)` returns false; + - restore the saved table, then assert the VM's `AdhocTools::binding("adhoc/x")` is `None`, and that `tools.adhoc({ name = 'x', description = 'y' }, function() return 'z' end)` now succeeds and returns an object equal to `tools.get('adhoc/x')`. + - At `8c0d7e4bf`, `register` ran before the failed insert, so the binding is `Some` and the second definition raises `tools.adhoc name "x" is already registered`. The `None` assertion fails. +- **D1-7 regression test,** in `crates/runvil-internal/lua/src/tools/tests-offering.rs` (318 lines). Build the test VM over a `ToolSet` whose offering holds a catalog tool `adhoc/x`, as a run that ignored the refusal would. Then: + - `local ok, err = pcall(tools.offer, tools.get('adhoc/x'))`; + - assert `not ok` and that `tostring(err)` contains `tools.offer: "adhoc/x" is not a tool in this run`; + - assert `current_tool_bindings` for the VM returns `Ok` with no `adhoc/x` binding. + - At `8c0d7e4bf`, the offer succeeds, so `assert(not ok)` fails, and the render then returns `Error::Internal("offered tool \"adhoc/x\" has no binding")`. + - An adhoc tool the section defined, offered by object, is still recorded; the existing adhoc offer tests cover it. +- **F-1 check,** in PowerShell from `c:\Users\Vinnie\cursor\runvil\crates\gateway\config-ui\ui`: `Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue; npm test`. At `8c0d7e4bf` this fails, because the tests cannot import the missing `dist/app.js`. After F-1 it builds `dist/` and passes all 211 tests. `npm run typecheck` also passes. +- **F-2 check:** `rg -n "debug \x60cargo build\x60" crates/gateway/config-ui` (from the `runvil` root) prints nothing. +- **Exit checks,** run in PowerShell from `c:\Users\Vinnie\cursor\runvil`; each must exit 0: + - `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types --all-features` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `cargo fmt --all --check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check`, which must report no change + - `node --test crates/workshop/ui/test/docs-claims.mjs` +- **runvil-docs checks:** + - `Get-ChildItem c:\Users\Vinnie\cursor\runvil-docs\src -Recurse -Filter *.md | Select-String -Pattern ([string][char]0x2014)` prints nothing; + - `git -C c:\Users\Vinnie\cursor\runvil-docs grep -n "takes an array of at most 100000 entries" -- src` prints exactly the two new lines, and the message text matches the format string in `offer.rs`; + - the site build exits 0: `Set-Location c:\Users\Vinnie\cursor\runvil; $env:RUNVIL_DOCS='c:\Users\Vinnie\cursor\runvil-docs'; cargo xtask site --books-only`. + + + + +## Decision Record + +- User-resolved architecture choices: + - **D1-1 is fixed by refusing an array longer than `HELD_KEY_LIMIT` (100,000) before anything is allocated.** User: "Refuse arrays longer than HELD_KEY_LIMIT (100,000) before allocating anything." It reuses the limit `json.encode` already applies, one check covers all three crash paths, and raising or removing the limit later breaks no prompt that works under it. + - **The two config UI follow-ups are in scope.** User: "sweep up those two optional follow-ups into the resulting plan." + - **Two rejected candidates are fixed: D1-10 and D1-7.** The operator chose them from the rejected list when asked which rejected debts were worth fixing. D1-9 (validating the `UsablePack` vendor), D1-19 (count seeding before the render), and the rest stay rejected. +- Reversible decisions and consequences: + - D1-1 message text ` takes an array of at most 100000 entries, got `: it follows the other ` takes ...` refusals, prints the limit from the constant, and prints the script's `#t` so a hostile `__len` is visible. + - D1-1 tests go in a new `tests-offer-limits.rs` rather than `tests-offering.rs` (318 lines), which keeps both well under 500 lines and the limit cases together. + - D1-2 seeds from the rendered list's ids once rendered. It is one branch in `section_call_counts`, makes the contract and the doc true, and leaves the pre-render pattern (D1-19) unchanged. Counts take ids rather than bindings, because ids are all they read. + - D1-10 writes the Lua-side entries before the Rust registry, because only the Lua writes can plausibly fail (they allocate in the Lua heap). `register` fails only on a poisoned lock, and it clears both Lua entries when it does. `register` takes the prebuilt binding, so the object and the registry cannot differ. + - D1-7 raises the string path's existing `: "" is not a tool in this run` at the offer line instead of a new message. The two paths then agree, no docs change, and the advisory `adhoc` reservation keeps its report-only design. + - F-1 keeps CI's separate "Build config UI" step, so the build runs twice in that job (about a second) in exchange for a clearly named build failure. + - The design record `vibe/2026-10-23-1-one-offer-api.md` is not amended, because it is history. The new refusal is documented in `runvil-docs`. +- Rejected alternatives: + - D1-1 bound at the number of distinct tools and Packs the section can name: it is data-dependent, and it refuses arrays that are valid today, such as a repeated `'web'`. + - D1-1 streaming decode with no bound: a hostile `__len` with an `__index` would loop about 9.2e18 times under the effectively unlimited `HOOK_BUDGET` (`lua/src/hardening.rs` line 21). + - D1-1 collecting names first and validating later: it brings back the baseline's repeated-string copy exposure, which the endpoint's per-entry validation in `named` removed. + - D1-2 snapshotting the adhoc Pack expansion inside the Lua crate at render time: it needs render state in `ToolRuntime` and duplicates what the rendered list already holds. + - D1-2 rewriting the "Calls and counts" contract to allow live seeding: it changes a user-approved contract to match a defect. + - F-1 removing CI's separate build step: it saves about a second but loses the named build failure. + - D1-10 keeping `register` first and undoing it when a Lua write fails: it needs an `AdhocTools` removal method for a failure the reorder avoids outright. + - D1-7 skipping catalog `adhoc/*` tools in `install_tool_objects`: the embedder's tool would vanish from Lua silently instead of raising a clear error. + - D1-7 making `Run::new` enforce `reserved_adhoc`: it turns one advisory requirement into a mandatory one and changes the public run-start behavior. +- Assumptions and risks: + - D1-1's panic and abort paths were established by reading `offer.rs`, the mlua 0.12.0 source, and the repository's JSON reference-stack test, not by running them. The fail-first tests confirm the panics. The abort case is never run before the fix. + - The 1,200,000-entry test table fits in the 64 MiB Lua heap, at about 16 bytes per array slot. + +### Deferred and Out of Scope + +- D1-19, pre-render seeding at each dispatch: pre-existing and unchanged. Revisit if `tools.calls` must be stable before the first call. +- D1-20, Pack objects for declared Packs the roster marks unavailable: pre-existing. Revisit if a run with an unavailable declared Pack can reach section Lua. +- D1-7's script-call path: with a bypassed refusal, `tools.call('adhoc/x')` still runs the catalog tool, because `tool_route` finds no adhoc binding and routes it as a catalog call. That is not an internal error. Revisit if `RunContext::prepare`'s refusal becomes mandatory. +- D1-9, validating the `UsablePack` vendor as one segment: no consequence today, and the operator left it. Revisit before a release if the public constructor's signature should be fixed for good. +- Streaming the offer decode to one live reference at a time: not needed once the bound holds. Revisit if `HELD_KEY_LIMIT` is raised toward the reference-stack cap. +- Reserving the `runvil` vendor (D1-8): no spoofing path today. Revisit if the Workshop loads third-party plugins. +- The `runvil-docs` commit `24e8143` was not analyzed, because that repository has no `upstream` remote and is outside this target. Revisit with a separate run against `runvil-docs`. +- Pushing either repository. + + + + +## Project Survey + +- Status: complete +- Build command: `cargo build --locked` (the workspace `default-members` build only the gateway, `crates/gateway/app`). The desktop app is `cargo build --locked -p workshop`. The Engine crates alone: `cargo build --locked -p runvil -p runvil-engine -p runvil-lua -p runvil-parser -p runvil-types -p runvil-vfs -p runvil-model-client`. +- Focused test command pattern: `cargo nextest run --locked -p --all-features `, for example `cargo nextest run --locked -p runvil-lua --all-features offer`. For a Workshop crate (`workshop`, `workshop-server`, `workshop-server-api`), drop `--all-features`. For one Node test file: `node --test .test.mjs` from the package directory, after `npm run build` when the file imports `dist/`. +- Component test command pattern: `cargo nextest run --locked -p [-p ...] --all-features`; for this plan's Engine work, `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types --all-features`. Node UI packages: `npm test` in the package directory (`crates/gateway/config-ui/ui`), or `npm test --workspaces --if-present` from `crates/workshop`, with `npm ci` first when `node_modules` is missing. Structural checks: `cargo test -p build-xtask`. +- Full-suite test command: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, then `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. The workspace run includes `build-xtask`'s structural checks. cargo-nextest 0.9.128 is installed; profiles are in `.config/nextest.toml`. +- Linter command: `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, and with the same variable `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`. Clippy covers `cargo check`; the one extra check is `cargo check -p gateway --no-default-features`. Companion gates: + - wording scan: `node --test crates/workshop/ui/test/docs-claims.mjs` (no `npm ci` needed, runs from any directory); + - facade surface: `Remove-Item Env:CARGO_BUILD_WARNINGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check`, compared against `crates/runvil/public-api.txt` (the pinned nightly is installed); + - UI types: `npm run typecheck` in `crates/gateway/config-ui/ui`; + - supply chain: `cargo deny check` (the pre-push hook runs it when installed). +- Formatter check command: `cargo fmt --all --check` (rustfmt `style_edition = "2024"`; the pre-commit hook runs it). +- Docs command: `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`. With the same flags, CI also runs `cargo doc -p runvil --no-deps` (default features), `cargo doc -p harness --no-deps`, and `cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items`. The `runvil-docs` book: `$env:RUNVIL_DOCS='c:\Users\Vinnie\cursor\runvil-docs'; cargo xtask site --books-only`. +- Test placement and naming conventions: + - Unit tests sit in sibling files wired under `#[cfg(test)]` with a path attribute: `foo-tests.rs` or `foo-tests- + + +## Execution Instructions + + + +### Step 1: Build the config UI bundle before its tests [completed] + +- Component: Config UI test build +- Tests: run each in PowerShell as written; each exits 0 unless its note says otherwise. + - `Set-Location c:\Users\Vinnie\cursor\runvil\crates\gateway\config-ui\ui; Remove-Item -Recurse -Force dist -ErrorAction SilentlyContinue; npm test` builds `dist/` through `pretest`, then every test passes (211 of 211 when the plan was written). + - `Set-Location c:\Users\Vinnie\cursor\runvil\crates\gateway\config-ui\ui; npm run typecheck` + - `Set-Location c:\Users\Vinnie\cursor\runvil; rg -n "debug \x60cargo build\x60" crates/gateway/config-ui` prints nothing and exits 1, which is rg's no-match code. + +- Placement: first. This component touches only the Node package in `crates/gateway/config-ui/ui`, shares no file with any other component, and depends on nothing, so it lands ahead of the Rust work. Its two pieces, the `pretest` script (F-1) and the corrected notes (F-2), are built jointly in this one step: the new notes name the `pretest` script, so they are only true once it exists, and the checks above cover both. +- Target: the config UI tests never run against a stale bundle, and their notes say how the bundle is built. Today `package.json` defines `build` (`node build.mjs`) and `test` (`node --test "src/**/*.test.mjs"`) but no `pretest`, and the tests import the gitignored `dist/app.js` and `dist/app.css`. A `dist/` built before `ceae9ad0f` once failed two `tab-bar.test.mjs` tests until a rebuild. +- F-1: in `crates/gateway/config-ui/ui/package.json`, add `"pretest": "npm run build"` to `scripts`, beside `test`. Leave CI's separate "Build config UI" step in `.github/workflows/ci.yml` (lines 311-317) as it is, so a build failure is still reported under its own name; the build then runs twice in that job, about a second, which is the accepted cost. +- F-2: reword the three notes that say a debug `cargo build` produces `dist/`. `crates/gateway/config-ui/build.rs` writes its bundle to `$OUT_DIR/ui-dist/`, never into the repository. + - `crates/gateway/config-ui/ui/src/test-support.mjs` line 19 becomes ``/** Imports the built bundle once; `npm test` builds it first through the `pretest` script. */``. + - In `crates/gateway/config-ui/ui/src/main.test.mjs` (lines 5-6) and `crates/gateway/config-ui/ui/src/styles/tokens.test.mjs` (lines 6-7), the sentence "Run after `npm run build` (a debug `cargo build` also produces dist/)." becomes "Run with `npm test`, whose `pretest` script builds dist/ first." Keep each comment wrapped like its neighboring lines. + - Leave `crates/gateway/config-ui/ui/build.mjs` lines 1-5 and `crates/gateway/config-ui/src/assets.rs` line 36 as they are; they describe the crate's own bundle correctly. +- Fail first: before adding `pretest`, run the first Tests command and confirm it fails because the tests cannot import the missing `dist/app.js`, as it does at `8c0d7e4bf`. Then make both changes. +- Commit `Build the config UI bundle before its tests` with the four files. Do not push. + + + + + +### Step 2: Bound offer arrays at the held key limit [completed] + +- Component: Offer decoder +- Tests: run each in PowerShell from `c:\Users\Vinnie\cursor\runvil` as written; each exits 0. + - `cargo nextest run --locked -p runvil-lua --all-features` + - `cargo fmt --all --check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p runvil-lua --all-targets --all-features` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Placement: the offer decoder is the first Rust component because the docs component documents its new refusal, and it fixes the most severe defect, a script-chosen `__len` that can panic a run or abort the process. It has two pieces, this bound and the adhoc object check in Step 3. They are sequential because both edit `crates/runvil-internal/lua/src/tools/offer.rs`, and the bound goes first because the docs component needs its final message. +- Target: section Lua can no longer turn a `tools.offer` or `tools.offer_dynamic` call into a Rust panic or a process abort. Today `array` sizes `Vec::with_capacity` from `table.len()`, which honors `__len`, before reading any entry, and holds one live `mlua::Value` per entry. `__len` returning `math.maxinteger` panics with "capacity overflow", `1 << 40` requests at least 16 TiB and aborts, and a plain array of about 1,000,000 or more entries exhausts mlua's reference stack and panics with "cannot create a Lua reference, out of auxiliary stack space". In the Workshop the panic ends one conversation's run and the abort kills the server. +- Change, in `crates/runvil-internal/lua/src/tools/offer.rs` `array`: + - Read `table.len()?` first. When it is greater than `crate::walk_budget::HELD_KEY_LIMIT` (`pub(crate)`, a `usize` of 100,000), return `mlua::Error::external(format!("{verb} takes an array of at most {HELD_KEY_LIMIT} entries, got {len}"))` before `Vec::with_capacity` and before any `get`. `len` is the `#t` value as the script reported it, an `i64`, so `math.maxinteger` prints `9223372036854775807`. Compare it with the `usize` limit without a lossy `as` cast. + - Keep the current behavior for every `len` up to the limit, including a negative `len` reading as an empty array through `usize::try_from(..).unwrap_or(0)`. Keep the per-entry checks, the raw-key check, and the rule that every entry is checked before any is recorded, so a refusal records nothing. The rendered-list refusal still comes first, because `install_offer` checks it before decoding. + - Update `array`'s doc comment to state the bound. +- Tests to write, in a new `crates/runvil-internal/lua/src/tools/tests-offer-limits.rs` that opens with a `//!` line naming its concern, wired from `crates/runvil-internal/lua/src/tools/tests.rs` as `#[path = "tests-offer-limits.rs"] mod offer_limits;` beside the other splits. Build each VM with `lua_over(offer_set())` from `crate::test_vm`, whose set offers `web`, and read refusals with `raised` and recorded offers with `offers`: + 1. `tools.offer(setmetatable({}, { __len = function() return math.maxinteger end }))` raises text containing `tools.offer takes an array of at most 100000 entries, got 9223372036854775807`. The same VM then runs `tools.offer('web')` without error, and `offers` is then exactly `static:web`. + 2. `local t = {} for i = 1, 1200000 do t[i] = 'web' end tools.offer(t)` raises text containing `got 1200000`. This is the size `crates/runvil-internal/lua/src/tests/json/limits.rs` uses, and the table fits in the 64 MiB Lua heap. + 3. An array of exactly 100,000 `'web'` entries is accepted, and `offers` is exactly `static:web`. + 4. `tools.offer_dynamic` with an array of 100,001 `'web'` entries raises `tools.offer_dynamic takes an array of at most 100000 entries, got 100001`. + 5. `__len` returning `1 << 40` raises the bound error with `got 1099511627776`. +- Fail first: write cases 1 and 2 alone, run `cargo nextest run --locked -p runvil-lua --all-features offer_limits`, and confirm both fail, with the "capacity overflow" panic and the "out of auxiliary stack space" panic, as they do at `8c0d7e4bf`. Then add the bound and cases 3 to 5. Never run case 5 before the bound lands: it aborts the test binary, which neither `#[should_panic]` nor `catch_unwind` can observe. +- The existing offer tests in `crates/runvil-internal/lua/src/tools/tests-offering.rs` must still pass. They cover the array forms, gaps, stray keys, nested tables, `__len` proxies, and `tools.offer(tools.packs)`. +- Commit `Bound offer arrays at the held key limit` with the code and its tests. Do not push. + + + + + +### Step 3: Refuse offered adhoc objects that no adhoc tool holds [completed] + +- Component: Offer decoder +- Tests: run each in PowerShell from `c:\Users\Vinnie\cursor\runvil` as written; each exits 0. + - `cargo nextest run --locked -p runvil-lua --all-features` + - `cargo fmt --all --check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p runvil-lua --all-targets --all-features` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Placement: the second offer decoder piece, built after Step 2 because both edit `crates/runvil-internal/lua/src/tools/offer.rs` and must not be built in parallel. +- Target: a run that ignored the `adhoc` refusal never reaches an internal error by offering a catalog `adhoc/*` tool object; the offer line raises the ordinary unknown-tool error. Today `RunContext::prepare` refuses a catalog or roster naming the Pack id `adhoc` through `Requirements::reserved_adhoc`, but that refusal is advisory and `runvil::Run::new` accepts an unprepared context. `install_tool_objects` (`crates/runvil-internal/lua/src/tools/objects.rs`) then gives the catalog tool `adhoc/x` an object, `Offering::target` records any `LuaToolHandle` unchecked, and the render fails in `expanded` (`crates/runvil-internal/lua/src/vm/state.rs`) with `Error::Internal("offered tool \"adhoc/x\" has no binding")`. The string form, `tools.offer('adhoc/x')`, already raises `tools.offer: "adhoc/x" is not a tool in this run` from `named`. +- Change, in `crates/runvil-internal/lua/src/tools/offer.rs` `Offering::target`: in the `LuaToolHandle` arm, when the handle's id is under the `adhoc` Pack (`is_adhoc(&id.pack())`) and `self.adhoc.binding(&id.to_string())` is `None`, return `mlua::Error::external(format!("{verb}: {name:?} is not a tool in this run"))` with `name` the id text, which is the string path's message. Map a poisoned-lock error from `binding` with `mlua::Error::external`, as `named` does. Record every other handle as before. +- Leave the internal error in `expanded` as the invariant check it now is, and leave the `adhoc` reservation in the registry, the parser, and `RunContext::prepare` unchanged. The message is not new, so the docs do not change. Do not skip catalog `adhoc/*` tools in `install_tool_objects`, which would make the tool vanish from Lua silently, and do not make `Run::new` enforce `reserved_adhoc`, which would change public run-start behavior. +- Test to write, in `crates/runvil-internal/lua/src/tools/tests-offering.rs`: build the VM with `lua_over` over a `ToolSet`, made with `ToolSet::new` the way `crate::test_vm::offer_set` makes one, whose offering holds the catalog tool `adhoc/x` (for example the binding `crate::ToolBinding::from_info("adhoc_x", &tool_at("adhoc/x"))`), as a run that ignored the refusal would. Then: + - `local ok, err = pcall(tools.offer, tools.get('adhoc/x'))`; + - assert `not ok` and that `tostring(err)` contains `tools.offer: "adhoc/x" is not a tool in this run`; + - assert that `current_tool_bindings` for the VM returns `Ok` with no `adhoc/x` binding (`crate::test_vm::scoped` resolves it and expects `Ok`). +- Fail first: write the test, run `cargo nextest run --locked -p runvil-lua --all-features offering`, and confirm `assert(not ok)` fails, as it does at `8c0d7e4bf`, where the offer succeeds and the render then returns the internal error. Then make the change. +- An adhoc tool the section defined, offered by object, is still recorded; the existing adhoc offer tests, such as the `grab` object in `every_accepted_form_records_each_target_once_in_call_order_under_its_verbs_mode`, cover it. `tests-offering.rs` (332 lines now) stays at or under 500. +- Commit `Refuse offered adhoc objects that no adhoc tool holds`. Do not push. + + + + + +### Step 4: Make tools.adhoc all or nothing [completed] + +- Component: Adhoc definition +- Tests: run each in PowerShell from `c:\Users\Vinnie\cursor\runvil` as written; each exits 0. + - `cargo nextest run --locked -p runvil-lua --all-features` + - `cargo fmt --all --check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy -p runvil-lua --all-targets --all-features` + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Placement: its own component, independent of the offer decoder. It comes before call-count seeding (Step 5) because both edit `crates/runvil-internal/lua/src/tools.rs` and `crates/runvil-internal/lua/src/tools/tests-adhoc.rs`, and this change stays inside `runvil-lua` while Step 5 crosses into `runvil-engine`. One piece, one step. +- Target: `tools.adhoc` either defines a tool completely or leaves no trace of it. Today `install_adhoc` calls `adhoc.register(&name, &info)` (the Rust `AdhocTools` push), then `handlers.raw_set(name, handler)`, then `insert_tool_object(lua, &binding)`. The last two allocate in the Lua heap. If either fails after `register` succeeded, for example from a memory error an author's `pcall` survives, the tool has a binding but no handler or object, a redefinition raises "already registered", and a call reaches `Error::Internal("an adhoc tool answer names a registered handler")`. +- Change: + - `crates/runvil-internal/lua/src/vm/adhoc.rs`: `AdhocTools::register` takes the prebuilt binding, `pub(crate) fn register(&self, binding: ToolBinding) -> Result<()>`, so the tool object and the registry hold the same binding. It still fails only on a poisoned lock. Drop any import it no longer needs. + - `crates/runvil-internal/lua/src/tools.rs` `install_adhoc`, after `adhoc_info`: `let binding = ToolBinding::from_info(&name, &info);`, then `handlers.raw_set(name.as_str(), handler)?;`, then `let object = insert_tool_object(lua, &binding)?;`, then `adhoc.register(binding)`. If `register` fails, set the handler entry for the name and the tool-object entry for the id back to `nil`, and return the error; a small helper beside `insert_tool_object` in `crates/runvil-internal/lua/src/tools/objects.rs` can clear the object entry. Otherwise return `object`. + - A failure in the handler write or the object insert leaves `AdhocTools` untouched. A handler entry left by a failed object insert is unreachable, because dispatch and the collision checks read only `AdhocTools`, and a redefinition overwrites it. + - Update `install_adhoc`'s doc comment to say the definition is all or nothing. + - Make `TOOL_OBJECTS_REGISTRY` in `crates/runvil-internal/lua/src/tools/objects.rs` `pub(super)` or `pub(crate)` so the test can reach it. +- Do not keep `register` first and undo it when a Lua write fails; that needs an `AdhocTools` removal method for a failure the reorder avoids outright. +- Test to write, in `crates/runvil-internal/lua/src/tools/tests-adhoc.rs`, on the test VM's Lua state: + - save the table stored under the named registry value `TOOL_OBJECTS_REGISTRY` (`runvil.tools.objects`), then set that value to `false`, so `insert_tool_object` fails; + - `pcall(tools.adhoc, { name = 'x', description = 'y' }, function() return 'z' end)` returns false; + - restore the saved table, then assert `vm.adhoc_tools().binding("adhoc/x")` is `Ok(None)`, and that `tools.adhoc({ name = 'x', description = 'y' }, function() return 'z' end)` now succeeds and returns an object equal (`==`) to `tools.get('adhoc/x')`. +- Fail first: write the test, run `cargo nextest run --locked -p runvil-lua --all-features adhoc`, and confirm the `None` assertion fails, as it does at `8c0d7e4bf`, where `register` ran before the failed insert, so the binding is `Some` and a second definition raises `tools.adhoc name "x" is already registered`. Then make the change. +- `tests-adhoc.rs` (284 lines now) stays at or under 500. +- Commit `Make tools.adhoc all or nothing`. Do not push. + + + + + +### Step 5: Seed tool call counts from the rendered list [completed] + +- Component: Call-count seeding +- Tests: run each in PowerShell from `c:\Users\Vinnie\cursor\runvil` as written and in this order, because the seventh clears the variables the fifth and sixth set; each exits 0. + - `cargo nextest run --locked -p runvil-lua -p runvil-engine -p runvil-types --all-features` + - `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features` + - `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api` + - `cargo fmt --all --check` + - `$env:CARGO_BUILD_WARNINGS='deny'; cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features` + - `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --locked --no-deps --all-features -p runvil-engine --document-private-items` + - `Remove-Item Env:CARGO_BUILD_WARNINGS, Env:RUSTDOCFLAGS -ErrorAction SilentlyContinue; cargo +nightly-2026-09-05 xtask api --check` reports no change to `crates/runvil/public-api.txt`. + - `node --test crates/workshop/ui/test/docs-claims.mjs` + +- Placement: the last `runvil` component. It spans `runvil-lua` and `runvil-engine` and edits `tools.rs` and `tests-adhoc.rs` after Step 4. Its two pieces, the `runvil-lua` seeding API and the `runvil-engine` dispatch path, are built jointly in this one step, because changing `SectionVm::install_tool_call_counts` to take ids breaks the Engine caller until both land. Every `runvil` item has landed once this step's code is in, so its Tests carry the plan's exit checks. +- Target: `tools.calls` follows its stated contract after the render, so a tool outside the rendered list gets no count until its first call. Today `section_call_counts` (`crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs`) calls `current_tool_bindings` on every catalog and adhoc dispatch, and `script_call_counts` (`crates/runvil-internal/engine/src/execute/section_context.rs`) calls `counts.ensure` for every binding returned. `expanded` maps an offered adhoc Pack to the live adhoc registry, and `tools.adhoc` works after the render. So in a section that offered `tools.packs.get('adhoc')` before its first loop round, an adhoc tool defined after that round, which the model cannot call, reads `0` from `tools.calls` after any later dispatch instead of raising `has no seeded count`. Routing is unaffected. Once the section's list is rendered, counts seed only from the rendered list's ids; seeding before the render (D1-19) stays as it is. +- Change: + - `crates/runvil-internal/lua/src/messages.rs`: add `pub(crate) fn rendered_ids(&self) -> Option>` on `MessageList`, next to `rendered_target`. It returns the ids in the rendered list's `dispatch` values (`Advertised.id`), `None` before the render, and never `RenderedTools.dynamic`. The file is at 490 physical lines. If this function and its import take it over 500 after `cargo fmt`, build the same ids in `SectionVm::rendered_ids` from the existing `MessageList::rendered_tools()` instead and leave `messages.rs` unchanged. + - `crates/runvil-internal/lua/src/vm/state.rs`: add `pub fn rendered_ids(&self) -> Option>` on `SectionVm`, delegating to `self.messages`, and change `install_tool_call_counts(&self, bindings: &[ToolBinding])` to take `ids: &[ToolId]`. Rewrite the `current_tool_bindings` doc comment so it no longer claims the bindings never change once the list is rendered: an offered adhoc Pack expands to the adhoc tools defined when the function runs, and after the render the Engine reads the rendered list instead. + - `crates/runvil-internal/lua/src/tools.rs`: `install_tool_call_counts(lua, bound_tools, adhoc, ids: &[ToolId])` seeds `ToolCallCounts::new(ids.iter().map(ToString::to_string))`. The unseeded-key hints, ` (a catalog tool that was neither offered in this section nor called with tools.call)` and ` (an adhoc tool that was neither offered in this section nor called with tools.call)`, are unchanged. + - `crates/runvil-internal/engine/src/execute/section_context.rs`: `script_call_counts(&mut self, ctx, ids: &[ToolId])` and `install_section_scope(.., ids: &[ToolId])` take ids instead of bindings. Their doc comments say the seeded set is the section's live scope before its first render and its rendered list after. + - `crates/runvil-internal/engine/src/execute/scheduler/tool_call.rs`: `section_call_counts` uses `vm.rendered_ids()` when it is `Some`, and otherwise maps `current_tool_bindings(..)` to ids, as today. Its callers, `prepare_catalog_call` and `prepare_adhoc_call` in `tool_call-adhoc.rs`, are unchanged. + - Test callers pass ids: `crates/runvil-internal/lua/src/tools/tests.rs` (`.install_tool_call_counts(&[echo])`) and `crates/runvil-internal/lua/src/tools/tests-adhoc.rs` (`.install_tool_call_counts(&bound)`). + - No public API change: these are internal crates, and `crates/runvil/public-api.txt` must not change. +- Do not snapshot the adhoc Pack expansion inside `runvil-lua` at render time, which duplicates what the rendered list already holds, and do not reword the "Calls and counts" contract to allow live seeding. +- Test to write, in `crates/runvil-internal/engine/src/execute/tests/debug_and_counts.rs`: drive one `models.loop` round with a scripted model reply that makes no tool call, the way `a_round_advertises_the_section_scope_with_adhoc_tools` in `crates/runvil-internal/engine/src/execute/tests/chat_scope.rs` drives a rendered round. The section Lua, in order: + - `local grab = tools.adhoc({ name = 'grab', description = 'Grab' }, function() return 'got' end)`, then `tools.offer(tools.packs.get('adhoc'))`; + - one loop round, which renders the list; + - `tools.adhoc({ name = 'late', description = 'Late' }, function() return 'late' end)`, then `tools.call(grab, {})`; + - `local ok, err = pcall(function() return tools.calls['adhoc/late'] end)`; + - assert `not ok`, and that `tostring(err)` contains both `has no seeded count` and `(an adhoc tool that was neither offered in this section nor called with tools.call)`; + - assert `tools.calls['adhoc/grab'] == 1`. +- Fail first: write the test, run `cargo nextest run --locked -p runvil-engine --all-features debug_and_counts`, and confirm `assert(not ok)` fails, as it does at `8c0d7e4bf`, where `section_call_counts` re-expands the adhoc Pack to include `late`, so `tools.calls['adhoc/late']` reads `0` and `pcall` succeeds. Then make the change. +- The existing counts tests still pass: `tool_call_counts_seed_by_id_and_explain_an_unseeded_key` in `crates/runvil-internal/lua/src/tools/tests.rs` and the adhoc-hint assertion in `debug_and_counts.rs`. `debug_and_counts.rs` (305 lines now) stays at or under 500. +- Commit `Seed tool call counts from the rendered list`. Do not push. + + + + + +### Step 6: Document the offer array limit [completed] + +- Component: Offer limit docs +- Tests: run each in PowerShell as written; each exits 0. + - `Get-ChildItem c:\Users\Vinnie\cursor\runvil-docs\src -Recurse -Filter *.md | Select-String -Pattern ([string][char]0x2014)` prints nothing. + - `git -C c:\Users\Vinnie\cursor\runvil-docs grep -n "takes an array of at most 100000 entries" -- src` prints exactly one line, the new bullet in `src/language/12-tools.md`. + - `git -C c:\Users\Vinnie\cursor\runvil-docs grep -n "array length | 100,000 entries per" -- src` prints exactly one line, the new row in `src/language/17-quick-reference.md`. + - `rg -n "takes an array of at most" c:\Users\Vinnie\cursor\runvil\crates\runvil-internal\lua\src\tools\offer.rs` prints the format string, and the documented message matches it with `{verb}` as `tools.offer` and `{HELD_KEY_LIMIT}` as `100000`. + - `Set-Location c:\Users\Vinnie\cursor\runvil; $env:RUNVIL_DOCS='c:\Users\Vinnie\cursor\runvil-docs'; cargo xtask site --books-only` + +- Placement: last. It documents Step 2's refusal, so it must match that step's final format string; its site build runs from the finished `runvil` tree; and it commits in another repository. One piece, one step. +- Repository: `c:\Users\Vinnie\cursor\runvil-docs`, branch `master`, HEAD `24e8143` ("Document the one offer API and bring the guide up to date"). This step makes one commit there, no `runvil` commit, and pushes nothing. The design record `vibe/2026-10-23-1-one-offer-api.md` is history and is not amended. +- `src/language/12-tools.md`, in "Errors from `tools.offer`": after the bullet that begins "A table with a key outside `1` to `#t` raises" (line 964), add ``- An array longer than 100,000 entries raises `tools.offer takes an array of at most 100000 entries, got {n}` before any entry is read, so an array whose `#t` is 100,001 raises `tools.offer takes an array of at most 100000 entries, got 100001`.``. The paragraph after that list already says `tools.offer_dynamic` raises every one of these errors under its own name. +- `src/language/17-quick-reference.md`, in the "Limits and defaults" table, add ``| `tools.offer` array length | 100,000 entries per `tools.offer` or `tools.offer_dynamic` call | fixed | [Errors from `tools.offer`](12-tools.md#errors-from-toolsoffer) |``. The table is sorted by limit name, so the row goes last, after `request_timeout`, rather than directly beside the `json.encode` key limit row (line 263) that shares its 100,000. +- No em dashes or double dashes in either addition. +- Commit `Document the offer array limit` in `runvil-docs`. Do not push. + + + +