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