diff --git a/AGENTS.md b/AGENTS.md index 091c50a1..3a61e387 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,11 +4,12 @@ TinyAgents is a Rust 2024 library crate rooted at `Cargo.toml`. Public API exports live in `src/lib.rs`, with the crate-wide error type in `src/error.rs`. -The five surfaces each live in their own module directory: `src/graph/` +The four surfaces each live in their own module directory: `src/graph/` (durable typed state graphs), `src/harness/` (provider-neutral model calls, tools, middleware, streaming), `src/language/` (the declarative `.rag` -blueprint format), `src/registry/` (the named capability catalog), and -`src/repl/` (the imperative `.ragsh` session runtime). +blueprint format), and `src/registry/` (the named capability catalog). +Scripted, interpreter-backed orchestration (a CodeAct/REPL loop) is a host +concern and is deliberately not implemented here. Prefer small, focused modules that do one thing extremely well. New feature areas should live in module directories instead of accumulating broad, @@ -18,18 +19,18 @@ dedicated `types.rs` file and keep module-local unit tests in a dedicated smallest useful API. Two Cargo features gate optional dependencies: `sqlite` (embedded SQLite -checkpointer, `graph::checkpoint::SqliteCheckpointer`) and `repl` (embedded -Rhai engine backing `repl::session`); every other provider and surface is +checkpointer, `graph::checkpoint::SqliteCheckpointer`) and `tools` (the builtin +generic tool family, `harness::tools`); every other provider and surface is compiled in by default. Integration tests are in `tests/`, covering serialization, graph routing, -registry binding, the expressive and REPL languages, streaming, subagents, +registry binding, the expressive language, streaming, subagents, and provider contracts (including live, network-gated tests such as `tests/live_*.rs`). Runnable usage examples are in `examples/`, especially `examples/basic_graph.rs`. Design notes and module-level specifications live in `docs/`, with `docs/spec/README.md` as the top-level architecture reference and `docs/modules/` holding per-surface design docs (`graph/`, -`harness/`, `registry/`, `expressive-language/`, `repl-language/`). A `wiki/` +`harness/`, `registry/`, `expressive-language/`). A `wiki/` git submodule holds the published GitHub wiki pages; do not edit it as part of unrelated work, and commit its pointer update separately when it does change. diff --git a/Cargo.lock b/Cargo.lock index 69eccb00..852d1962 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,20 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "const-random", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.5" @@ -142,26 +128,6 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" -[[package]] -name = "const-random" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" -dependencies = [ - "const-random-macro", -] - -[[package]] -name = "const-random-macro" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -177,12 +143,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.2.2" @@ -726,15 +686,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "no-std-compat" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" -dependencies = [ - "spin", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -749,9 +700,6 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "portable-atomic", -] [[package]] name = "percent-encoding" @@ -789,12 +737,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - [[package]] name = "potential_utf" version = "0.1.5" @@ -991,35 +933,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "rhai" -version = "1.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" -dependencies = [ - "ahash", - "bitflags", - "no-std-compat", - "num-traits", - "once_cell", - "rhai_codegen", - "smallvec", - "smartstring", - "thin-vec", - "web-time", -] - -[[package]] -name = "rhai_codegen" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "ring" version = "0.17.14" @@ -1197,16 +1110,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "siphasher" version = "1.0.3" @@ -1225,17 +1128,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "smartstring" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" -dependencies = [ - "autocfg", - "static_assertions", - "version_check", -] - [[package]] name = "socket2" version = "0.6.4" @@ -1246,12 +1138,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1270,12 +1156,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "subtle" version = "2.6.1" @@ -1337,12 +1217,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "thin-vec" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" - [[package]] name = "thiserror" version = "2.0.19" @@ -1363,15 +1237,6 @@ dependencies = [ "syn 3.0.2", ] -[[package]] -name = "tiny-keccak" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" -dependencies = [ - "crunchy", -] - [[package]] name = "tinyagents" version = "2.1.0" @@ -1384,7 +1249,6 @@ dependencies = [ "futures", "regex", "reqwest", - "rhai", "rusqlite", "serde", "serde_json", @@ -1430,7 +1294,6 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -1594,12 +1457,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index a6dd03da..6f068777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "tinyagents" version = "2.1.0" edition = "2024" license = "GPL-3.0-only" -description = "A recursive language-model (RLM) harness for Rust." +description = "A durable agent + graph harness for Rust." repository = "https://github.com/tinyhumansai/tinyagents" readme = "README.md" keywords = ["llm", "agents", "graph", "langchain", "langgraph"] @@ -59,12 +59,6 @@ reqwest = { version = "0.12", default-features = false, features = [ # not add one, it does not resolve. rusqlite = { version = "0.40", features = ["bundled"], optional = true } -# Optional embedded Rhai scripting engine powering the `.ragsh` REPL session -# runtime (`repl::session`). The `sync` feature makes the engine and its values -# `Send + Sync` so a session can live inside an async graph node. Pulled in only -# by the `repl` feature to keep the default build light. -rhai = { version = "1", features = ["sync"], optional = true } - # Optional builtin tool family for deterministic time/date helpers. # `serde` is required by `harness::session_store`, whose records carry # `DateTime` timestamps across the serde boundary. @@ -75,14 +69,6 @@ chrono-tz = { version = "0.10", optional = true } default = [] # Embedded SQLite-backed checkpointer (`graph::checkpoint::SqliteCheckpointer`). sqlite = ["dep:rusqlite"] -# Embedded Rhai-backed `.ragsh` REPL session runtime (`repl::session`). -repl = ["dep:rhai"] -# Recursive-language-model runtime (`rlm`): a driver model writes code cells -# executed in a sandboxed interpreter (embedded Rhai, or an external Python / -# JavaScript process) whose only host surface is capability calls back into -# the registry (`llm`, `tool`, `agent`). `tokio/process` + `tokio/io-util` -# drive the external interpreter subprocesses. -rlm = ["dep:rhai", "tokio/process", "tokio/io-util"] # Builtin generic tools (`harness::tools`) kept out of the default dependency # graph so host applications can choose whether they want these implementations. tools = ["dep:chrono-tz"] @@ -102,12 +88,3 @@ futures = "0.3" # The OpenAI provider is always compiled, so these build with a plain # `cargo build --examples`; they only need a network call + `OPENAI_API_KEY` # at run time. - -# --- RLM examples (need the `rlm` feature) --- -[[example]] -name = "rlm_rhai" -required-features = ["rlm"] - -[[example]] -name = "rlm_python" -required-features = ["rlm"] diff --git a/README.md b/README.md index b8bb8b86..5c9cb2c7 100644 --- a/README.md +++ b/README.md @@ -11,33 +11,18 @@ License: GPL v3

-**TinyAgents is a recursive language-model (RLM) harness for Rust.** It is a -typed, durable runtime where language models call models, agents call agents, +**TinyAgents is a durable agent and graph harness for Rust.** It is a typed, +checkpointed runtime where language models call models, agents call agents, graphs run graphs, and a model can author, compile, and run the very workflow it is standing inside — all as inspectable, checkpointed, policy-checked Rust. -## What is an RLM, and why recursive? +## Recursion, without an embedded interpreter Most agent frameworks stuff everything into one ever-growing context window and -hope the model copes. **Recursive Language Models (RLMs)** take a different -stance: a long prompt is treated as an external *environment* that the model -explores through a REPL — examining it, decomposing it, and **recursively calling -itself (or sub-models) over snippets** instead of swallowing the whole thing at -once. This mitigates "context rot" and lets effective context exceed the raw -window. - -The idea comes from recent research: - -- **Paper:** "Recursive Language Models," Alex L. Zhang, Tim Kraska, Omar Khattab - (MIT CSAIL), 2025 — [arXiv:2512.24601](https://arxiv.org/abs/2512.24601) -- **Blog:** Alex L. Zhang, "Recursive Language Models" — - -- **Reference implementation:** - -TinyAgents is **inspired by and architected around** the RLM execution model — a -production-shaped Rust harness for building RLM-style systems. It does not claim -to reproduce the paper's benchmark numbers; instead it brings the *execution -model* to Rust as concrete, implemented surfaces: +hope the model copes. TinyAgents takes the other stance: a long task is an +external *environment* that gets decomposed, and the runtime is re-entrant, so a +model can recurse over pieces of it instead of swallowing the whole thing at +once. The concrete surfaces: - **Sub-agents (agents calling agents).** A harness agent is exposed *as a tool* to another agent, so orchestration is literally a model calling a model @@ -45,22 +30,24 @@ model* to Rust as concrete, implemented surfaces: - **Recursion policy + depth tracking.** The runtime tracks `root_run_id` / `parent_run_id`, enforces a recursion limit, and rolls child runs' events, usage, and cost up to the parent as first-class observable runs. -- **Graphs that run graphs.** A node can embed another compiled graph, and the - `.ragsh` REPL can drive a graph from inside a graph node (graph → REPL → - graph). -- **The REPL as the RLM core.** In `.ragsh`, context and prompts are runtime - *values*, not just prompt text. The model writes small programs, inspects their - output, calls sub-models / sub-agents / sub-graphs as functions, and iterates — - the RLM/CodeAct loop. +- **Graphs that run graphs.** A node can embed another compiled graph, so a + whole compiled workflow can appear as a single step inside another one. - **Self-authoring (the deepest recursion).** A model can emit a `.rag` blueprint that compiles through the *same* registry-bound compiler path as a human-authored file, then runs on the *same* runtime the model is already executing in. The harness can describe and re-enter itself. -Two languages, one runtime: `.rag` (declarative blueprint) and `.ragsh` -(imperative REPL) both lower into the exact same `graph` + `harness` types as -hand-written Rust — a language whose programs *are* the runtime that interprets -them. +One language, one runtime: `.rag` blueprints lower into the exact same `graph` + +`harness` types as hand-written Rust — a language whose programs *are* the +runtime that interprets them. + +**Not in this crate, by design:** the scripted CodeAct/REPL loop — an embedded +interpreter (Rhai, Python, JavaScript) executing model-written code cells whose +only host surface is capability calls. That is a host concern. TinyAgents gives +it everything it needs (the capability `registry`, the harness, typed +`SessionId`/`CellId`/`CallId`, and the `repl_agent` node kind for binding a +host-provided scripted node by name) without pulling an interpreter into your +dependency graph. ## Features @@ -72,12 +59,10 @@ them. checkpoints, interrupts, subgraphs, streaming, topology export, and time travel. - **Registry** — a named capability catalog (models, tools, agents, graphs, - stores, middleware, policy) that `.rag` and `.ragsh` bind by name. + stores, middleware, policy) that `.rag` binds by name. - **`.rag` expressive language** — a declarative, side-effect-free blueprint format that compiles (lexer → parser → compiler) into the runtime; the safe boundary for agent-authored plans. -- **`.ragsh` REPL language** — imperative, capability-bound interactive - orchestration; the RLM/CodeAct loop surface. - **Recursion & sub-agents** — agents-as-tools, subgraphs, depth tracking, and a recursion policy so deep call trees stay bounded and observable. - **Durability & checkpoints** — resume long runs, replay history, and travel @@ -92,13 +77,13 @@ them. ## Architecture ```text - +-----------------------+ +-----------------------+ - | .rag blueprint | | .ragsh REPL | - | declarative workflow | | imperative RLM loop | - +-----------+-----------+ +-----------+-----------+ - \ / - \ compile / lower (by name) / - v v + +-----------------------+ + | .rag blueprint | + | declarative workflow | + +-----------+-----------+ + | + | compile / lower (by name) + v +-------------+ +-------------------------------------------+ | Application |------->| Capability Registry | | Rust code | | models | tools | agents | graphs | policy | @@ -148,7 +133,7 @@ The recursion loop — agents call agents, and graphs run graphs: | | depth +1, recursion policy, | | child run rolls up usage/cost +-- loops back --+--- re-enters the runtime ---+ - to Agent Node (graph -> REPL -> graph) + to Agent Node (graph -> subgraph -> graph) ``` ## Quick start @@ -163,7 +148,7 @@ tinyagents = "1.5" The OpenAI (and OpenAI-compatible) provider is compiled in by default; the build stays offline unless you actually make a call. Two optional Cargo features gate heavier dependencies: `sqlite` (embedded SQLite checkpointer) -and `repl` (embedded Rhai engine for the `.ragsh` session runtime). +and `tools` (the builtin generic tool family). To explore locally: @@ -276,12 +261,11 @@ OpenAI-backed examples require `OPENAI_API_KEY` at run time. - [crates.io](https://crates.io/crates/tinyagents) - [docs.rs API reference](https://docs.rs/tinyagents) - [Wiki home](https://github.com/tinyhumansai/tinyagents/wiki) - - [Recursion and the RLM model](https://github.com/tinyhumansai/tinyagents/wiki/Recursion-and-RLM) + - [Recursion and sub-agents](https://github.com/tinyhumansai/tinyagents/wiki/Recursion-and-RLM) - [Harness](https://github.com/tinyhumansai/tinyagents/wiki/Harness) - [Graph runtime](https://github.com/tinyhumansai/tinyagents/wiki/Graph-Runtime) - [Registry](https://github.com/tinyhumansai/tinyagents/wiki/Registry) - [Expressive language `.rag`](https://github.com/tinyhumansai/tinyagents/wiki/Expressive-Language-RAG) - - [REPL language `.ragsh`](https://github.com/tinyhumansai/tinyagents/wiki/REPL-Language-RAGSH) - [Providers](https://github.com/tinyhumansai/tinyagents/wiki/Providers) - [Quick start](https://github.com/tinyhumansai/tinyagents/wiki/Quick-Start) - [Examples](https://github.com/tinyhumansai/tinyagents/wiki/Examples) @@ -320,7 +304,7 @@ for the configuration format. ## Contributing TinyAgents welcomes focused contributions that improve the graph runtime, -harness contracts, the registry, the `.rag` / `.ragsh` languages, provider +harness contracts, the registry, the `.rag` language, provider adapters, tests, examples, and documentation. Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request. diff --git a/docs/modules/expressive-language/README.md b/docs/modules/expressive-language/README.md index 3a7ac20b..c73cf4d5 100644 --- a/docs/modules/expressive-language/README.md +++ b/docs/modules/expressive-language/README.md @@ -9,16 +9,17 @@ hand-written Rust. The graph runtime should not know whether a graph came from Rust builders or a source file. The language is also the safe serialization boundary for agent-authored graph -plans. If a REPL agent proposes a new workflow, that proposal should become +plans. If an orchestrating agent proposes a new workflow, that proposal should +become `.rag` source or an equivalent AST, then pass through the same parser, resolver, registry binding, policy checks, and graph compiler as a human-authored file. Generated topology must never be installed directly into the runtime. This module is intentionally declarative. Interactive scripting and -CodeAct-style recursive execution belong to the -[REPL language module](../repl-language/README.md). A `.rag` file defines graph topology -and bindings; a `.ragsh` session inspects, scripts, and orchestrates harness or -graph calls through capability-bound functions. +CodeAct-style recursive execution are host concerns and are not implemented in +this crate. A `.rag` file defines graph topology and bindings; a host session +inspects, scripts, and orchestrates harness or graph calls through +capability-bound functions of its own. For what the parser/compiler implement today versus what is still aspirational, see [Implementation status](implementation-status.md). @@ -33,8 +34,8 @@ see [Implementation status](implementation-status.md). node templates through registries. - Declare graph input/output shape, state channels, reducer policies, and checkpoint/interrupt policy when the compiled graph supports them. -- Declare commands, fanout sends, joins, subgraphs, sub-agents, and REPL-backed - nodes without embedding arbitrary executable code. +- Declare commands, fanout sends, joins, subgraphs, sub-agents, and + host-script-backed nodes without embedding arbitrary executable code. - Produce inspectable blueprints for registries, UIs, documentation, tests, and generated workflow review. - Preserve source spans for clear errors. @@ -110,13 +111,13 @@ The docs can still describe the language as TinyAgents source. ## Expressiveness Targets The long-term language should cover the graph concepts proven useful in -LangGraph, LangChain agent graphs, OpenHuman's state-machine harness, and RLM +LangGraph, LangChain agent graphs, OpenHuman's state-machine harness, and CodeAct style orchestration: - graph defaults: recursion limits, timeouts, checkpointing, durability, streaming modes, cache policy, steering policy, and concurrency - capabilities: allowed models, tools, agents, graphs, stores, middleware, - retrievers, route functions, node templates, and REPL scripts + retrievers, route functions, node templates, and host scripts - state channels: messages, scratch state, tool calls, artifacts, candidates, usage/cost deltas, interrupt payloads, and custom app fields - reducers: last value, append, aggregate, topic, messages-by-id, barrier, @@ -124,7 +125,7 @@ style orchestration: - routing: direct edges, conditional routes, typed route labels, command goto, `Send` fanout, joins/barriers, parent graph handoff, and terminal output - execution nodes: model, agent loop, tool executor, subgraph, sub-agent, - interrupt, router, map/fanout, join, and REPL agent + interrupt, router, map/fanout, join, and host-script agent - observability: source name, graph id, node ids, tags, metadata, event stream projections, generated-by provenance, and blueprint version - safety: source size limits, policy allowlists, review gates for generated diff --git a/docs/modules/expressive-language/implementation-status.md b/docs/modules/expressive-language/implementation-status.md index de3afe9b..66fed799 100644 --- a/docs/modules/expressive-language/implementation-status.md +++ b/docs/modules/expressive-language/implementation-status.md @@ -50,7 +50,7 @@ Extended (H2): - `agent "name"` — sub-agent reference for a `subagent` node (`NodeSpec::agent`). - `graph "name"` — subgraph reference for a `subgraph` node (`NodeSpec::subgraph`; binding prefers it over the legacy `model` field). -- `script "name"` — REPL script capability for a `repl_agent` node +- `script "name"` — host script capability for a `repl_agent` node (`NodeSpec::script`). Declaration only — never inline code. - `input "mapping"` — input mapping for sub-agent / subgraph nodes. - `command { goto update { key value … } }` — typed command diff --git a/docs/modules/expressive-language/reference.md b/docs/modules/expressive-language/reference.md index 6aa5aa6a..07672302 100644 --- a/docs/modules/expressive-language/reference.md +++ b/docs/modules/expressive-language/reference.md @@ -118,8 +118,9 @@ node, where the policy is actually attached to the run's `SteeringHandle`. ### `repl_agent` -Runs a registered REPL script or model-driven CodeAct loop through the harness -REPL runtime. +Runs a host-provided script or model-driven CodeAct loop, bound by name to a +registered `Script` component. The node implementation is supplied by the host; +this crate ships no interpreter. Supported fields: @@ -175,9 +176,10 @@ Registries: - graph registry for subgraphs - store registry - middleware registry -- REPL script registry +- host script registry -When a graph is generated by a REPL session, the session may call the compiler +When a graph is generated by a host orchestration session, the session may call +the compiler with source text or an AST, but the compiler must use the same registries and policy checks. Generated source can request capabilities only from the allowed set attached to the parent run or registry namespace. diff --git a/docs/modules/graph/README.md b/docs/modules/graph/README.md index 6dc46230..cebc2106 100644 --- a/docs/modules/graph/README.md +++ b/docs/modules/graph/README.md @@ -70,15 +70,15 @@ Rust-specific precedent: HITL, observability events, blueprints, and RPC run control. - TinyAgents should preserve the ergonomic Rust builder surface from these precedents, but the target graph runtime must also be rich enough to be - generated from `.rag`, inspected by UIs, driven from `.ragsh`, and tested with - deterministic state/channel snapshots. + generated from `.rag`, inspected by UIs, driven from a host orchestrator, and + tested with deterministic state/channel snapshots. ## Design Stance The graph module is the stable execution contract below all graph authoring surfaces. A graph may be built by Rust code, loaded from a `.rag` blueprint, -compiled from a REPL cell, generated by an agent, or restored from a registry -record. Once compiled, those origins must converge on the same immutable +compiled from a host orchestration session, generated by an agent, or restored +from a registry record. Once compiled, those origins must converge on the same immutable `CompiledGraph` behavior: - topology is validated before execution @@ -86,7 +86,7 @@ record. Once compiled, those origins must converge on the same immutable - state writes pass through channel reducers - commands and `Send` packets are explicit runtime values - checkpoints and pending writes are owned by the graph runtime -- nested graph, sub-agent, and REPL calls preserve run hierarchy +- nested graph and sub-agent calls preserve run hierarchy - events contain enough source/origin metadata to explain generated graphs Agent-authored graph definitions are allowed only as source for the expressive diff --git a/docs/modules/registry/README.md b/docs/modules/registry/README.md index f19c57a7..0827a075 100644 --- a/docs/modules/registry/README.md +++ b/docs/modules/registry/README.md @@ -118,7 +118,8 @@ live handles. `ComponentKind` partitions the registry namespace and now has **12** variants. Alongside `Model`, `Tool`, `Graph`, `Router`, `Reducer`, `Store`, `Agent`, and -`Script` (a REPL script a `repl_agent` node may reference), four kinds cover the +`Script` (a host-provided script a `repl_agent` node may reference by name), +four kinds cover the runtime's durable roles: | Kind | `as_str` | diff --git a/docs/modules/repl-language/README.md b/docs/modules/repl-language/README.md deleted file mode 100644 index 92d0c8c7..00000000 --- a/docs/modules/repl-language/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# REPL Language Module Specification - -The REPL language is an interactive orchestration layer for TinyAgents. It is -inspired by Recursive Language Models (`rlm`) and CodeAct-style agents, where a -model can write small programs, inspect their output, call sub-models, and -iterate until it has a final answer. - -This module is separate from the expressive language: - -- the expressive language (`.rag`) is a declarative graph definition format -- the REPL language (`.ragsh`) is an imperative session language for inspecting, - scripting, and recursively orchestrating harness and graph runs - -Both layers compile or lower into the same harness and graph runtime. Neither -layer should bypass the model registry, tool registry, graph registry, event -system, recursion policy, or run limits. - -## Detailed Module Docs - -- [Design](design.md) - - [RLM feature map, embedding, safety, events, testkit](operations.md) - -## Responsibilities - -- Provide an interactive session runtime over harness and graph primitives. -- Execute small scripts with a persistent namespace. -- Expose registered models, agents, graphs, tools, stores, and context as - capability-bound functions. -- Let sessions draft, validate, inspect, diff, compile, and optionally register - graph blueprints through the expressive-language compiler. -- Support model-driven CodeAct loops where model output contains fenced REPL - blocks. -- Capture stdout, return values, state changes, model calls, tool calls, graph - calls, errors, and final answers as typed events. -- Support recursive sub-model, sub-agent, and sub-graph calls with depth - tracking. -- Support batched model, agent, and graph calls with bounded concurrency. -- Preserve source spans and session history for diagnostics and replay. -- Provide deterministic test utilities for scripted sessions. - -## Non-Responsibilities - -- It is not a replacement for the declarative graph language. -- It is not a general-purpose unsafe host-code execution layer. -- It does not provide direct filesystem, network, environment variable, or - process access. -- It does not own model provider logic. -- It does not own graph topology or checkpointing. -- It does not allow scripts to call unregistered tools or models. -- It does not install model-generated graph topology directly into the runtime; - generated graphs must pass through the `.rag` compiler and policy checks. - -## Recommended Direction - -Use Rhai for the first in-process REPL runtime and document Python as a future -out-of-process compatibility sandbox. Rhai gives TinyAgents a Rust-native, -capability-bound embedding surface, while Python remains useful for training and -RLM-compatible workflows where the sandbox boundary is explicit. - -Recommended extension: `.ragsh`. diff --git a/docs/modules/repl-language/design.md b/docs/modules/repl-language/design.md deleted file mode 100644 index 168fc42a..00000000 --- a/docs/modules/repl-language/design.md +++ /dev/null @@ -1,469 +0,0 @@ -# REPL Language Module Specification - -Parent module: [REPL language](README.md). - -The REPL language is an interactive orchestration layer for TinyAgents. It is -inspired by Recursive Language Models (`rlm`) and CodeAct-style agents, where a -model can write small programs, inspect their output, call sub-models, and -iterate until it has a final answer. - -This module is separate from the expressive language: - -- the expressive language (`.rag`) is a declarative graph definition format -- the REPL language (`.ragsh`) is an imperative session language for inspecting, - scripting, and recursively orchestrating harness and graph runs - -Both layers compile or lower into the same harness and graph runtime. Neither -layer should bypass the model registry, tool registry, graph registry, event -system, recursion policy, or run limits. - -## Source Inspiration - -Primary references: - -- `alexzhang13/rlm`: -- RLM paper and docs linked from that repository -- Rhai book: -- Rhai sandboxing: -- Rhai operation limits: -- Rhai API docs: - -The useful idea from `rlm` is not Python itself. The useful idea is that context -and intermediate state live in a persistent REPL namespace, while language-model -calls, recursive sub-calls, and tools are exposed as functions inside that -namespace. - -TinyAgents should preserve that programming model while making every capability -explicit and typed at the Rust boundary. - -## Responsibilities - -- Provide an interactive session runtime over harness and graph primitives. -- Execute small scripts with a persistent namespace. -- Expose registered models, agents, graphs, tools, stores, and context as - capability-bound functions. -- Let sessions draft, validate, inspect, diff, compile, and optionally register - graph blueprints through the expressive-language compiler. -- Support model-driven CodeAct loops where model output contains fenced REPL - blocks. -- Capture stdout, return values, state changes, model calls, tool calls, graph - calls, errors, and final answers as typed events. -- Support recursive sub-model, sub-agent, and sub-graph calls with depth - tracking. -- Support batched model, agent, and graph calls with bounded concurrency. -- Preserve source spans and session history for diagnostics and replay. -- Provide deterministic test utilities for scripted sessions. - -## Non-Responsibilities - -- It is not a replacement for the declarative graph language. -- It is not a general-purpose unsafe host-code execution layer. -- It does not provide direct filesystem, network, environment variable, or - process access. -- It does not own model provider logic. -- It does not own graph topology or checkpointing. -- It does not allow scripts to call unregistered tools or models. -- It does not install model-generated graph topology directly into the runtime; - generated graphs must pass through the `.rag` compiler and policy checks. - -## Why Rhai First - -The `rlm` repository uses Python as its default local REPL. Python is effective -for long-context programming because models already know it well, but embedding -Python in Rust would make TinyAgents depend on a large runtime, a separate -sandbox story, and a weaker capability boundary. - -Rhai is a better first fit for TinyAgents because it is an embedded scripting -language for Rust with a small host API. It lets TinyAgents register exactly the -functions and values a script may use. The Rhai book describes Rhai as -sandboxed from the host environment by default, with external access provided by -registered functions. It also supports operation limits through -`Engine::set_max_operations`, which gives TinyAgents a direct way to fail closed -on runaway scripts. - -Rhai tradeoffs: - -- Pros: - - Rust-native embedding. - - No Python interpreter dependency. - - Host-controlled function registration. - - Familiar JavaScript/Rust-like syntax. - - Resource limits such as operation counts. - - Suitable for WASM and other Rust deployment targets. -- Cons: - - Models know Python better than Rhai. - - Rhai is dynamically typed, so TinyAgents must validate values at capability - boundaries. - - Async host functions require an adapter design. - - The default `Engine` is not `Send + Sync` unless configured with the Rhai - `sync` feature, so runtime ownership must be explicit. - -Recommendation: use Rhai for the first in-process REPL runtime and document a -future Python compatibility sandbox as a separate environment backend. - -## Language Extension - -Recommended extension: `.ragsh`. - -Reasoning: - -- pairs naturally with `.rag` -- reads like "TinyAgents shell" -- avoids implying that the syntax is Rust -- leaves room for future non-Rhai backends - -Examples: - -```text -support.rag declarative graph definition -support.ragsh interactive orchestration script -``` - -## Runtime Model - -The REPL runtime is a session around a capability registry. - -```rust -pub struct ReplSession { - pub session_id: SessionId, - pub run_context: RunContext, - pub variables: ReplVariables, - pub capabilities: ReplCapabilities, - pub policy: ReplPolicy, - pub events: EventSink, -} - -pub struct ReplCapabilities { - pub models: ModelRegistry, - pub tools: ToolRegistry, - pub graphs: GraphRegistry, - pub agents: AgentRegistry, - pub stores: StoreRegistry, - pub language: Option>, -} - -pub struct ReplPolicy { - pub max_operations: u64, - pub max_iterations: usize, - pub max_script_bytes: usize, - pub max_output_bytes: usize, - pub max_model_calls: usize, - pub max_tool_calls: usize, - pub max_graph_calls: usize, - pub max_graph_definitions: usize, - pub max_depth: usize, - pub timeout: Option, - pub max_concurrency: usize, - pub generated_graphs_require_review: bool, -} -``` - -The session namespace persists across cells. Each cell produces a `ReplResult`. - -```rust -pub struct ReplResult { - pub stdout: String, - pub value: Option, - pub variables_changed: Vec, - pub calls: Vec, - pub final_answer: Option, - pub elapsed: Duration, -} -``` - -## Built-In Variables - -Initial variables: - -- `context`: user input or context payload -- `state`: current graph or agent state when the REPL is used inside a node -- `messages`: current message list when available -- `history`: prior REPL cells and compacted summaries -- `run`: run metadata such as run id, thread id, tags, and depth -- `answer`: final-answer object or helper function - -The runtime should restore reserved names after each cell, similar to `rlm`. -Scripts may create local variables, but they may not permanently replace core -capabilities such as `model_query` or `graph_run`. - -Reserved names: - -- `model_query` -- `model_query_batched` -- `agent_query` -- `agent_query_batched` -- `graph_run` -- `graph_run_batched` -- `graph_define` -- `graph_validate` -- `graph_compile` -- `graph_diff` -- `graph_register` -- `tool_call` -- `tool_call_batched` -- `emit` -- `show_vars` -- `answer` -- `context` -- `state` -- `messages` -- `history` -- `run` - -## Built-In Functions - -The REPL should expose a small, stable surface. These functions are host -capabilities, not script-native side effects. - -### `model_query` - -Single provider-neutral model call through the harness. - -```rhai -let summary = model_query(#{ - model: "default", - prompt: "Summarize the relevant facts:\n" + context -}); -``` - -Lowering: - -```text -model_query(...) -> ModelRegistry -> ChatModel::invoke -> ModelResponse -``` - -Requirements: - -- validates model alias -- applies harness middleware -- records usage and cost -- emits model events -- increments model-call limits -- returns text by default and structured metadata on request - -### `model_query_batched` - -Bounded concurrent model calls. - -```rhai -let prompts = chunks.map(|chunk| #{ - model: "default", - prompt: "Extract relevant names:\n" + chunk -}); - -let answers = model_query_batched(prompts); -``` - -Requirements: - -- preserves input order -- records per-item failures without losing successful results when policy allows -- respects `max_concurrency` -- rolls usage and cost into the parent run - -### `agent_query` - -Run a registered harness agent loop. - -```rhai -let result = agent_query(#{ - agent: "support_agent", - input: #{ - messages: messages, - notes: notes - } -}); -``` - -Lowering: - -```text -agent_query(...) -> AgentHarness::run -> AgentRun -``` - -Use this when the subtask should have model-tool iteration but does not need a -full graph. - -### `graph_run` - -Run a registered compiled graph. - -```rhai -let run = graph_run(#{ - graph: "approval_flow", - input: state, - thread_id: run.thread_id -}); -``` - -Lowering: - -```text -graph_run(...) -> CompiledGraph::run/resume -> GraphRun -``` - -Use this when the subtask has explicit topology, routing, interrupts, or -checkpointing. - -### `graph_define` - -Create a graph blueprint from `.rag` source without installing it. - -```rhai -let draft = graph_define(#{ - name: "candidate_support_flow", - source: ` -graph candidate_support_flow { - start agent - - node agent { - kind agent - model "default" - tools ["lookup_user"] - routes { - tool_call -> tools - final -> END - } - } - - node tools { - kind tool_executor - next agent - } -} -` -}); -``` - -Lowering: - -```text -graph_define(...) -> LanguageCompiler::parse -> GraphBlueprint -``` - -Requirements: - -- preserves source spans -- records generated-by provenance -- does not compile, register, or run the graph -- counts against `max_graph_definitions` -- rejects source that exceeds policy limits - -### `graph_validate` - -Parse and resolve a graph blueprint against the current capability allowlist. - -```rhai -let diagnostics = graph_validate(draft); -``` - -Requirements: - -- validates syntax, duplicate ids, routes, node kinds, and policies -- checks model, tool, agent, graph, reducer, store, middleware, and script - references against registries -- returns structured diagnostics that can be shown to the model or user -- does not mutate graph registry state - -### `graph_compile` - -Compile a validated blueprint into a `CompiledGraph` value under policy. - -```rhai -let compiled = graph_compile(draft); -``` - -Requirements: - -- uses the same expressive-language compiler as file-backed `.rag` source -- applies parent run capability allowlists -- marks generated graphs as untrusted unless policy says otherwise -- requires review when `generated_graphs_require_review` is true -- emits compiler and graph blueprint events - -### `graph_diff` - -Compare two graph blueprints or a blueprint and a registered graph. - -```rhai -let diff = graph_diff("support_flow", draft); -``` - -Requirements: - -- reports node, edge, channel, policy, capability, and metadata differences -- preserves source locations where available -- redacts prompt or metadata fields according to event policy -- is deterministic for tests and review UIs - -### `graph_register` - -Register a compiled graph under a name only when policy permits it. - -```rhai -graph_register(#{ - name: "candidate_support_flow", - graph: compiled, - review_id: "approval_123" -}); -``` - -Requirements: - -- never accepts raw source directly -- requires a compiled graph -- requires a review token when policy says generated graphs need approval -- emits registry events -- does not grant capabilities beyond the compiled graph's validated bindings - -### `tool_call` - -Call a registered tool by name. - -```rhai -let user = tool_call(#{ - tool: "lookup_user", - arguments: #{ user_id: "usr_123" } -}); -``` - -Requirements: - -- validates the tool exists -- validates arguments against the tool schema -- applies middleware -- emits tool events -- records raw and normalized result values - -### `emit` - -Emit a custom event for tracing and tests. - -```rhai -emit("candidate_selected", #{ id: candidate.id, score: candidate.score }); -``` - -### `answer` - -Mark the session complete. - -```rhai -answer("The account should be escalated to human review."); -``` - -or, if an object style is preferred: - -```rhai -answer.content = "The account should be escalated to human review."; -answer.ready = true; -``` - -The function style should be the default because it is harder to accidentally -partially mutate. - - ---- - -Continues in [`operations.md`](operations.md) (RLM feature map, -CodeAct loop, examples, Rhai embedding plan, Python compatibility -backend, safety, events, diagnostics, testkit, milestones). diff --git a/docs/modules/repl-language/operations.md b/docs/modules/repl-language/operations.md deleted file mode 100644 index 3e4fb858..00000000 --- a/docs/modules/repl-language/operations.md +++ /dev/null @@ -1,351 +0,0 @@ -# REPL Language: RLM Feature Map, Embedding, Safety, Events, Testkit - -Continues from [`design.md`](design.md): RLM feature map, CodeAct -loop, example session/graph node, Rhai embedding plan, Python -compatibility backend, safety, events, diagnostics, testkit, and -implementation milestones. - -## RLM Feature Map - -The goal is to port the useful `rlm` behavior into TinyAgents without porting -Python's unsafe local execution model. - -| `rlm` feature | TinyAgents REPL equivalent | -| --------------------------- | ----------------------------------------------------- | -| Python `context` variable | Rhai `context` variable | -| Python persistent locals | `ReplSession::variables` | -| fenced `repl` blocks | fenced `ragsh` blocks | -| `llm_query` | `model_query` | -| `llm_query_batched` | `model_query_batched` | -| `rlm_query` | `agent_query` or `repl_query` | -| `rlm_query_batched` | `agent_query_batched` or `repl_query_batched` | -| custom Python tools | registered Rust tool capabilities | -| generated Python programs | `.ragsh` cells plus generated `.rag` graph blueprints | -| `SHOW_VARS()` | `show_vars()` | -| `answer["ready"] = True` | `answer(...)` | -| max iterations | `ReplPolicy::max_iterations` for CodeAct loops | -| max depth | graph/harness recursion policy | -| max budget | harness cost policy | -| token compaction | harness summarization feature | -| JSONL trajectory logger | typed event stream plus store backend | -| Docker/cloud REPL isolation | future `PythonSandboxRepl` backend | - -## CodeAct Loop - -A model-driven REPL agent has this lifecycle: - -1. Create `ReplSession`. -2. Load `context`, `state`, `messages`, `history`, and `run` variables. -3. Build a model request explaining the available REPL functions. -4. Invoke the model through the harness. -5. Extract fenced `ragsh` blocks from the assistant message. -6. Execute each block in the REPL session. -7. Capture stdout, changed variables, call records, events, and errors. -8. Append a compact execution result as the next user message. -9. Repeat until `answer(...)` is called or limits are reached. -10. Persist events, usage, cost, and final answer. - -This loop is a harness feature. When used inside a graph node, the graph still -owns node routing, checkpointing, interrupts, recursion depth, and failure -policy. - -If the model writes `.rag` source, the loop should treat it as a graph proposal. -The REPL may validate, diff, compile, and run that proposal only through the -expressive-language compiler and the graph registry policy. This is how an -agent can define its own graph without acquiring arbitrary topology mutation or -host-code execution privileges. - -## Example Session - -```rhai -let lines = context.split("\n"); -let candidates = []; - -for line in lines { - if line.contains("SECRET_NUMBER=") { - candidates.push(line); - } -} - -emit("candidates_found", #{ count: candidates.len() }); - -let result = model_query(#{ - model: "default", - prompt: "Return only the digits from this candidate line:\n" + candidates[0] -}); - -answer(result); -``` - -## Example Graph Node - -```tinyagents -graph support_repl { - start investigate - - node investigate { - kind repl_agent - model "default" - script "support-investigation.ragsh" - tools ["lookup_user", "create_ticket"] - routes { - final -> END - needs_review -> review - } - } - - node review { - kind interrupt - prompt "Approve escalation?" - routes { - approved -> END - rejected -> investigate - } - } -} -``` - -The `repl_agent` node is a harness-backed node template. It may execute a fixed -script, a model-driven CodeAct loop, or a combination where a fixed prologue -sets up variables before the model starts writing cells. - -## Rhai Embedding Plan - -The Rhai runtime should be isolated behind an interface so future Python or WASM -backends can reuse the same TinyAgents semantics. - -```rust -#[async_trait] -pub trait ReplBackend: Send { - async fn execute_cell( - &mut self, - session: &mut ReplSession, - source: SourceCell, - ) -> Result; -} - -pub struct RhaiReplBackend { - engine: rhai::Engine, - ast_cache: AstCache, -} -``` - -Rhai-specific requirements: - -- configure `Engine::set_max_operations` -- disable or avoid unneeded packages -- register only TinyAgents capability functions -- expose data through `Dynamic`, maps, and arrays with explicit conversion -- compile and cache ASTs for repeated scripts -- keep each session's `Scope` separate -- restore reserved names after each cell -- truncate stdout and returned values according to policy -- convert Rhai errors into structured diagnostics with spans - -Async adapter requirement: - -Rhai host functions are easiest to expose as synchronous functions. TinyAgents -model, tool, and graph calls are async. The backend should not hide blocking in -unbounded threads. Use one of these designs: - -1. command recording: Rhai functions create `ReplCommand` values, then the - Rust async runtime executes those commands after the cell -2. blocking bridge: host functions call into a bounded runtime handle with - strict timeouts -3. staged syntax: `let x = model_query(...)` is transformed before evaluation - into host-executed calls - -Recommendation for v1: use a blocking bridge only in examples and tests, but -design the public API around command recording. Command recording is easier to -make deterministic and safer under async graph execution. - -## Python Compatibility Backend - -Python should be a compatibility backend, not the default embedded runtime. - -```rust -pub struct PythonSandboxReplBackend { - sandbox: SandboxClient, -} -``` - -Potential use cases: - -- training model behavior that already expects Python -- local research workflows -- compatibility with RLM-style prompts -- data-heavy scripts where Python libraries are explicitly useful - -Requirements: - -- must run out of process -- must have no direct host filesystem access by default -- must communicate through a framed JSON protocol -- must expose the same TinyAgents capability functions -- must enforce the same `ReplPolicy` -- must emit the same `ReplEvent` stream - -This lets TinyAgents support Python-like RLM ergonomics without making Python a -trusted in-process extension language. - -## Safety - -Safety rules: - -- no arbitrary filesystem access in the default Rhai backend -- no environment variable interpolation from scripts -- no direct network access -- no process spawning -- no unregistered native functions -- bounded script size -- bounded operation count -- bounded output size -- bounded model/tool/graph calls -- bounded recursion depth -- bounded concurrency -- typed conversion at every capability boundary -- redaction before event and store writes - -The REPL is an orchestration surface, not a privilege escalation surface. - -## Events - -The REPL event stream should compose with graph and harness events. - -```rust -pub enum ReplEvent { - SessionStarted { session_id: SessionId, run_id: RunId }, - CellStarted { cell_id: CellId, source_name: String }, - CellStdout { cell_id: CellId, chunk: String }, - CellCompleted { cell_id: CellId, elapsed: Duration }, - CellFailed { cell_id: CellId, diagnostic: Diagnostic }, - VariableChanged { cell_id: CellId, name: String }, - CapabilityCallStarted { cell_id: CellId, call_id: CallId, name: String }, - CapabilityCallCompleted { cell_id: CellId, call_id: CallId }, - GraphBlueprintDefined { cell_id: CellId, graph_name: String }, - GraphBlueprintValidated { cell_id: CellId, graph_name: String }, - GraphBlueprintCompiled { cell_id: CellId, graph_name: String }, - GraphBlueprintRegistered { cell_id: CellId, graph_name: String }, - FinalAnswer { cell_id: CellId, content: String }, - SessionCompleted { session_id: SessionId }, - SessionFailed { session_id: SessionId, error: String }, -} -``` - -When the REPL calls a model, tool, agent, or graph, the child harness/graph -events should preserve: - -- root run id -- parent run id -- cell id -- node id when used inside a graph -- recursion depth -- capability name - -## Diagnostics - -Diagnostics should preserve source spans from scripts and model-generated cells. - -Required errors: - -- invalid script syntax -- unknown capability -- unknown model -- unknown tool -- unknown graph -- invalid graph source -- graph compilation failed -- generated graph review required -- graph registration denied -- invalid arguments -- unsupported value type -- operation limit exceeded -- timeout exceeded -- output limit exceeded -- call limit exceeded -- recursion limit exceeded -- unsafe backend requested -- reserved name overwrite - -Example: - -```text -error[E-ragsh-unknown-tool]: tool `lookup_usr` is not registered - --> support.ragsh:8:18 - | -8 | let user = tool_call(#{ tool: "lookup_usr", arguments: #{ id: id } }); - | ^^^^^^^^^^^^^^^^^^^^^^^^^ unknown tool - | -help: did you mean `lookup_user`? -``` - -## Testkit - -`repl::testkit` should include: - -- fake model capability -- fake tool capability -- fake graph capability -- deterministic event recorder -- script execution helper -- CodeAct loop helper with scripted model responses -- operation-limit assertion -- output-limit assertion -- recursive-call assertion -- batched-call ordering assertion -- golden trajectory fixtures - -## Implementation Milestones - -### R1: Documentation And Types - -- add this module doc -- add `repl` package shape to the spec -- define `ReplSession`, `ReplPolicy`, `ReplResult`, and `ReplEvent` -- no Rhai dependency yet - -### R2: Rhai Prototype - -- add optional `repl-rhai` feature -- embed Rhai behind `ReplBackend` -- support persistent variables -- support `show_vars`, `emit`, and `answer` -- enforce operation and output limits - -### R3: Harness Capabilities - -- add `model_query` -- add `model_query_batched` -- add fake-model tests -- forward harness events through REPL events - -### R4: Tool And Agent Capabilities - -- add `tool_call` -- add `agent_query` -- validate schemas and limits -- record usage and cost rollups - -### R5: Graph Capability - -- add `graph_run` -- add `graph_define`, `graph_validate`, `graph_compile`, `graph_diff`, and - `graph_register` -- support graph-node `kind repl_agent` -- preserve node id, parent run id, and depth in child events -- require generated-graph review gates when policy enables them - -### R6: CodeAct Loop - -- parse fenced `ragsh` blocks from assistant messages -- execute cells iteratively -- append compact execution feedback to model history -- stop on `answer(...)` -- add trajectory logging and tests - -### R7: Python Sandbox Backend - -- add optional out-of-process backend -- expose the same capability protocol -- run RLM-compatible Python scripts under explicit policy -- keep it disabled by default diff --git a/docs/modules/rlm/README.md b/docs/modules/rlm/README.md deleted file mode 100644 index 59b2f88b..00000000 --- a/docs/modules/rlm/README.md +++ /dev/null @@ -1,179 +0,0 @@ -# RLM — the recursive-language-model runtime (`src/rlm/`) - -The `rlm` module (Cargo feature `rlm`) turns a code sandbox into a -recursive-language-model harness: a **driver model** writes code cells, the -cells execute in a **sandboxed interpreter**, and the only host surface the -scripts see is a set of **capability calls back into the registry** — sub-LLM -queries, tools, and sub-agent delegation. Because scripts can call models -(and agents that call models), the loop is recursive by construction, which -is the execution pattern of the RLM literature this crate is architected -around (see the crate-level docs in `src/lib.rs`). - -```text - ┌──────────────────────────────────────────────┐ - task ───► │ RlmRunner: driver model ⇄ code cells ⇄ obs. │ ───► answer - └──────────────┬───────────────────────────────┘ - │ eval(code) - ┌──────────────▼───────────────┐ - │ RlmSession │ - │ ┌──────────┐ HostCall │ - │ │ RlmInter-│ ─────────────► │ RlmHost ──► CapabilityRegistry - │ │ preter │ ◄───────────── │ (policy, counters, depth) - │ └──────────┘ Value/error │ ├─ llm → ChatModel::invoke - └──────────────────────────────┘ ├─ tool → Tool::call - └─ agent → HarnessAgent::run -``` - -## The three layers - -| Layer | Type | What it is | -|---|---|---| -| Interpreter | `RlmInterpreter` (trait) | Pluggable cell execution: `eval_cell`, `set_variable`, `usage_guide`, `shutdown`. State persists across cells like a notebook. | -| Session | `RlmSession` | One interpreter bound to one `RlmHost`; the programmatic "interpreter as an API" surface. Enforces per-cell policy fail-closed. | -| Runner | `RlmRunner` | The model-driven loop: template → system prompt, fenced code cells in, observations out, stop on `final_answer(...)`. | - -Every layer is usable on its own: an embedder that wants its own loop (or a -notebook UI) drives `RlmSession::eval` directly and never touches the runner. - -## Interpreter backends - -- **`InterpreterSpec::Rhai`** (default) — the embedded Rhai engine. This is - the only *hermetic* sandbox: no filesystem, network, or process access - exists inside the engine; the registered capability closures are its whole - world. Bounded by `max_operations`, the cell deadline (`on_progress` - hook), and the blocking bridge around every capability call (the same - fail-closed adapter as `repl::session`). -- **`InterpreterSpec::Python { binary, args }`** — an external CPython child - process (`python3` by default). A bootstrap prelude is injected via `-c`; - cells run in a persistent exec namespace. -- **`InterpreterSpec::Javascript { binary, args }`** — a Node.js child - (`node` by default, prelude via `-e`); cells run in a persistent `vm` - context. -- **`InterpreterSpec::Command { binary, args }`** — any command that speaks - the wire protocol itself (a containerized runner, a jailed interpreter, a - different language). - -### The wire protocol (external backends) - -Line-delimited JSON on the child's stdin/stdout; calls are strictly -sequential so no correlation ids are needed. Child → host: `ready`, -`call {call: HostCall}`, `result {stdout, value, error}`, `var_set`. -Host → child: `eval {code}`, `set_var {name, value}`, -`call_result {ok, value|error}`, `shutdown`. `HostCall` is the shared, -serde-stable call shape (`{"capability": "llm"|"tool"|"agent"|"final_answer", ...}`). -Any runtime that speaks this protocol is a valid backend — that is the -extension point for other harnesses (e.g. openhuman) to bring their own -interpreter. - -### Sandboxing honesty - -The host enforces every policy limit fail-closed for **all** backends (a -child that exceeds its deadline or trips a bound is killed, not asked). But -an external child process has whatever OS access the environment grants it. -For untrusted driver models, either stay on the embedded Rhai backend or run -the external interpreter inside real isolation (container/jail/seccomp) via -`InterpreterSpec::Command`. - -## The capability surface inside scripts - -Identical semantics across languages (spelling varies per usage guide): - -- `llm(prompt)` / `llm({model, prompt, system})` → string — a sub-LLM call; - the unnamed form uses the session's default sub-model. -- `tool(name, args)` → tool result (raw JSON when the tool provides it). - Arguments are validated against the tool's schema at the host boundary. -- `agent(name, input)` → string — delegates to a registered - `HarnessAgent`; a full nested agent run with event fan-out and the shared - recursion-depth guard (`RunConfig::checked_child_depth`). -- `final_answer(text)` — ends the run with this answer. -- `print(...)` / `console.log(...)` — captured and echoed back to the - driver next turn, bounded by `max_output_bytes` (explicit truncation - marker). - -### Error contract - -Script-visible failures (unknown tool, schema mismatch, tool error, provider -error) surface *inside* the script as catchable exceptions (`RlmError` in -Python/JS, `try`/`catch` in Rhai) so the driving model can adapt — that -feedback loop is the point of an RLM. Policy violations (`LimitExceeded`, -`Timeout`, `Cancelled`, `SubAgentDepth`) are **fatal**: the cell aborts (and -an external child is killed) so scripts can never observe and route around -their own resource limits. `rlm::is_fatal` is the classifier. - -## Config-driven runs - -Everything a run needs is one serde document — the integration surface for -external harnesses: - -```json -{ - "interpreter": {"kind": "python", "binary": "/opt/venv/bin/python3"}, - "driver_model": "openai", - "sub_model": "openai", - "template": "context-explorer", - "policy": { - "max_cells": 8, "max_llm_calls": 16, "max_tool_calls": 64, - "max_agent_calls": 8, "max_depth": 8, - "max_script_bytes": 65536, "max_output_bytes": 262144, - "cell_timeout": 90000, "max_operations": 5000000 - } -} -``` - -`RlmConfig::from_json` → `RlmRunner::from_config(config, registry, state)` → -`runner.set_context(json)` (optional) → `runner.run(task)`. - -### Templates - -`TemplateSpec::Named` selects a built-in; `TemplateSpec::Inline` carries a -custom `RlmTemplate` in the config document. Placeholders `{{language}}`, -`{{usage}}`, `{{capabilities}}`, `{{limits}}` are substituted at run time -from the live session (so the prompt always reflects the actual registry and -policy). Built-ins: - -- `general` — solve the task with code; sub-LLMs for fuzzy subproblems. -- `context-explorer` — the RLM long-context pattern: material injected as - the `context` variable, probed programmatically, never printed whole. -- `orchestrator` — decompose and delegate to registered sub-agents. - -## Policy (all fail-closed) - -`RlmPolicy`: `max_cells`, `max_script_bytes`, `max_output_bytes`, -`max_llm_calls`, `max_tool_calls`, `max_agent_calls`, `max_depth`, -`cell_timeout` (ms in JSON), `max_operations` (Rhai only). Counters are -session-cumulative. `RlmCancelFlag` provides sticky external cancellation, -observed mid-script (Rhai `on_progress`), mid-call (blocking bridge), and -before each cell. - -## Runner loop details - -- The driver must reply with exactly one fenced code block; the first fence - is extracted regardless of its info string (models mislabel languages). -- A fence-less reply earns one nudge (it is often raw unfenced code); a - second consecutive fence-less reply is accepted as a prose answer - (`RlmStopReason::ModelAnswered`). -- Observations echo captured stdout, the cell value, and any script error. -- Stop reasons: `Answered` (a cell called `final_answer`), `ModelAnswered`, - `CellBudgetExhausted`. The full per-cell trajectory is returned in - `RlmOutcome::steps`. - -## Tests and examples - -- Unit: `src/rlm/test.rs` (config round-trips, templates, extraction, the - embedded backend, runner loop against `ScriptedModel`). -- E2E: `tests/e2e_rlm.rs` — sub-agent delegation from scripts, real - `python3`/`node` protocol round-trips (skipped when the binary is - missing), fail-closed timeout kill, config-driven runner. -- Live: `tests/live_rlm.rs` — network-gated on `OPENAI_API_KEY`. -- Examples: `examples/rlm_rhai.rs` (context-explorer over expense records), - `examples/rlm_python.rs` (tools + a real sub-agent, external Python). - Run with `cargo run --features rlm --example rlm_rhai`. - -## Relationship to `repl::session` - -`repl::session` is the `.ragsh` interactive orchestration surface: Rhai -only, richer built-ins (graph authoring, batching), REPL-first. `rlm` is the -model-*driven* counterpart: interpreter-pluggable, config-first, and shaped -for embedding in other harnesses. They share design DNA deliberately — the -fail-closed blocking bridge, reserved capability boundary, and policy -posture are the same pattern. diff --git a/docs/spec/README.md b/docs/spec/README.md index cfe17670..57638ff7 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -1,14 +1,16 @@ # TinyAgents System Specification -TinyAgents is a Rust-native LLM application framework inspired by LangChain, -LangGraph, and CodeAct-style recursive language-model runtimes. The system is -organized around five modules: +TinyAgents is a Rust-native LLM application framework inspired by LangChain and +LangGraph. The system is organized around four modules: 1. the harness 2. the graph 3. the registry 4. the expressive language -5. the REPL language + +Scripted, interpreter-backed orchestration (a CodeAct/REPL loop over +model-written code cells) is deliberately a *host* concern built on top of these +modules, not a surface this crate ships. See "Host-side surfaces" below. The goal is to make agent systems easy to define, inspect, run, test, and eventually serialize without hiding the Rust types that make production systems @@ -34,16 +36,34 @@ them: harness-decoupled graph engine, persistent checkpoints, HITL, graph observability, blueprints, JSON-RPC run control, and a behavior-preserving cutover from an implicit turn loop to an explicit phase machine. -- RLM contributes the REPL/code-act model: context and prompts as runtime - values, recursive sub-model or sub-agent calls as functions, persistent - session variables, trajectory logging, and sandbox choices. +- CodeAct/recursive-language-model runtimes contribute the recursion model: + context and prompts as runtime values, recursive sub-model or sub-agent calls + as functions, persistent session variables, and trajectory logging. TinyAgents + provides the primitives (registry capabilities, sub-agents, session/cell/call + ids, event journals); the interpreter and its sandbox stay host-side. The target architecture is therefore layered: the harness owns model/tool execution and policies, the graph owns deterministic state transition and -durability, the registry owns named capabilities, `.rag` owns serializable graph -blueprints, and `.ragsh` owns capability-bound interactive orchestration. No -layer should bypass another layer's safety, policy, observability, or test -contracts. +durability, the registry owns named capabilities, and `.rag` owns serializable +graph blueprints. No layer should bypass another layer's safety, policy, +observability, or test contracts. + +## Host-side surfaces + +Some things a recursive agent system needs are intentionally *not* implemented +here, because a host can implement them on top of the four modules and because +shipping them would drag an embedded interpreter into every dependent's build: + +- the scripted CodeAct/REPL session loop (an embedded Rhai / Python / JavaScript + interpreter running model-written code cells) +- the driver loop that prompts a model for the next code cell and feeds the + cell's output back in + +What this crate provides for those hosts: the capability `registry` (so a script +can only reach named `llm` / `tool` / `agent` capabilities), the harness and its +sub-agent recursion accounting, typed `SessionId` / `CellId` / `CallId`, the +event journal, and the `.rag` `repl_agent` node kind, which binds a +host-provided scripted node to a registered `Script` component by name. ## Detailed Module Docs @@ -88,8 +108,6 @@ contracts. - [Design](../modules/registry/design.md) - [Model catalog and local snapshots](../modules/registry/model-catalog.md) - [Expressive language module](../modules/expressive-language/README.md) -- [REPL language module](../modules/repl-language/README.md) - - [Design](../modules/repl-language/design.md) Docs should follow the module layout. Do not place standalone specification files directly in `docs/` or `docs/modules/`; each high-level topic should have @@ -105,8 +123,6 @@ it. - Keep model providers, tools, memory, and tracing behind stable traits. - Support both Rust builder APIs and a compact expressive language for workflow definitions. -- Support a capability-bound REPL language for interactive graph and harness - orchestration. - Allow agents to author, inspect, compile, and run graph blueprints through the same registry-bound compiler path used by human-authored `.rag` files. - Allow parent orchestrators and humans to steer orchestrator agents and @@ -157,7 +173,7 @@ for implementation status. The crate is a single library at the repository root (`Cargo.toml`), with `src/lib.rs` re-exporting the public surface and `src/error.rs` holding the -crate-wide error type. Each of the five surfaces lives in its own module +crate-wide error type. Each of the four surfaces lives in its own module directory: ```text @@ -168,14 +184,13 @@ src/ harness/ # provider-neutral model calls, tools, middleware, streaming, ... language/ # the declarative `.rag` blueprint format (lexer/parser/compiler) registry/ # the named capability catalog (models, tools, agents, stores, ...) - repl/ # the imperative `.ragsh` session runtime ``` Provider implementations (OpenAI and the OpenAI-compatible endpoints for Anthropic, Ollama, DeepSeek, Groq, xAI, OpenRouter, Together, and Mistral) live inside `src/harness/providers/` and are compiled in unconditionally. Two Cargo features gate optional dependencies: `sqlite` (embedded SQLite -checkpointer) and `repl` (embedded Rhai engine for `.ragsh` sessions). +checkpointer) and `tools` (the builtin generic tool family). ## Milestones @@ -214,8 +229,9 @@ Langfuse tracing integration (`LangfuseClient`, `GraphLangfuseExporter`). Historical decisions that have since been settled, kept for context: -- The expressive language file extension is `.rag` (interactive/imperative - orchestration uses the separate `.ragsh` extension). +- The expressive language file extension is `.rag`. Interactive/imperative + orchestration was prototyped as a separate `.ragsh` surface and has since been + removed from this crate as a host concern. - State schemas remain Rust-owned; `.rag` binds to them by name through the registry rather than declaring schemas itself. - Provider crates live in this crate as always-compiled modules behind diff --git a/docs/spec/expressive-language-spec.md b/docs/spec/expressive-language-spec.md index 3993c412..6890a68a 100644 --- a/docs/spec/expressive-language-spec.md +++ b/docs/spec/expressive-language-spec.md @@ -8,7 +8,8 @@ This language is not meant to replace Rust. It is a workflow definition layer fo fast iteration, examples, documentation, and eventually user-authored agent plans. -It is also the safe boundary for agent-authored graph plans. A REPL or model may +It is also the safe boundary for agent-authored graph plans. A host session or +model may propose `.rag` source, but that source must pass through the same parser, diagnostics, registry binding, allowlist checks, review gates, and graph compiler as human-authored source before it can run. @@ -130,7 +131,7 @@ expressive language. For generated source, the runtime relationship is: ```text -REPL/model proposal -> .rag source or AST -> parser -> diagnostics -> resolver +host/model proposal -> .rag source or AST -> parser -> diagnostics -> resolver -> policy/review gate -> compiler -> GraphBuilder + Harness bindings -> CompiledGraph -> optional registry registration ``` diff --git a/examples/rlm_python.rs b/examples/rlm_python.rs deleted file mode 100644 index 13db721e..00000000 --- a/examples/rlm_python.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! A live RLM run over an **external Python interpreter**, with a real tool -//! and a real sub-agent registered. -//! -//! The driver model writes Python cells; the child `python3` process executes -//! them, and every capability call (`llm`, `tool`, `agent`, `final_answer`) -//! travels back over the wire protocol to the host, which enforces the policy -//! and lowers to the harness runtime. The `agent("summarizer", ...)` call -//! drives a *complete nested agent run* on its own OpenAI-backed harness — -//! scripts calling agents calling models. -//! -//! Run with (needs `OPENAI_API_KEY` and `python3` on PATH): -//! -//! ```text -//! cargo run --features rlm --example rlm_python -//! ``` - -use std::sync::Arc; - -use async_trait::async_trait; -use serde_json::json; - -use tinyagents::harness::providers::openai::OpenAiModel; -use tinyagents::harness::runtime::AgentHarness; -use tinyagents::harness::tool::{Tool, ToolCall, ToolResult, ToolSchema}; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{RlmConfig, RlmRunner}; -use tinyagents::{HarnessSubAgent, Result, SubAgent}; - -/// A deterministic metrics tool the script can query. -struct MetricsTool; - -#[async_trait] -impl Tool<()> for MetricsTool { - fn name(&self) -> &str { - "service_metrics" - } - - fn description(&self) -> &str { - "Returns latency and error-rate metrics for a named service." - } - - fn schema(&self) -> ToolSchema { - ToolSchema::new( - "service_metrics", - "Returns latency and error-rate metrics for a named service.", - json!({ - "type": "object", - "properties": { "service": { "type": "string" } }, - "required": ["service"], - "additionalProperties": false - }), - ) - } - - async fn call(&self, _state: &(), call: ToolCall) -> Result { - let service = call - .arguments - .get("service") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let metrics = match service { - "checkout" => json!({"p99_ms": 2140, "error_rate": 0.031, "deploys_today": 3}), - "search" => json!({"p99_ms": 180, "error_rate": 0.002, "deploys_today": 0}), - "auth" => json!({"p99_ms": 95, "error_rate": 0.001, "deploys_today": 1}), - other => json!({"error": format!("unknown service `{other}`")}), - }; - let mut result = ToolResult::text(call.id, call.name, metrics.to_string()); - result.raw = Some(metrics); - Ok(result) - } -} - -#[tokio::main] -async fn main() -> Result<()> { - dotenvy::dotenv().ok(); - - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry.register_model("openai", Arc::new(OpenAiModel::from_env()?))?; - registry.register_tool(Arc::new(MetricsTool))?; - - // A real sub-agent on its own harness: scripts delegate to it by name. - let mut child: AgentHarness<()> = AgentHarness::new(); - child - .register_model("openai", Arc::new(OpenAiModel::from_env()?)) - .set_default_model("openai"); - let summarizer = Arc::new( - SubAgent::new( - "summarizer", - "Writes a crisp two-sentence incident summary from raw findings.", - Arc::new(child), - ) - .with_system_prompt( - "You summarize incident findings for executives: two sentences, plain language, \ - lead with impact.", - ), - ); - registry.register_agent(Arc::new(HarnessSubAgent::new(summarizer)))?; - - let config = RlmConfig::from_json( - r#"{ - "interpreter": {"kind": "python"}, - "driver_model": "openai", - "template": "general", - "policy": {"max_cells": 8, "cell_timeout": 90000} - }"#, - )?; - - let mut runner = RlmRunner::from_config(config, Arc::new(registry), Arc::new(()))?; - let outcome = runner - .run( - "Check the service_metrics tool for the services checkout, search, and auth. \ - Identify which service looks unhealthy and why. Then delegate to the `summarizer` \ - agent to produce an executive summary of your findings, and return that summary \ - as the final answer.", - ) - .await?; - - for (i, step) in outcome.steps.iter().enumerate() { - println!("── cell {} ──\n{}\n", i + 1, step.code); - if !step.outcome.stdout.is_empty() { - println!("stdout:\n{}", step.outcome.stdout); - } - if let Some(error) = &step.outcome.error { - println!("error: {error}"); - } - } - println!("── answer ({:?}) ──", outcome.stop_reason); - println!("{}", outcome.answer.as_deref().unwrap_or("(none)")); - println!( - "\ndriver calls: {}, sub-llm calls: {}, tool calls: {}, agent calls: {}", - outcome.driver_calls, outcome.sub_llm_calls, outcome.tool_calls, outcome.agent_calls - ); - runner.shutdown().await?; - Ok(()) -} diff --git a/examples/rlm_rhai.rs b/examples/rlm_rhai.rs deleted file mode 100644 index 26a304a9..00000000 --- a/examples/rlm_rhai.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! A live recursive-language-model run over the embedded Rhai sandbox. -//! -//! The classic RLM shape: a context too noisy to eyeball is injected into the -//! sandbox as the `context` variable, and the driver model must *probe it -//! with code* — slicing, filtering, and delegating fuzzy judgment on -//! individual entries to sub-LLM calls (`llm(...)`) — before answering with -//! `final_answer(...)`. -//! -//! Run with (needs `OPENAI_API_KEY`, optional `OPENAI_MODEL`): -//! -//! ```text -//! cargo run --features rlm --example rlm_rhai -//! ``` - -use std::sync::Arc; - -use serde_json::json; - -use tinyagents::Result; -use tinyagents::harness::providers::openai::OpenAiModel; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{RlmConfig, RlmRunner}; - -#[tokio::main] -async fn main() -> Result<()> { - dotenvy::dotenv().ok(); - let model = OpenAiModel::from_env()?; - - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry.register_model("openai", Arc::new(model))?; - - // The whole run is one JSON document — the same document an external - // harness could load from disk. - let config = RlmConfig::from_json( - r#"{ - "interpreter": {"kind": "rhai"}, - "driver_model": "openai", - "template": "context-explorer", - "policy": {"max_cells": 8, "max_llm_calls": 16, "cell_timeout": 90000} - }"#, - )?; - - let mut runner = RlmRunner::from_config(config, Arc::new(registry), Arc::new(()))?; - - // A synthetic "too big to read" context: expense records with three - // hidden anomalies among routine noise. - let mut records = Vec::new(); - for i in 0..200usize { - let team = ["platform", "growth", "ops"][i % 3]; - let amount = 40 + (i * 7) % 60; - records.push(json!({ - "id": i, - "team": team, - "amount_usd": amount, - "memo": format!("routine cloud spend, invoice {i}"), - })); - } - records[57] = json!({"id": 57, "team": "growth", "amount_usd": 18400, - "memo": "annual conference sponsorship paid twice, needs review"}); - records[121] = json!({"id": 121, "team": "ops", "amount_usd": 9750, - "memo": "emergency hardware replacement after flood damage"}); - records[188] = json!({"id": 188, "team": "platform", "amount_usd": 12300, - "memo": "contractor invoice with mismatched PO number"}); - runner.set_context(json!(records)).await?; - - println!("── system prompt ──\n{}\n", runner.system_prompt()); - - let outcome = runner - .run( - "The `context` variable holds 200 expense records. Find every anomalous record \ - (unusual amount or memo), and summarize each anomaly in one line.", - ) - .await?; - - for (i, step) in outcome.steps.iter().enumerate() { - println!("── cell {} ──\n{}\n", i + 1, step.code); - if !step.outcome.stdout.is_empty() { - println!("stdout:\n{}", step.outcome.stdout); - } - if let Some(error) = &step.outcome.error { - println!("error: {error}"); - } - } - println!("── answer ({:?}) ──", outcome.stop_reason); - println!("{}", outcome.answer.as_deref().unwrap_or("(none)")); - println!( - "\ndriver calls: {}, sub-llm calls: {}, tool calls: {}, agent calls: {}", - outcome.driver_calls, outcome.sub_llm_calls, outcome.tool_calls, outcome.agent_calls - ); - Ok(()) -} diff --git a/src/error.rs b/src/error.rs index 823eae73..39894bfc 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ //! Crate-wide error type and `Result` alias. //! //! Every fallible surface of the recursive runtime — graph execution, the -//! harness agent loop, sub-agent recursion, `.rag`/`.ragsh` compilation, and +//! harness agent loop, sub-agent recursion, `.rag` compilation, and //! registry binding — funnels through [`TinyAgentsError`] so failures from a //! deeply nested child run roll up to the caller through one uniform type. //! Downstream code should prefer the [`Result`] alias exported here. @@ -16,7 +16,7 @@ pub type Result = std::result::Result; /// /// Variants are grouped by the surface that raises them: graph construction and /// execution, model/tool invocation, run limits and policy, graph durability, -/// and `.rag`/`.ragsh` language processing. +/// and `.rag` language processing. #[derive(Debug, Error)] pub enum TinyAgentsError { /// A graph was compiled or run without a configured `START` edge, so there @@ -220,7 +220,7 @@ pub enum TinyAgentsError { Resume(String), // --- language / blueprint errors --- - /// A `.rag`/`.ragsh` source could not be tokenised or parsed. + /// A `.rag` source could not be tokenised or parsed. #[error("parse error at line {line}, column {column}: {message}")] Parse { message: String, diff --git a/src/graph/README.md b/src/graph/README.md index 68a3e4ac..7c73d28a 100644 --- a/src/graph/README.md +++ b/src/graph/README.md @@ -1,13 +1,12 @@ # graph TinyAgents' durable workflow runtime (LangGraph-style), and one of the -load-bearing surfaces of the crate's recursive language-model (RLM) -architecture. +load-bearing surfaces of the crate's recursive architecture. Because a node can embed another compiled graph (`subgraph`) or invoke a sub-agent, **graphs run graphs** and orchestration recurses while every step stays typed, checkpointed, and observable. A workflow authored from a `.rag` -blueprint or driven from the `.ragsh` REPL lowers into exactly these same +blueprint or driven from a host orchestrator lowers into exactly these same types, so a model can describe, compile, and re-enter the very runtime it is executing inside. diff --git a/src/graph/builder/mod.rs b/src/graph/builder/mod.rs index eaf4e8f6..bf163571 100644 --- a/src/graph/builder/mod.rs +++ b/src/graph/builder/mod.rs @@ -5,7 +5,7 @@ //! and [`GraphBuilder::compile`] validates that topology and freezes it into an //! immutable [`crate::graph::CompiledGraph`]. Because a node handler can itself //! drive another compiled graph or a sub-agent, the same builder API is what -//! both hand-written Rust and model-authored `.rag`/`.ragsh` programs lower into +//! both hand-written Rust and model-authored `.rag` programs lower into //! when they assemble a workflow that may recurse into sub-workflows. //! //! See [`types`] for the builder data types. `compile` validates the topology diff --git a/src/graph/mod.rs b/src/graph/mod.rs index c8848373..17bfc3cc 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -2,10 +2,10 @@ //! //! The graph module is TinyAgents' durable workflow runtime (LangGraph-style) //! and one of the load-bearing surfaces of the crate's recursive language-model -//! (RLM) architecture: because a node can embed another compiled graph +//! architecture: because a node can embed another compiled graph //! ([`subgraph`]) or invoke a sub-agent, **graphs run graphs** and orchestration //! recurses while every step stays typed, checkpointed, and observable. A -//! workflow authored from a `.rag` blueprint or driven from the `.ragsh` REPL +//! workflow authored from a `.rag` blueprint or driven from a host orchestrator //! lowers into exactly these same types, so a model can describe, compile, and //! re-enter the very runtime it is executing inside. //! diff --git a/src/harness/agent_loop/README.md b/src/harness/agent_loop/README.md index 53e567b4..c08272e9 100644 --- a/src/harness/agent_loop/README.md +++ b/src/harness/agent_loop/README.md @@ -1,7 +1,7 @@ # harness::agent_loop The default model-tool-model agent loop: the innermost turn of the recursive -(RLM-style) harness. +harness. This loop is where one model call is driven to completion. Because a whole harness can be exposed as a tool (`harness::subagent::SubAgentTool`), the diff --git a/src/harness/agent_loop/mod.rs b/src/harness/agent_loop/mod.rs index bfb567a0..a835adfd 100644 --- a/src/harness/agent_loop/mod.rs +++ b/src/harness/agent_loop/mod.rs @@ -1,7 +1,7 @@ //! Default model-tool-model agent loop. //! -//! This loop is the innermost turn of the recursive-language-model (RLM) -//! runtime: it is where one model call is driven to completion, and because a +//! This loop is the innermost turn of the recursive runtime: it is where one +//! model call is driven to completion, and because a //! whole harness can be exposed as a tool //! ([`crate::harness::subagent::SubAgentTool`]), the very tools this loop //! executes may themselves be other agents — so "a model calling a model" is diff --git a/src/harness/embeddings/mod.rs b/src/harness/embeddings/mod.rs index 49aba051..3728ff98 100644 --- a/src/harness/embeddings/mod.rs +++ b/src/harness/embeddings/mod.rs @@ -1,6 +1,6 @@ //! Harness embeddings + retrieval module. //! -//! In the recursive (RLM-style) architecture this module is how a model reaches +//! In the recursive architecture this module is how a model reaches //! *outside* its context window: instead of stuffing a whole corpus into one //! prompt, an agent (or a sub-agent / REPL step) embeds documents once and then //! recursively retrieves only the snippets relevant to the current sub-question, diff --git a/src/harness/events/types.rs b/src/harness/events/types.rs index 11a7f921..02bf1e30 100644 --- a/src/harness/events/types.rs +++ b/src/harness/events/types.rs @@ -599,46 +599,6 @@ pub enum AgentEvent { /// Human-readable error description. error: String, }, - - /// A `.ragsh` REPL cell dispatched (or completed) a host capability call - /// (`model_query`, `tool_call`, `agent_query`, or an `emit`). - /// - /// [`ReplResult::calls`](crate::repl::session::ReplResult) is only readable - /// *after* a cell returns, so a long fan-out would otherwise look frozen to - /// a live observer. This event streams each capability call as it starts and - /// again as it completes, letting a host (which subscribes an - /// [`EventListener`] on the session [`EventSink`]) forward REPL progress to - /// its own UI/progress sink mid-cell. Gated behind the `repl` feature so the - /// default build neither pulls in the Rhai engine nor references - /// [`ReplCallRecord`](crate::repl::session::ReplCallRecord). - #[cfg(feature = "repl")] - ReplCall { - /// The session label (its [`SessionId`](crate::harness::ids::SessionId) - /// string) the call was issued from, correlating the event back to the - /// REPL session that produced it. - session_id: String, - /// The call record. On the [`ReplCallPhase::Started`] phase the record's - /// `elapsed` is zero and `detail` is minimal (the completed phase - /// carries the full detail and measured `elapsed`); `call_id` is stable - /// across the two phases so a listener can pair them. - record: crate::repl::session::ReplCallRecord, - /// Whether the call is starting or has completed. - phase: ReplCallPhase, - }, -} - -/// The lifecycle phase of an [`AgentEvent::ReplCall`] — whether a REPL host -/// capability call is just starting or has completed. -/// -/// Gated behind the `repl` feature alongside the event it annotates. -#[cfg(feature = "repl")] -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReplCallPhase { - /// The capability call has been dispatched but has not yet returned. - Started, - /// The capability call returned (successfully or with a recorded error). - Completed, } /// Names the kind of run limit that tripped in an [`AgentEvent::LimitReached`]. @@ -718,8 +678,6 @@ impl AgentEvent { AgentEvent::StreamClosed => "stream.closed", AgentEvent::RunCompleted { .. } => "run.completed", AgentEvent::RunFailed { .. } => "run.failed", - #[cfg(feature = "repl")] - AgentEvent::ReplCall { .. } => "repl.call", } } } diff --git a/src/harness/ids/types.rs b/src/harness/ids/types.rs index 8365e72e..b2655352 100644 --- a/src/harness/ids/types.rs +++ b/src/harness/ids/types.rs @@ -52,19 +52,19 @@ pub struct NodeId(pub(crate) String); #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct TaskId(pub(crate) String); -/// Identifies a single interactive `.ragsh` REPL session. +/// Identifies a single interactive orchestration session. /// -/// A `SessionId` names one [`crate::repl`] session: a persistent namespace and +/// A `SessionId` names one host-driven session: a persistent namespace and /// capability boundary that a (possibly model-driven) orchestrator drives one -/// cell at a time. Pairing it with a [`RunId`] lets REPL events and the child +/// cell at a time. Pairing it with a [`RunId`] lets session events and the child /// model/tool/graph runs a session spawns be correlated back to the session /// that issued them. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct SessionId(pub(crate) String); -/// Identifies a single executed cell within a [`SessionId`] REPL session. +/// Identifies a single executed cell within a [`SessionId`] session. /// -/// Each `.ragsh` cell evaluated against a session is named by a `CellId` so +/// Each cell evaluated against a session is named by a `CellId` so /// stdout chunks, variable changes, capability calls, and diagnostics can be /// attributed to the exact cell that produced them. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/src/harness/middleware/README.md b/src/harness/middleware/README.md index 695d889c..c95699c5 100644 --- a/src/harness/middleware/README.md +++ b/src/harness/middleware/README.md @@ -2,7 +2,7 @@ Cross-cutting extension points that wrap agent, model, and tool execution. -In the recursive (RLM-style) harness a sub-agent or sub-graph is just another +In the recursive harness a sub-agent or sub-graph is just another agent loop, so the same before/after hooks bracket the parent run *and* every nested model/tool/agent call beneath it. That uniform wrapping is what lets concerns like tracing, usage/cost roll-up, and guardrails compose consistently diff --git a/src/harness/middleware/mod.rs b/src/harness/middleware/mod.rs index ba4df6e3..af4d8997 100644 --- a/src/harness/middleware/mod.rs +++ b/src/harness/middleware/mod.rs @@ -1,6 +1,6 @@ //! Middleware stack. //! -//! In the recursive (RLM-style) harness, middleware is the layer that wraps +//! In the recursive harness, middleware is the layer that wraps //! *every* level of the recursion identically: because a sub-agent or sub-graph //! is just another agent loop, the same before/after hooks bracket the parent //! run and each nested model/tool/agent call beneath it. That uniform wrapping diff --git a/src/harness/model/mod.rs b/src/harness/model/mod.rs index 9c4d9182..d58ad6ab 100644 --- a/src/harness/model/mod.rs +++ b/src/harness/model/mod.rs @@ -1,7 +1,7 @@ //! Harness model layer. //! //! The model layer is the innermost rung of the recursive ladder: every level -//! of the RLM-style harness — a top-level agent, a sub-agent exposed as a tool, +//! of the harness — a top-level agent, a sub-agent exposed as a tool, //! or a node inside a subgraph — ultimately bottoms out in a [`ChatModel`] call //! routed through this provider-neutral request/response shape. Because the //! shapes are uniform, "a model calling a model" is the same typed surface at diff --git a/src/harness/providers/mod.rs b/src/harness/providers/mod.rs index 5280751b..fce10b18 100644 --- a/src/harness/providers/mod.rs +++ b/src/harness/providers/mod.rs @@ -1,7 +1,8 @@ //! Model provider integrations — the leaves of the recursion. //! //! Every recursive call in the runtime — an agent, a sub-agent, a graph node, a -//! `.ragsh` step — ultimately bottoms out in a concrete model invocation, and +//! host orchestration step — ultimately bottoms out in a concrete model +//! invocation, and //! that invocation goes through a provider adapter here. Adapters translate //! between TinyAgents' provider-neutral request/response types //! ([`ModelRequest`]/[`ModelResponse`]) and a provider's own wire API, so the diff --git a/src/language/compiler.rs b/src/language/compiler.rs index 2b445a9f..e6e75032 100644 --- a/src/language/compiler.rs +++ b/src/language/compiler.rs @@ -492,7 +492,7 @@ fn provenance_of( } } -/// Parses, compiles, and registry-binds `.rag`/`.ragsh` `source` in one call. +/// Parses, compiles, and registry-binds `.rag` `source` in one call. /// /// This is the convenience façade for the common path: it runs /// `parse -> compile -> registry-bind` and returns the validated blueprints. diff --git a/src/language/mod.rs b/src/language/mod.rs index f17a6425..bec2de50 100644 --- a/src/language/mod.rs +++ b/src/language/mod.rs @@ -1,7 +1,7 @@ //! Expressive language (`.rag`) — the declarative blueprint surface of the //! recursive runtime. //! -//! In TinyAgents' recursive (RLM-style) architecture, a model can author the +//! In TinyAgents' recursive architecture, a model can author the //! very workflow it is standing inside. `.rag` is the *safe boundary* for that //! self-authoring: a capability-by-name blueprint format that lowers into the //! exact same [`crate::graph`] and [`crate::harness`] runtime as hand-written diff --git a/src/language/resolver.rs b/src/language/resolver.rs index a78f9898..0f8da39d 100644 --- a/src/language/resolver.rs +++ b/src/language/resolver.rs @@ -371,7 +371,7 @@ fn unregistered(what: &str, node: &str, target: &str) -> TinyAgentsError { )) } -/// Parses, registry-resolves (with full source spans), and lowers `.rag`/`.ragsh` +/// Parses, registry-resolves (with full source spans), and lowers `.rag` /// `source` into validated blueprints in one call. /// /// This is the recommended single entry point: it routes generated and diff --git a/src/lib.rs b/src/lib.rs index 6db8b27e..c90b1d13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ -//! # TinyAgents — a recursive language-model (RLM) harness for Rust +//! # TinyAgents — a durable agent + graph harness for Rust //! //! TinyAgents is a typed, durable runtime where **language models call models, //! agents call agents, and graphs run graphs** — and where a model can author, @@ -6,19 +6,15 @@ //! checkpointed, policy-checked Rust. //! //! The "recursive" framing is the through-line of the whole crate, not a -//! footnote. It is architected around the execution model described in -//! "Recursive Language Models" (Alex L. Zhang, Tim Kraska, Omar Khattab, MIT -//! CSAIL, 2025; ): rather than stuffing -//! everything into one context window, a model treats long context as an -//! external *environment* it interacts with through a REPL — examining, -//! decomposing, and recursively calling sub-models over snippets. TinyAgents -//! brings that idea to Rust as a production-shaped harness (sub-model / -//! sub-agent / sub-graph calls as functions, persistent session values, depth -//! tracking, and trajectory/event logging). It is *inspired by and architected -//! around* the RLM execution model, not a reimplementation of the paper's -//! benchmarks. +//! footnote: rather than stuffing everything into one context window, a model +//! treats long context as an external *environment* it decomposes, recursively +//! calling sub-models, sub-agents, and sub-graphs as functions. TinyAgents +//! brings that to Rust as a production-shaped harness (capability calls by +//! name, depth tracking, and trajectory/event logging). The *scripted* form of +//! that loop — an embedded interpreter running model-written code cells — is a +//! host concern built on these surfaces, not something this crate ships. //! -//! ## The five surfaces +//! ## The four surfaces //! //! 1. **Harness** ([`harness`]) — provider-neutral model calls, typed tools, //! middleware, structured output, streaming, usage/cost, retry/limits, cache, @@ -30,18 +26,15 @@ //! durable [`ThreadGoal`] with graph-native continuation and a //! [`TaskBoard`] kanban — exposed as harness tools. //! 3. **Registry** ([`registry`]) — a named capability catalog (models, tools, -//! agents, graphs, stores, middleware, policy) that `.rag`/`.ragsh` bind by -//! name. +//! agents, graphs, stores, middleware, policy) that `.rag` binds by name. //! 4. **Expressive language `.rag`** ([`language`]) — a declarative, //! side-effect-free blueprint format that compiles (lexer → parser → //! compiler) into the same graph/harness runtime; the safe boundary for //! agent-authored plans. -//! 5. **REPL language `.ragsh`** ([`repl`]) — imperative, capability-bound -//! interactive orchestration; the RLM/CodeAct loop surface. //! //! ## The recursion story //! -//! Both `.rag` and `.ragsh` lower into the *same* [`graph`] + [`harness`] types +//! `.rag` lowers into the *same* [`graph`] + [`harness`] types //! as hand-written Rust — a language whose programs are the runtime that //! interprets them. A harness agent can be exposed *as a tool* to another agent //! ([`SubAgent`], [`SubAgentTool`], [`SubAgentSession`]), so orchestration is @@ -56,14 +49,15 @@ //! Hosted and local providers (OpenAI plus the OpenAI-compatible endpoints for //! Anthropic, Ollama, DeepSeek, Groq, xAI, OpenRouter, Together, and Mistral) //! are compiled in unconditionally alongside the offline, deterministic -//! [`harness::providers::MockModel`]. Three Cargo features gate optional, +//! [`harness::providers::MockModel`]. Two Cargo features gate optional, //! heavier dependencies instead: `sqlite` (embedded SQLite checkpointer, -//! [`graph::checkpoint::SqliteCheckpointer`]), `repl` (embedded Rhai engine -//! powering the `.ragsh` session runtime, [`repl::session`]), and `rlm` (the -//! recursive-language-model runtime: a driver model writes code cells run in -//! a sandboxed interpreter — embedded Rhai or an external Python/JavaScript -//! process — whose only host surface is capability calls back into the -//! registry). +//! [`graph::checkpoint::SqliteCheckpointer`]) and `tools` (the builtin generic +//! tool family, [`harness::tools`]). +//! +//! Scripted, imperative orchestration surfaces (an embedded interpreter driving +//! capability calls — the `.ragsh` REPL and the recursive-language-model +//! runtime that used to ship here) are deliberately *not* part of this crate: +//! they are host concerns, built on top of [`registry`] and [`harness`]. //! //! ## Crate-root re-exports //! @@ -76,9 +70,6 @@ pub mod graph; pub mod harness; pub mod language; pub mod registry; -pub mod repl; -#[cfg(feature = "rlm")] -pub mod rlm; /// Durable session history and run ledger — a persistence domain in its own /// right, not part of the agent-loop harness. Requires the `sqlite` feature. #[cfg(feature = "sqlite")] @@ -106,7 +97,7 @@ pub use session::{ // --- Error: the crate-wide error type and `Result` alias --- pub use error::{Result, TinyAgentsError}; -// --- Registry: named capability catalog (.rag/.ragsh binding by name) --- +// --- Registry: named capability catalog (.rag binding by name) --- pub use registry::{ AliasBinding, CapabilityRegistry, ComponentId, ComponentKind, ComponentMetadata, DiagnosticSeverity, ModelCapabilities, ModelCatalog, ModelCatalogEntry, ModelCatalogSnapshot, @@ -116,7 +107,7 @@ pub use registry::{ // --- Language: registry → blueprint binding façade --- // The strict, registry-backed entry points the REPL and orchestrators use to -// turn `.rag`/`.ragsh` source into validated blueprints. `compile_source` runs +// turn `.rag` source into validated blueprints. `compile_source` runs // parse -> compile -> registry-bind in one call. pub use language::capability_resolver::{ CapabilityResolver, bind_capabilities, bind_capabilities_with_registry, @@ -271,26 +262,3 @@ pub use graph::testkit::{ assert_graph, failing_node, fanout_node, interrupting_node, noop_node, run_recorded, scripted_route_node, scripted_update_node, subagent_fake_node, subgraph_test_node, }; - -// --- REPL language `.ragsh` Rhai session runtime (feature = "repl") --- -// The imperative orchestration surface. Gated behind the `repl` feature so the -// default build does not pull in the embedded Rhai engine. `ReplSession` here is -// the scripting session from `repl::session`; the line-oriented command session -// remains available as `repl::ReplSession`. -#[cfg(feature = "repl")] -pub use repl::session::{ - LanguageCompiler, ReplCallKind, ReplCallRecord, ReplCancelFlag, ReplCapabilities, ReplPolicy, - ReplResult, ReplSession, ReplValue, ReplVariables, -}; - -// --- RLM runtime (feature = "rlm") --- -// The recursive-language-model surface: a driver model writes code cells that -// run in a sandboxed interpreter (embedded Rhai or an external Python/Node -// process) whose only host surface is capability calls (`llm`, `tool`, -// `agent`) back into the registry. Config-driven end to end (`RlmConfig`). -#[cfg(feature = "rlm")] -pub use rlm::{ - CellOutcome, HostCall, InterpreterSpec, RlmCallKind, RlmCallRecord, RlmCancelFlag, RlmConfig, - RlmHost, RlmHostApi, RlmInterpreter, RlmOutcome, RlmPolicy, RlmRunner, RlmSession, RlmStep, - RlmStopReason, RlmTemplate, TemplateSpec, -}; diff --git a/src/registry/capability/mod.rs b/src/registry/capability/mod.rs index b79ce6c1..d3e85ad4 100644 --- a/src/registry/capability/mod.rs +++ b/src/registry/capability/mod.rs @@ -3,15 +3,15 @@ //! //! This is where a name like `"researcher"` or `"summarize"` becomes a real, //! callable handle. By registering capabilities here and then handing the -//! registry to the language layer, a parent run lets a `.rag` blueprint or -//! `.ragsh` line spawn sub-models, sub-agents, and sub-graphs it never +//! registry to the language layer, a parent run lets a `.rag` blueprint or a +//! host session spawn sub-models, sub-agents, and sub-graphs it never //! hardcoded — while the registry's allowlist guarantees those references can //! only resolve to capabilities a human actually registered. //! //! See [`types`] for the data definitions. This module provides registration, //! lookup, aliasing, duplicate validation, and conveniences for handing the //! catalog's models and tools to a harness ([`to_model_registry`] / -//! [`to_tool_registry`]) or to the `.rag`/`.ragsh` capability resolver +//! [`to_tool_registry`]) or to the `.rag` capability resolver //! ([`capability_resolver`]). //! //! [`to_model_registry`]: CapabilityRegistry::to_model_registry @@ -372,7 +372,7 @@ impl CapabilityRegistry { /// Returns the canonical registered names for `kind` *and* every alias of /// that kind, in sorted, de-duplicated order. /// - /// This is the set of names declarative `.rag`/`.ragsh` source may reference + /// This is the set of names declarative `.rag` source may reference /// for `kind`: both the canonical registration and any alias resolve to a /// real component, so both are valid references. It backs /// [`CapabilityResolver::from_registry`](crate::language::compiler::CapabilityResolver::from_registry). @@ -432,7 +432,7 @@ impl CapabilityRegistry { registry } - /// Builds a fully populated `.rag`/`.ragsh` [`CapabilityResolver`] from every + /// Builds a fully populated `.rag` [`CapabilityResolver`] from every /// registered capability — models, tools, graph blueprints, routers, and /// reducers, including their aliases — plus the default node kinds. /// diff --git a/src/registry/capability/types.rs b/src/registry/capability/types.rs index f576e875..8942cb1e 100644 --- a/src/registry/capability/types.rs +++ b/src/registry/capability/types.rs @@ -6,7 +6,7 @@ //! [`crate::harness::model::ModelRegistry`] and //! [`crate::harness::tool::ToolRegistry`], which are per-run executable stores. //! The [`CapabilityRegistry`] is a *capability catalog*: it owns named models, -//! tools, graph blueprints, routers, and reducers so declarative `.rag`/`.ragsh` +//! tools, graph blueprints, routers, and reducers so declarative `.rag` //! sources can be bound by name, then validated against what Rust has actually //! registered and allowed. diff --git a/src/registry/component/mod.rs b/src/registry/component/mod.rs index d89fc83c..81da3482 100644 --- a/src/registry/component/mod.rs +++ b/src/registry/component/mod.rs @@ -3,7 +3,7 @@ //! These are the vocabulary of the recursive catalog: a [`ComponentKind`] //! ([`Model`](ComponentKind::Model), [`Tool`](ComponentKind::Tool), //! [`Graph`](ComponentKind::Graph), [`Agent`](ComponentKind::Agent), …) plus a -//! [`ComponentId`] name is exactly what a `.rag`/`.ragsh` reference carries, and +//! [`ComponentId`] name is exactly what a `.rag` reference carries, and //! [`ComponentMetadata`] is the durable, serializable description that lets a //! capability be discovered, listed, and bound by name long after the process //! that registered it has exited. diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 01549f88..643e8307 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -1,9 +1,9 @@ //! Registry coordination and discovery primitives — the **named capability //! catalog** that makes TinyAgents recursive. //! -//! In the recursive (RLM-style) architecture, a model, agent, or graph can -//! reach for capabilities it never hardcoded: a `.rag` blueprint or `.ragsh` -//! REPL line references a model/tool/agent/graph *by name*, and the registry is +//! In the recursive architecture, a model, agent, or graph can reach for +//! capabilities it never hardcoded: a `.rag` blueprint (or a host orchestration +//! session) references a model/tool/agent/graph *by name*, and the registry is //! what resolves that name to a real, Rust-registered handle. By owning the set //! of legal names, the registry is also the boundary that makes agent-authored //! plans safe to compile — a self-authored workflow can only bind to @@ -13,7 +13,7 @@ //! two complementary pieces: //! //! - [`CapabilityRegistry`] ([`capability`]) — the name-addressable catalog of -//! models, tools, graph blueprints, routers, and reducers that `.rag`/`.ragsh` +//! models, tools, graph blueprints, routers, and reducers that `.rag` //! sources bind against, plus the discovery [`component`] types //! ([`ComponentKind`]/[`ComponentId`]/[`ComponentMetadata`]) that describe //! what is registered. diff --git a/src/repl/mod.rs b/src/repl/mod.rs deleted file mode 100644 index 34a20277..00000000 --- a/src/repl/mod.rs +++ /dev/null @@ -1,303 +0,0 @@ -//! REPL language (`.ragsh`) — capability-bound interactive orchestration; the -//! RLM/CodeAct surface of the runtime. -//! -//! `.ragsh` is TinyAgents' answer to the Recursive Language Model execution -//! model: instead of stuffing everything into one context window, an -//! orchestrator (a human, or a model acting as one) drives a session by issuing -//! small typed commands — set/get session *values*, load and compile a `.rag` -//! blueprint, run a graph, or `call` a registered capability — inspecting each -//! [`ReplOutcome`] and iterating. Because every capability-bearing command is -//! checked against a [`CapabilityPolicy`] allowlist before it can touch the -//! runtime, the same surface is safe to expose to a model that is recursively -//! orchestrating sub-models, sub-agents, and sub-graphs from inside a run. -//! -//! The REPL language is the interactive, session-oriented counterpart to the -//! declarative `.rag` expressive language. An operator (human or parent -//! orchestrator) drives a harness/graph session by issuing typed commands that -//! are policy-checked before they reach the runtime. -//! -//! This module is currently at **milestone R1** (Documentation and Types). It -//! establishes the command grammar, a line-oriented parser, a session/capability -//! boundary, and structured outcomes. Commands that need live harness/graph -//! integration are policy-checked and returned as [`ReplOutcome::Planned`] -//! rather than executed; wiring to the live harness/graph runtime is deferred to -//! milestones R2–R6. -//! -//! # Grammar -//! -//! ```text -//! line = verb ( ws+ arg )* ws* -//! verb = [a-zA-Z][a-zA-Z0-9_-]* -//! arg = quoted | bare -//! quoted = '"' ( | '\\' )* '"' -//! bare = ( )+ -//! ``` -//! -//! The first token is the command verb (matched case-insensitively). Subsequent -//! tokens are positional arguments. For the `call` verb the *remainder* of the -//! line after the capability name is parsed as a single JSON value, so -//! multi-token JSON objects and arrays are accepted verbatim. -//! -//! ## Verb table -//! -//! | Verb | Signature | Notes | -//! |------------|----------------------------------|--------------------------------| -//! | `help` | `help` | Also: `?` | -//! | `quit` | `quit` | Also: `exit`, `q` | -//! | `load` | `load ` | Requires `"load"` capability | -//! | `compile` | `compile ` | Requires `"compile"` capability| -//! | `run` | `run ` | Requires `"run"` capability | -//! | `set` | `set ` | `` may be quoted | -//! | `get` | `get ` | | -//! | `show` | `show vars\|graphs\|status` | | -//! | `call` | `call ` | JSON may span multiple tokens | - -pub mod types; - -pub use types::*; - -/// Rhai-backed `.ragsh` session runtime (the imperative RLM/CodeAct surface). -/// -/// This is the evolution of the line-oriented command REPL above into a full -/// scripting session with a persistent namespace, policy-bounded capability -/// calls, and typed cell results — the [`session::ReplSession`] described in the -/// module design document. It is gated behind the `repl` cargo feature so the -/// default build stays free of the embedded Rhai engine. -/// -/// The command-driven [`ReplSession`](crate::repl::ReplSession) above remains -/// available for the line-oriented REPL; the scripting engine is exposed as -/// [`session::ReplSession`] (and re-exported at the crate root as -/// [`crate::ReplSession`] when the feature is enabled) to keep both surfaces -/// compiling side by side. -#[cfg(feature = "repl")] -pub mod session; - -// Re-export the non-colliding session types at the `repl` root for convenience. -// `session::ReplSession` is intentionally *not* re-exported here because the -// line-oriented `ReplSession` (above) already occupies that name in the default -// build; reach the scripting session via `repl::session::ReplSession` or the -// crate-root `crate::ReplSession` re-export. -#[cfg(feature = "repl")] -pub use session::{ - LanguageCompiler, ReplCallKind, ReplCallRecord, ReplCapabilities, ReplPolicy, ReplResult, - ReplValue, ReplVariables, -}; - -#[cfg(test)] -mod test; - -// ── Public parser ───────────────────────────────────────────────────────────── - -/// Parse a single `.ragsh` REPL command line into a [`ReplCommand`]. -/// -/// Leading and trailing whitespace is ignored. The first token is matched -/// case-insensitively against the verb table. For `call`, the remainder of -/// the line after the capability name is parsed as a JSON value. -/// -/// # Errors -/// -/// Returns [`crate::error::TinyAgentsError::Parse`] for: -/// -/// * empty input -/// * unknown verb -/// * missing required argument(s) -/// * unterminated quoted string -/// * invalid JSON argument to `call` -pub fn parse_command(line: &str) -> crate::error::Result { - let trimmed = line.trim(); - if trimmed.is_empty() { - return Err(parse_err_at(trimmed, trimmed, "empty input")); - } - - let (verb, rest) = split_token(trimmed, trimmed)?; - - match verb.to_lowercase().as_str() { - "help" | "?" => Ok(ReplCommand::Help), - - "quit" | "exit" | "q" => Ok(ReplCommand::Quit), - - "load" => { - let (path, _) = require_token(trimmed, rest, "load ")?; - Ok(ReplCommand::Load { path }) - } - - "compile" => { - let (name, _) = require_token(trimmed, rest, "compile ")?; - Ok(ReplCommand::Compile { name }) - } - - "run" => { - let (graph, rest) = require_token(trimmed, rest, "run ")?; - let (input, _) = require_token(trimmed, rest, "run ")?; - Ok(ReplCommand::Run { graph, input }) - } - - "set" => { - let (key, rest) = require_token(trimmed, rest, "set ")?; - let (value, _) = require_token(trimmed, rest, "set ")?; - Ok(ReplCommand::Set { key, value }) - } - - "get" => { - let (key, _) = require_token(trimmed, rest, "get ")?; - Ok(ReplCommand::Get { key }) - } - - "show" => { - let (what, _) = require_token(trimmed, rest, "show ")?; - Ok(ReplCommand::Show { what }) - } - - "call" => { - let (capability, json_rest) = require_token(trimmed, rest, "call ")?; - let json_str = json_rest.trim(); - if json_str.is_empty() { - return Err(parse_err_at( - trimmed, - json_rest, - "call requires a JSON argument: call ", - )); - } - let args: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { - parse_err_at( - trimmed, - json_str, - &format!("invalid JSON argument for `call`: {e}"), - ) - })?; - Ok(ReplCommand::Call { capability, args }) - } - - other => Err(parse_err_at( - trimmed, - trimmed, - &format!("unknown command `{other}`"), - )), - } -} - -// ── Private helpers ─────────────────────────────────────────────────────────── - -/// Build a [`crate::error::TinyAgentsError::Parse`] with the given message and -/// optional source position. -fn parse_err(message: &str, line: usize, column: usize) -> crate::error::TinyAgentsError { - crate::error::TinyAgentsError::Parse { - message: message.to_string(), - line, - column, - } -} - -/// Builds a [`crate::error::TinyAgentsError::Parse`] pointing at `at` — a -/// substring slice of `origin` — reporting a real 1-based line/column instead -/// of the placeholder `(0, 0)`. -/// -/// `parse_command` always parses a single command line, so the line is always -/// `1`; the column is the 1-based character offset of `at` within `origin`. -/// Falls back to the end of `origin` if `at` is not actually a subslice of it -/// (defensive; should not happen given how callers use this). -fn parse_err_at(origin: &str, at: &str, message: &str) -> crate::error::TinyAgentsError { - let column = char_column(origin, at); - parse_err(message, 1, column) -} - -/// Computes the 1-based character column of `at` within `origin`, where `at` -/// is a substring slice of `origin` obtained by slicing (not copying). -fn char_column(origin: &str, at: &str) -> usize { - let origin_start = origin.as_ptr() as usize; - let origin_end = origin_start + origin.len(); - let at_start = at.as_ptr() as usize; - let offset = if at_start >= origin_start && at_start <= origin_end { - at_start - origin_start - } else { - // `at` isn't a subslice of `origin` (shouldn't happen); point at the end. - origin.len() - }; - origin[..offset].chars().count() + 1 -} - -/// Split the next token from `s`, returning `(token, remainder)`. -/// -/// Handles quoted strings (`"..."`) with `\\`, `\"`, `\n`, `\t` escapes. -/// Bare tokens end at the first whitespace character. -/// -/// Returns `None` if `s` (after trimming leading whitespace) is empty. -/// -/// `origin` is the full trimmed command line `s` was sliced from; it is used -/// only to compute a real 1-based column for any parse error, via -/// [`parse_err_at`]. -fn split_token<'a>(origin: &str, s: &'a str) -> crate::error::Result<(String, &'a str)> { - let s = s.trim_start(); - if s.is_empty() { - return Err(parse_err_at(origin, s, "unexpected end of input")); - } - - if s.starts_with('"') { - // Quoted string: scan from after the opening quote. - // The labeled block returns the byte offset of the closing `"` within - // `inner` so we can compute the remainder slice without a mutable - // Option accumulator (which would trigger an unused-assignment warning). - let inner = s - .strip_prefix('"') - .expect("already checked starts_with('\"')"); - let mut token = String::new(); - - let inner_offset: usize = 'scan: { - let mut chars = inner.char_indices(); - loop { - match chars.next() { - None => { - return Err(parse_err_at(origin, s, "unterminated quoted string")); - } - Some((i, '"')) => break 'scan i, - Some((_, '\\')) => match chars.next() { - Some((_, '"')) => token.push('"'), - Some((_, '\\')) => token.push('\\'), - Some((_, 'n')) => token.push('\n'), - Some((_, 't')) => token.push('\t'), - Some((_, c)) => { - token.push('\\'); - token.push(c); - } - None => { - return Err(parse_err_at(origin, s, "unterminated escape sequence")); - } - }, - Some((_, c)) => token.push(c), - } - } - }; - - // Skip the opening `"` (1 byte) + bytes up to closing `"` + the closing `"` itself. - let remainder = &s[1 + inner_offset + 1..]; - Ok((token, remainder)) - } else { - // Bare word: ends at the first whitespace. - let end = s - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(i, _)| i) - .unwrap_or(s.len()); - let token = s[..end].to_string(); - let remainder = &s[end..]; - Ok((token, remainder)) - } -} - -/// Like [`split_token`] but returns a [`crate::error::TinyAgentsError::Parse`] -/// mentioning the expected usage if the remaining input is empty. -fn require_token<'a>( - origin: &str, - s: &'a str, - usage: &str, -) -> crate::error::Result<(String, &'a str)> { - let s = s.trim_start(); - if s.is_empty() { - return Err(parse_err_at( - origin, - s, - &format!("missing argument — usage: {usage}"), - )); - } - split_token(origin, s) -} diff --git a/src/repl/session/builtins/authoring.rs b/src/repl/session/builtins/authoring.rs deleted file mode 100644 index e49970e0..00000000 --- a/src/repl/session/builtins/authoring.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! Graph-authoring implementations (`graph_define`, `graph_validate`, -//! `graph_compile`, `graph_diff`, `graph_register`) lowering through the -//! expressive-language compiler and capability resolver. -//! -//! Split out of `session/builtins/mod.rs`; see that module's doc comment -//! for the full built-in surface and the blocking-bridge design. - -use super::*; - -// ── Graph-authoring implementations ───────────────────────────────────────── - -pub(super) fn graph_define_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - let name = - map_str(params, "name").ok_or_else(|| invalid(ctx, "graph_define: missing `name`"))?; - let source = - map_str(params, "source").ok_or_else(|| invalid(ctx, "graph_define: missing `source`"))?; - - // Check the limit up front (without consuming a slot) so a session that - // has already hit the cap fails fast instead of paying for a parse and - // compile it can't keep the result of anyway. - if ctx.counters.lock().expect("counters poisoned").graph_def >= ctx.policy.max_graph_definitions - { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "graph definition limit ({}) exceeded", - ctx.policy.max_graph_definitions - )), - )); - } - if source.len() > ctx.policy.max_script_bytes { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "graph source is {} bytes, exceeding max_script_bytes ({})", - source.len(), - ctx.policy.max_script_bytes - )), - )); - } - - let label = ctx - .language - .as_ref() - .map(|l| l.provenance_label.clone()) - .unwrap_or_else(|| ctx.session_label.clone()); - let origin = Origin::generated_by(label); - let program = parse_str(&source).map_err(|err| raise(ctx, err))?; - let blueprints = - compile_with_provenance(&program, origin.clone()).map_err(|err| raise(ctx, err))?; - let blueprint = blueprints - .into_iter() - .find(|b| b.graph_id == name) - .ok_or_else(|| { - invalid( - ctx, - format!("graph_define: source has no graph named `{name}`"), - ) - })?; - - // The draft is about to be recorded successfully; consume a slot now - // (re-checking the limit under the same lock to guard against a - // concurrent `graph_define` racing between the check above and here). - { - let mut counters = ctx.counters.lock().expect("counters poisoned"); - if counters.graph_def >= ctx.policy.max_graph_definitions { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "graph definition limit ({}) exceeded", - ctx.policy.max_graph_definitions - )), - )); - } - counters.graph_def += 1; - } - - let handle = GraphBlueprintHandle { - name: blueprint.graph_id.clone(), - source, - blueprint: blueprint.clone(), - origin, - compiled: false, - requires_review: ctx.policy.generated_graphs_require_review, - }; - ctx.drafts - .lock() - .expect("drafts poisoned") - .insert(handle.name.clone(), handle.clone()); - record( - ctx, - new_call_id(), - ReplCallKind::Graph, - "graph_define", - json!({ "name": handle.name }), - Duration::default(), - ); - Ok(draft_descriptor(&handle)) -} - -/// Builds the script-visible descriptor map for a graph draft (carrying its -/// name, node count, and compile/review status). The opaque -/// [`GraphBlueprintHandle`] itself lives host-side in `ctx.drafts`. -fn draft_descriptor(handle: &GraphBlueprintHandle) -> Dynamic { - let mut map = Map::new(); - map.insert("name".into(), Dynamic::from(handle.name.clone())); - map.insert( - "nodes".into(), - Dynamic::from(handle.blueprint.nodes.len() as i64), - ); - map.insert("compiled".into(), Dynamic::from(handle.compiled)); - map.insert( - "requires_review".into(), - Dynamic::from(handle.requires_review), - ); - Dynamic::from_map(map) -} - -/// Looks up a graph draft by the `name` field of a descriptor map. -pub(super) fn lookup_draft( - ctx: &HostContext, - descriptor: &Map, - func: &str, -) -> Result> { - let name = map_str(descriptor, "name") - .ok_or_else(|| invalid(ctx, format!("{func}: descriptor is missing `name`")))?; - ctx.drafts - .lock() - .expect("drafts poisoned") - .get(&name) - .cloned() - .ok_or_else(|| invalid(ctx, format!("{func}: no graph draft named `{name}`"))) -} - -pub(super) fn graph_validate_impl( - ctx: &HostContext, - descriptor: &Map, -) -> Result> { - let handle = lookup_draft(ctx, descriptor, "graph_validate")?; - let program = parse_str(&handle.source).map_err(|err| raise(ctx, err))?; - let diagnostics = Resolver::from_registry(&*ctx.registry).resolve_program(&program); - let array: Array = diagnostics - .iter() - .map(|d| Dynamic::from(d.message.clone())) - .collect(); - Ok(Dynamic::from_array(array)) -} - -pub(super) fn graph_compile_impl( - ctx: &HostContext, - descriptor: &Map, -) -> Result> { - let mut handle = lookup_draft(ctx, descriptor, "graph_compile")?; - // Bind the blueprint through the same resolver gate file-backed `.rag` - // source passes — generated topology is never trusted blindly. - Resolver::from_registry(&*ctx.registry) - .resolve_blueprint(&handle.blueprint) - .map_err(|err| raise(ctx, err))?; - handle.compiled = true; - handle.requires_review = ctx.policy.generated_graphs_require_review; - ctx.drafts - .lock() - .expect("drafts poisoned") - .insert(handle.name.clone(), handle.clone()); - record( - ctx, - new_call_id(), - ReplCallKind::Graph, - "graph_compile", - json!({ "name": handle.name, "requires_review": handle.requires_review }), - Duration::default(), - ); - Ok(draft_descriptor(&handle)) -} - -pub(super) fn graph_diff_handles( - ctx: &HostContext, - old: &Blueprint, - new: &Blueprint, -) -> Result> { - let diff = blueprint_diff(old, new); - let value = serde_json::to_value(&diff) - .map_err(|err| raise(ctx, TinyAgentsError::Validation(err.to_string())))?; - Ok(repl_value_to_dynamic(&json_to_repl_value(&value))) -} - -pub(super) fn graph_register_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - let graph = params - .get("graph") - .and_then(|d| d.read_lock::().map(|m| m.clone())) - .ok_or_else(|| { - invalid( - ctx, - "graph_register: `graph` must be a compiled graph descriptor", - ) - })?; - let handle = lookup_draft(ctx, &graph, "graph_register")?; - if !handle.compiled { - return Err(raise( - ctx, - TinyAgentsError::Validation( - "graph_register: graph must be compiled via graph_compile first".to_string(), - ), - )); - } - let review_id = map_str(params, "review_id").filter(|s| !s.is_empty()); - if handle.requires_review && review_id.is_none() { - return Err(raise( - ctx, - TinyAgentsError::Validation(format!( - "graph_register: generated graph `{}` requires review (no review_id)", - handle.name - )), - )); - } - // Enforce the review gate and emit a registry intent. The compiled topology - // is handed to the host for installation through the registry resolver — - // the REPL never installs generated topology directly. - record( - ctx, - new_call_id(), - ReplCallKind::Graph, - "graph_register", - json!({ "name": handle.name, "review_id": review_id }), - Duration::default(), - ); - Ok(Dynamic::from(handle.name)) -} diff --git a/src/repl/session/builtins/batched.rs b/src/repl/session/builtins/batched.rs deleted file mode 100644 index 45669933..00000000 --- a/src/repl/session/builtins/batched.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! Batched capability implementations (`model_query_batched`, -//! `tool_call_batched`, `agent_query_batched`, `graph_run_batched`). -//! -//! Split out of `session/builtins/mod.rs`; see that module's doc comment -//! for the full built-in surface and the blocking-bridge design. - -use super::*; - -// ── Batched implementations ───────────────────────────────────────────────── - -/// Extracts the object-map items of a batched argument array. -fn batch_items( - ctx: &HostContext, - items: &Array, - func: &str, -) -> Result, Box> { - items - .iter() - .map(|item| { - item.read_lock::() - .map(|m| m.clone()) - .ok_or_else(|| invalid(ctx, format!("{func}: each item must be an object map"))) - }) - .collect() -} - -pub(super) fn model_query_batched_impl( - ctx: &HostContext, - items: &Array, -) -> Result> { - use futures::stream::{self, StreamExt}; - - let items = batch_items(ctx, items, "model_query_batched")?; - let mut prepared = Vec::with_capacity(items.len()); - let mut call_ids = Vec::with_capacity(items.len()); - for params in &items { - let model_name = map_str(params, "model") - .ok_or_else(|| invalid(ctx, "model_query_batched: missing `model`"))?; - bump_model(ctx)?; - let model = ctx - .registry - .model(&model_name) - .ok_or_else(|| raise(ctx, TinyAgentsError::ModelNotFound(model_name.clone())))?; - let request = build_model_request(params); - let structured = map_bool(params, "structured").unwrap_or(false); - // Stream a "started" event for every fan-out leg up front, so a live - // observer sees the whole batch dispatch before any leg completes. - let call_id = new_call_id(); - emit_call_started(ctx, &call_id, ReplCallKind::Model, &model_name); - call_ids.push(call_id); - prepared.push((model_name, model, request, structured)); - } - - let concurrency = ctx.policy.max_concurrency.max(1); - let results: Vec> = - bridge_block_on_raw(ctx.buffers.deadline(), &ctx.cancel, async { - stream::iter(prepared.iter().map(|(name, model, request, structured)| { - let name = name.clone(); - let structured = *structured; - async move { - let start = Instant::now(); - let response = model.invoke(&ctx.state, request.clone()).await?; - let finish_reason = response.finish_reason.clone(); - let text = Message::Assistant(response.message).text(); - Ok((name, text, finish_reason, structured, start.elapsed())) - } - })) - .buffered(concurrency) - .collect() - .await - }) - .map_err(|err| raise(ctx, err))?; - - // `buffered` preserves input order, so results align 1:1 with `call_ids`. - let mut out = Array::with_capacity(results.len()); - for (call_id, result) in call_ids.into_iter().zip(results) { - let (name, text, finish_reason, structured, elapsed) = - result.map_err(|err| raise(ctx, err))?; - record( - ctx, - call_id, - ReplCallKind::Model, - &name, - json!({ "chars": text.len() }), - elapsed, - ); - out.push(model_value(text, finish_reason, structured)); - } - Ok(Dynamic::from_array(out)) -} - -pub(super) fn tool_call_batched_impl( - ctx: &HostContext, - items: &Array, -) -> Result> { - use futures::stream::{self, StreamExt}; - - let items = batch_items(ctx, items, "tool_call_batched")?; - let mut prepared = Vec::with_capacity(items.len()); - let mut call_ids = Vec::with_capacity(items.len()); - for params in &items { - let tool_name = map_str(params, "tool") - .ok_or_else(|| invalid(ctx, "tool_call_batched: missing `tool`"))?; - bump_tool(ctx)?; - let tool = ctx - .registry - .tool(&tool_name) - .ok_or_else(|| raise(ctx, TinyAgentsError::ToolNotFound(tool_name.clone())))?; - let arguments = map_json(params, "arguments").unwrap_or(Value::Null); - let call_id = new_call_id(); - emit_call_started(ctx, &call_id, ReplCallKind::Tool, &tool_name); - call_ids.push(call_id); - prepared.push((tool_name, tool, arguments)); - } - - let concurrency = ctx.policy.max_concurrency.max(1); - let results: Vec< - Result<(String, crate::harness::tool::ToolResult, Duration), TinyAgentsError>, - > = bridge_block_on_raw(ctx.buffers.deadline(), &ctx.cancel, async { - stream::iter(prepared.iter().zip(call_ids.iter()).map( - |((name, tool, arguments), call_id)| { - let name = name.clone(); - let call = ToolCall { - id: call_id.as_str().to_string(), - name: name.clone(), - arguments: arguments.clone(), - invalid: None, - }; - async move { - let start = Instant::now(); - let result = tool.call(&ctx.state, call).await?; - Ok((name, result, start.elapsed())) - } - }, - )) - .buffered(concurrency) - .collect() - .await - }) - .map_err(|err| raise(ctx, err))?; - - // Each item's own tool-reported error is surfaced per item, matching the - // single-call path's behavior for that one call, rather than aborting the - // whole batch and discarding every other item's already-computed result — - // a batch of N independent tool calls should not lose N-1 successes - // because item N/2 failed. A `bridge_block_on_raw`/transport failure - // above (a harness-level failure, not a tool-reported one) still aborts - // the whole batch, since no results exist to preserve in that case. - let mut out = Array::with_capacity(results.len()); - for (call_id, result) in call_ids.into_iter().zip(results) { - let (name, tool_result, elapsed) = result.map_err(|err| raise(ctx, err))?; - record( - ctx, - call_id, - ReplCallKind::Tool, - &name, - json!({ "chars": tool_result.content.len() }), - elapsed, - ); - match tool_result.error { - Some(error) => { - let mut map = Map::new(); - map.insert("ok".into(), Dynamic::from(false)); - map.insert("error".into(), Dynamic::from(error)); - out.push(Dynamic::from_map(map)); - } - None => { - let mut map = Map::new(); - map.insert("ok".into(), Dynamic::from(true)); - map.insert("content".into(), Dynamic::from(tool_result.content)); - out.push(Dynamic::from_map(map)); - } - } - } - Ok(Dynamic::from_array(out)) -} - -pub(super) fn agent_query_batched_impl( - ctx: &HostContext, - items: &Array, -) -> Result> { - use crate::graph::subagent_node::SubAgentInput; - use futures::stream::{self, StreamExt}; - - let items = batch_items(ctx, items, "agent_query_batched")?; - let mut prepared = Vec::with_capacity(items.len()); - let mut call_ids = Vec::with_capacity(items.len()); - for params in &items { - let agent_name = map_str(params, "agent") - .ok_or_else(|| invalid(ctx, "agent_query_batched: missing `agent`"))?; - bump_agent(ctx)?; - check_depth(ctx)?; - let agent = ctx.registry.agent(&agent_name).ok_or_else(|| { - raise( - ctx, - TinyAgentsError::Capability(format!("agent `{agent_name}` is not registered")), - ) - })?; - let prompt = map_str(params, "prompt") - .or_else(|| map_str(params, "input")) - .unwrap_or_default(); - let mut input = SubAgentInput::prompt(prompt); - if let Some(data) = map_json(params, "input") { - input = input.with_data(data); - } - let call_id = new_call_id(); - emit_call_started(ctx, &call_id, ReplCallKind::Agent, &agent_name); - call_ids.push(call_id); - prepared.push((agent_name, agent, input)); - } - - let concurrency = ctx.policy.max_concurrency.max(1); - let results: Vec> = - bridge_block_on_raw(ctx.buffers.deadline(), &ctx.cancel, async { - stream::iter(prepared.iter().map(|(name, agent, input)| { - let name = name.clone(); - async move { - let start = Instant::now(); - let output = agent.run(input.clone(), ctx.events.clone()).await?; - Ok((name, output.text, start.elapsed())) - } - })) - .buffered(concurrency) - .collect() - .await - }) - .map_err(|err| raise(ctx, err))?; - - let mut out = Array::with_capacity(results.len()); - for (call_id, result) in call_ids.into_iter().zip(results) { - let (name, text, elapsed) = result.map_err(|err| raise(ctx, err))?; - record(ctx, call_id, ReplCallKind::Agent, &name, json!({}), elapsed); - out.push(Dynamic::from(text)); - } - Ok(Dynamic::from_array(out)) -} - -pub(super) fn graph_run_batched_impl( - ctx: &HostContext, - items: &Array, -) -> Result> { - let items = batch_items(ctx, items, "graph_run_batched")?; - let mut out = Array::with_capacity(items.len()); - for params in &items { - out.push(graph_run_impl(ctx, params)?); - } - Ok(Dynamic::from_array(out)) -} diff --git a/src/repl/session/builtins/capabilities.rs b/src/repl/session/builtins/capabilities.rs deleted file mode 100644 index 3a84d0de..00000000 --- a/src/repl/session/builtins/capabilities.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Single-shot capability implementations (`model_query`, `tool_call`, -//! `agent_query`, `graph_run`). -//! -//! Split out of `session/builtins/mod.rs`; see that module's doc comment -//! for the full built-in surface and the blocking-bridge design. - -use super::*; - -// ── Single capability implementations ─────────────────────────────────────── - -pub(super) fn model_query_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - let model_name = - map_str(params, "model").ok_or_else(|| invalid(ctx, "model_query: missing `model`"))?; - bump_model(ctx)?; - let model = ctx - .registry - .model(&model_name) - .ok_or_else(|| raise(ctx, TinyAgentsError::ModelNotFound(model_name.clone())))?; - let request = build_model_request(params); - let call_id = new_call_id(); - emit_call_started(ctx, &call_id, ReplCallKind::Model, &model_name); - let start = Instant::now(); - let response = bridge_block_on( - ctx.buffers.deadline(), - &ctx.cancel, - model.invoke(&ctx.state, request), - ) - .map_err(|err| raise(ctx, err))?; - let elapsed = start.elapsed(); - let finish_reason = response.finish_reason.clone(); - let text = Message::Assistant(response.message).text(); - record( - ctx, - call_id, - ReplCallKind::Model, - &model_name, - json!({ "chars": text.len() }), - elapsed, - ); - Ok(model_value( - text, - finish_reason, - map_bool(params, "structured").unwrap_or(false), - )) -} - -pub(super) fn tool_call_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - let tool_name = - map_str(params, "tool").ok_or_else(|| invalid(ctx, "tool_call: missing `tool`"))?; - bump_tool(ctx)?; - let tool = ctx - .registry - .tool(&tool_name) - .ok_or_else(|| raise(ctx, TinyAgentsError::ToolNotFound(tool_name.clone())))?; - let arguments = map_json(params, "arguments").unwrap_or(Value::Null); - let call_id = new_call_id(); - let call = ToolCall { - id: call_id.as_str().to_string(), - name: tool_name.clone(), - arguments: arguments.clone(), - invalid: None, - }; - emit_call_started(ctx, &call_id, ReplCallKind::Tool, &tool_name); - let start = Instant::now(); - let result = bridge_block_on( - ctx.buffers.deadline(), - &ctx.cancel, - tool.call(&ctx.state, call), - ) - .map_err(|err| raise(ctx, err))?; - let elapsed = start.elapsed(); - record( - ctx, - call_id, - ReplCallKind::Tool, - &tool_name, - json!({ "arguments": arguments }), - elapsed, - ); - if let Some(error) = result.error { - return Err(raise(ctx, TinyAgentsError::Tool(error))); - } - let structured = map_bool(params, "structured").unwrap_or(false); - if structured && result.raw.is_some() { - let mut map = Map::new(); - map.insert("content".into(), Dynamic::from(result.content)); - map.insert( - "raw".into(), - repl_value_to_dynamic(&json_to_repl_value(&result.raw.unwrap_or(Value::Null))), - ); - Ok(Dynamic::from_map(map)) - } else { - Ok(Dynamic::from(result.content)) - } -} - -pub(super) fn agent_query_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - use crate::graph::subagent_node::SubAgentInput; - let agent_name = - map_str(params, "agent").ok_or_else(|| invalid(ctx, "agent_query: missing `agent`"))?; - bump_agent(ctx)?; - check_depth(ctx)?; - let agent = ctx.registry.agent(&agent_name).ok_or_else(|| { - raise( - ctx, - TinyAgentsError::Capability(format!("agent `{agent_name}` is not registered")), - ) - })?; - let prompt = map_str(params, "prompt") - .or_else(|| map_str(params, "input")) - .unwrap_or_default(); - let mut input = SubAgentInput::prompt(prompt); - if let Some(data) = map_json(params, "input") { - input = input.with_data(data); - } - let call_id = new_call_id(); - emit_call_started(ctx, &call_id, ReplCallKind::Agent, &agent_name); - let start = Instant::now(); - let output = bridge_block_on( - ctx.buffers.deadline(), - &ctx.cancel, - agent.run(input, ctx.events.clone()), - ) - .map_err(|err| raise(ctx, err))?; - record( - ctx, - call_id, - ReplCallKind::Agent, - &agent_name, - json!({ "model_calls": output.model_calls, "tool_calls": output.tool_calls }), - start.elapsed(), - ); - Ok(Dynamic::from(output.text)) -} - -/// Resolves a registered graph blueprint and records the run, returning a -/// reference to the resolved topology. -/// -/// Resolving a registered graph routes through the capability registry; the -/// REPL hands back the resolved blueprint reference (graph id, start node, node -/// count) rather than installing or stepping topology here. Materializing a -/// `CompiledGraph` and driving its super-steps is owned by the graph runtime -/// and wired in a later slice; this keeps the REPL an orchestration surface, -/// not a topology-mutation surface. -pub(super) fn graph_run_impl( - ctx: &HostContext, - params: &Map, -) -> Result> { - let graph_name = - map_str(params, "graph").ok_or_else(|| invalid(ctx, "graph_run: missing `graph`"))?; - bump_graph(ctx)?; - check_depth(ctx)?; - let blueprint = ctx - .registry - .graph_blueprint(&graph_name) - .ok_or_else(|| { - raise( - ctx, - TinyAgentsError::Capability(format!("graph `{graph_name}` is not registered")), - ) - })? - .clone(); - record( - ctx, - new_call_id(), - ReplCallKind::Graph, - &graph_name, - json!({ "nodes": blueprint.nodes.len() }), - Duration::default(), - ); - Ok(blueprint_reference(&blueprint)) -} - -/// Builds the script-visible reference map for a resolved graph blueprint. -fn blueprint_reference(blueprint: &Blueprint) -> Dynamic { - let mut map = Map::new(); - map.insert("graph".into(), Dynamic::from(blueprint.graph_id.clone())); - map.insert("start".into(), Dynamic::from(blueprint.start.clone())); - map.insert("nodes".into(), Dynamic::from(blueprint.nodes.len() as i64)); - map.insert("resolved".into(), Dynamic::from(true)); - Dynamic::from_map(map) -} diff --git a/src/repl/session/builtins/mod.rs b/src/repl/session/builtins/mod.rs deleted file mode 100644 index fe44a286..00000000 --- a/src/repl/session/builtins/mod.rs +++ /dev/null @@ -1,783 +0,0 @@ -//! Capability-bound built-in functions for the Rhai-backed `.ragsh` session -//! (design milestones R3–R5). -//! -//! This module registers the reserved built-in functions on a session's -//! [`rhai::Engine`] as **host capabilities**: each one resolves a name through -//! the session's [`CapabilityRegistry`], enforces the [`ReplPolicy`] call and -//! recursion limits, records a [`ReplCallRecord`], and lowers to the real -//! harness/graph runtime. -//! -//! The surface registered here is: -//! -//! - model calls — `model_query`, `model_query_batched` -//! - agent calls — `agent_query`, `agent_query_batched` -//! - graph runs — `graph_run`, `graph_run_batched` -//! - tool calls — `tool_call`, `tool_call_batched` -//! - session built-ins — `emit`, `show_vars`, `answer`, plus `print`/`debug` -//! capture -//! - graph authoring — `graph_define`, `graph_validate`, `graph_compile`, -//! `graph_diff`, `graph_register`, which lower through the Cluster H `.rag` -//! compiler and capability resolver. Generated topology is never installed -//! directly: a draft only becomes `compiled` after the resolver binds it, and -//! `graph_register` honors [`ReplPolicy::generated_graphs_require_review`]. -//! -//! ## The async adapter (blocking bridge) -//! -//! The Rhai engine is synchronous, but model, tool, agent, and graph calls are -//! async. This slice uses the design's **blocking bridge** for v1: a host -//! function builds the async future and drives it to completion in place with -//! [`futures::executor::block_on`] (see [`bridge_block_on`]). This keeps the -//! capability boundary deterministic for scripted tests (a [`ScriptedModel`] or -//! [`FakeTool`] resolves without yielding to a reactor) and works for real -//! providers when the session is driven from a multi-threaded runtime, where -//! blocking one worker does not starve the call's own I/O. -//! -//! The design's longer-term direction is *command recording* (host functions -//! emit `ReplCommand` values the async runtime executes after the cell); the -//! public `ReplResult`/`ReplCallRecord` types are already shaped for it. The -//! bridge is intentionally the only blocking surface and is confined to this -//! module. -//! -//! [`ScriptedModel`]: crate::harness::testkit::ScriptedModel -//! [`FakeTool`]: crate::harness::testkit::FakeTool - -use std::collections::BTreeMap; -use std::future::Future; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position}; -use serde_json::{Value, json}; - -use super::types::{GraphBlueprintHandle, LanguageCompiler, ReplCancelFlag, ReplPolicy}; -use super::{ - ReplCallKind, ReplCallRecord, dynamic_to_repl_value, json_to_repl_value, repl_value_to_dynamic, -}; -use crate::error::TinyAgentsError; -use crate::harness::events::{AgentEvent, EventSink, ReplCallPhase}; -use crate::harness::ids::{CallId, new_call_id}; -use crate::harness::message::Message; -use crate::harness::model::ModelRequest; -use crate::harness::tool::ToolCall; -use crate::language::compiler::compile_with_provenance; -use crate::language::parser::parse_str; -use crate::language::resolver::Resolver; -use crate::language::types::Origin; -use crate::language::{Blueprint, blueprint_diff}; -use crate::registry::CapabilityRegistry; - -/// Session-cumulative counters for capability calls, enforced against the -/// `ReplPolicy` `max_*_calls` limits. Counts accumulate across cells (the -/// limits are documented per session) and are shared with every capability -/// closure on the engine. -#[derive(Debug, Default, Clone, Copy)] -pub(super) struct CallCounters { - /// `model_query` (and per-item `model_query_batched`) calls made. - pub model: usize, - /// `tool_call` (and per-item `tool_call_batched`) calls made. - pub tool: usize, - /// `graph_run` (and per-item `graph_run_batched`) calls made. - pub graph: usize, - /// `agent_query` (and per-item `agent_query_batched`) calls made. - pub agent: usize, - /// `graph_define` blueprints drafted. - pub graph_def: usize, -} - -/// The host-side context shared (via `Arc`) with every capability closure on a -/// session's engine. Holds the live registries, application state, policy, and -/// the shared per-cell buffers / session counters / graph drafts. -pub(super) struct HostContext { - /// The unified capability catalog (models, tools, graphs, agents). - pub registry: Arc>, - /// The application state capability calls are invoked against. - pub state: Arc, - /// The session policy (call/recursion/concurrency limits). - pub policy: ReplPolicy, - /// Optional expressive-language compiler handle (provenance label). - pub language: Option, - /// The session id, used as the generated-graph provenance label. - pub session_label: String, - /// The session's run depth, the parent depth for recursive sub-runs. - pub run_depth: usize, - /// The event sink shared with the run context. - pub events: EventSink, - /// External cancellation flag, observed fail-closed by the `on_progress` - /// hook (mid-script) and the blocking capability bridge (mid-call). - pub cancel: ReplCancelFlag, - /// Per-cell shared buffers (stdout, calls, answer, host error, vars). - pub buffers: super::CellBuffers, - /// Session-cumulative call counters. - pub counters: Arc>, - /// Graph blueprints drafted this session, keyed by name. - pub drafts: Arc>>, -} - -/// How often the watcher thread in [`bridge_block_on_raw`] wakes to observe an -/// armed [`ReplCancelFlag`] while a capability call is in flight. -/// -/// Small enough that a user cancel releases a hung call promptly, large enough -/// that watching a fast (scripted-test) call costs nothing measurable. -const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Why the watcher tripped a bounded [`bridge_block_on_raw`] call. -enum BridgeStop { - /// The per-cell wall-clock deadline elapsed. - Deadline, - /// The external [`ReplCancelFlag`] was tripped. - Cancelled, -} - -/// Drives an async capability future to completion synchronously, bounded by an -/// optional wall-clock `deadline` **and** an external `cancel` flag — the v1 -/// "blocking bridge" adapter (see the [module docs](self)), with fail-closed -/// enforcement of both [`ReplPolicy::timeout`] and host cancellation. -/// -/// `on_progress` (see [`build_engine`]) only fires between Rhai -/// statements/operations, so it can never interrupt a blocked native call: this -/// is the enforcement point for that case. A detached watcher thread races the -/// capability future; when the deadline elapses or `cancel` trips first, the -/// future is dropped — canceling the underlying request, since providers are -/// built on cancel-safe `reqwest`/`futures` — and a `Timeout` or `Cancelled` -/// error is returned instead of blocking the session forever. If the future -/// finishes first, the watcher observes the dropped receiver and exits. -fn bridge_block_on_raw( - deadline: Option, - cancel: &ReplCancelFlag, - future: F, -) -> std::result::Result { - // Fail closed before the call even starts if either bound has already - // tripped, so a cancel/timeout that landed between statements is honored - // without dispatching the call at all. - if cancel.is_cancelled() { - return Err(TinyAgentsError::Cancelled); - } - if let Some(deadline) = deadline - && Instant::now() >= deadline - { - return Err(TinyAgentsError::Timeout(format!( - "{DEADLINE_EXCEEDED_TOKEN} before a host capability call could start" - ))); - } - - let (tx, rx) = futures::channel::oneshot::channel::(); - let watcher_cancel = cancel.clone(); - // A detached watcher wakes the race below when the deadline elapses or the - // cancel flag trips. If the capability future finishes first, `rx` is - // dropped and `tx.is_canceled()` lets the watcher exit promptly instead of - // polling out a full deadline. - std::thread::spawn(move || { - loop { - if tx.is_canceled() { - return; - } - if watcher_cancel.is_cancelled() { - let _ = tx.send(BridgeStop::Cancelled); - return; - } - match deadline { - Some(deadline) => { - let now = Instant::now(); - if now >= deadline { - let _ = tx.send(BridgeStop::Deadline); - return; - } - std::thread::sleep((deadline - now).min(CANCEL_POLL_INTERVAL)); - } - None => std::thread::sleep(CANCEL_POLL_INTERVAL), - } - } - }); - - match futures::executor::block_on(futures::future::select(Box::pin(future), rx)) { - futures::future::Either::Left((output, _watcher)) => Ok(output), - futures::future::Either::Right((stop, _fut)) => match stop { - Ok(BridgeStop::Cancelled) => Err(TinyAgentsError::Cancelled), - Ok(BridgeStop::Deadline) => Err(TinyAgentsError::Timeout(format!( - "{DEADLINE_EXCEEDED_TOKEN} during a host capability call" - ))), - // The watcher dropped its sender without sending — only reachable in - // a race the future has effectively already won; re-check the bounds - // and prefer cancellation, never silently succeeding. - Err(_canceled) => { - if cancel.is_cancelled() { - Err(TinyAgentsError::Cancelled) - } else { - Err(TinyAgentsError::Timeout(format!( - "{DEADLINE_EXCEEDED_TOKEN} during a host capability call" - ))) - } - } - }, - } -} - -/// Convenience wrapper over [`bridge_block_on_raw`] for the common case where -/// the capability future itself resolves to a `Result`, flattening the deadline -/// / cancellation error into the same error channel as the call's own failures. -fn bridge_block_on( - deadline: Option, - cancel: &ReplCancelFlag, - future: F, -) -> std::result::Result -where - F: Future>, -{ - bridge_block_on_raw(deadline, cancel, future)? -} - -/// One completed `model_query_batched` item: `(model, text, finish_reason, -/// structured, elapsed)`. -type ModelBatchItem = (String, String, Option, bool, Duration); - -/// One completed `agent_query_batched` item: `(agent, text, elapsed)`. -type AgentBatchItem = (String, String, Duration); - -// ── Error / recording helpers ─────────────────────────────────────────────── - -/// Returns whether a capability error must abort the cell (a policy bound -/// tripped) instead of surfacing inside the script as an ordinary, catchable -/// runtime error. Mirrors [`crate::rlm::host::is_fatal`] — kept as a separate -/// copy here since the `repl` and `rlm` cargo features are independent, so -/// this module cannot assume the `rlm` module is compiled in. -fn is_fatal(err: &TinyAgentsError) -> bool { - matches!( - err, - TinyAgentsError::LimitExceeded(_) - | TinyAgentsError::Timeout(_) - | TinyAgentsError::Cancelled - | TinyAgentsError::SubAgentDepth(_) - ) -} - -/// Stashes the precise crate error so `eval_cell` can surface it verbatim, and -/// returns the stringly-typed Rhai runtime error the engine propagates. -/// -/// Only *fatal* errors (see [`is_fatal`] — a policy bound such as a call -/// limit, timeout, cancellation, or recursion depth) are stashed as the -/// cell-aborting `host_error`: `on_progress` polls that flag and terminates -/// the script at the next statement, and `eval_cell`'s success path prefers -/// it even when the script otherwise completed normally. A *recoverable* -/// capability failure (unknown tool/model/agent, a tool-reported error, …) -/// must remain an ordinary catchable Rhai runtime error so `try`/`catch` in -/// the script actually works — it is stashed only into the non-aborting -/// `last_capability_error` slot, which `eval_cell`'s error path consults to -/// recover the typed error for an error the script left uncaught. -fn raise(ctx: &HostContext, err: TinyAgentsError) -> Box { - let message = err.to_string(); - if is_fatal(&err) { - ctx.buffers.set_host_error(err); - } else { - ctx.buffers.set_last_capability_error(err); - } - Box::new(EvalAltResult::ErrorRuntime( - Dynamic::from(message), - Position::NONE, - )) -} - -/// Raises a [`TinyAgentsError::Validation`] for an invalid script argument. -fn invalid( - ctx: &HostContext, - message: impl Into, -) -> Box { - raise(ctx, TinyAgentsError::Validation(message.into())) -} - -/// Records a completed capability call (or emitted event) into the per-cell -/// buffer **and** streams it live on the session [`EventSink`] as an -/// [`AgentEvent::ReplCall`] with phase [`ReplCallPhase::Completed`]. -/// -/// `call_id` is generated by the caller up front so a preceding -/// [`emit_call_started`] event can carry the same id, letting a host pair the -/// start and completion of one call. -fn record( - ctx: &HostContext, - call_id: CallId, - kind: ReplCallKind, - name: &str, - detail: Value, - elapsed: Duration, -) { - let record = ReplCallRecord { - call_id, - kind, - name: name.to_string(), - detail, - elapsed, - }; - emit_repl_call(ctx, &record, ReplCallPhase::Completed); - ctx.buffers.push_call(record); -} - -/// Emits an [`AgentEvent::ReplCall`] on the session event sink so a live -/// observer sees a capability call as it happens, rather than only in -/// [`ReplResult::calls`](super::ReplResult) after the cell returns. -fn emit_repl_call( - ctx: &HostContext, - record: &ReplCallRecord, - phase: ReplCallPhase, -) { - ctx.events.emit(AgentEvent::ReplCall { - session_id: ctx.session_label.clone(), - record: record.clone(), - phase, - }); -} - -/// Streams a `ReplCall` "started" event for a capability call about to be -/// dispatched, carrying `call_id` (matched by the later [`record`] completion), -/// its kind, and name — but no `detail` (arguments are only in the completed -/// record) and a zero `elapsed`. -fn emit_call_started( - ctx: &HostContext, - call_id: &CallId, - kind: ReplCallKind, - name: &str, -) { - let record = ReplCallRecord { - call_id: call_id.clone(), - kind, - name: name.to_string(), - detail: Value::Null, - elapsed: Duration::default(), - }; - emit_repl_call(ctx, &record, ReplCallPhase::Started); -} - -// ── Map argument helpers ──────────────────────────────────────────────────── - -/// Reads a string field from a Rhai object map argument. -fn map_str(map: &Map, key: &str) -> Option { - map.get(key).and_then(|d| d.clone().into_string().ok()) -} - -/// Reads a boolean field from a Rhai object map argument. -fn map_bool(map: &Map, key: &str) -> Option { - map.get(key).and_then(|d| d.as_bool().ok()) -} - -/// Converts a Rhai object map argument into a JSON value (for tool arguments -/// and structured payloads). -fn map_json(map: &Map, key: &str) -> Option { - map.get(key) - .map(|d| dynamic_to_repl_value(d).to_json()) - .filter(|v| !v.is_null()) -} - -// ── Counter limit helpers ─────────────────────────────────────────────────── - -/// Increments and bounds the model-call counter. -fn bump_model(ctx: &HostContext) -> Result<(), Box> { - let mut counters = ctx.counters.lock().expect("counters poisoned"); - if counters.model >= ctx.policy.max_model_calls { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "model call limit ({}) exceeded", - ctx.policy.max_model_calls - )), - )); - } - counters.model += 1; - Ok(()) -} - -/// Increments and bounds the tool-call counter. -fn bump_tool(ctx: &HostContext) -> Result<(), Box> { - let mut counters = ctx.counters.lock().expect("counters poisoned"); - if counters.tool >= ctx.policy.max_tool_calls { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "tool call limit ({}) exceeded", - ctx.policy.max_tool_calls - )), - )); - } - counters.tool += 1; - Ok(()) -} - -/// Increments and bounds the graph-run counter. -fn bump_graph(ctx: &HostContext) -> Result<(), Box> { - let mut counters = ctx.counters.lock().expect("counters poisoned"); - if counters.graph >= ctx.policy.max_graph_calls { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "graph call limit ({}) exceeded", - ctx.policy.max_graph_calls - )), - )); - } - counters.graph += 1; - Ok(()) -} - -/// Increments and bounds the agent-call counter. -fn bump_agent(ctx: &HostContext) -> Result<(), Box> { - let mut counters = ctx.counters.lock().expect("counters poisoned"); - if counters.agent >= ctx.policy.max_agent_calls { - return Err(raise( - ctx, - TinyAgentsError::LimitExceeded(format!( - "agent call limit ({}) exceeded", - ctx.policy.max_agent_calls - )), - )); - } - counters.agent += 1; - Ok(()) -} - -/// Enforces the recursion-depth bound for a sub-run (agent or graph). -/// -/// Reuses the harness recursion bookkeeping (Cluster G): a sub-run executes one -/// level below the session's run depth, and a child depth past -/// [`ReplPolicy::max_depth`] fails closed with -/// [`TinyAgentsError::SubAgentDepth`]. -fn check_depth(ctx: &HostContext) -> Result<(), Box> { - // Funnel the depth-cap check through the shared harness guard so the REPL - // sub-run bound stays in lock-step with SubAgent/SubAgentTool. - crate::harness::context::RunConfig::checked_child_depth(ctx.run_depth, ctx.policy.max_depth) - .map(|_| ()) - .map_err(|err| raise(ctx, err)) -} - -// ── Request builders ──────────────────────────────────────────────────────── - -/// Builds a [`ModelRequest`] from a `model_query` argument map. -/// -/// `model` here is the *registry* alias the script named (`map_str(params, -/// "model")`), not a provider model id — `CapabilityRegistry::register_model` -/// allows them to differ. Leave `ModelRequest::model` unset: the resolved -/// `ChatModel` already carries its own provider configuration, and a provider -/// transport that reads `request.model` (falling back to its own model only -/// when unset) would otherwise send the registry alias itself as the model id -/// on the wire. Mirrors `RlmHost::handle_llm` / `RlmRunner::run` in -/// `src/rlm/`, which build `ModelRequest { messages, ..Default::default() }` -/// for exactly this reason. -fn build_model_request(params: &Map) -> ModelRequest { - let mut messages = Vec::new(); - if let Some(system) = map_str(params, "system") { - messages.push(Message::system(system)); - } - if let Some(prompt) = map_str(params, "prompt") { - messages.push(Message::user(prompt)); - } - ModelRequest { - messages, - ..Default::default() - } -} - -/// Wraps a model response text as the script-visible value (a string by -/// default, or a structured map when `structured: true`). -fn model_value(text: String, finish_reason: Option, structured: bool) -> Dynamic { - if structured { - let mut map = Map::new(); - map.insert("content".into(), Dynamic::from(text)); - if let Some(reason) = finish_reason { - map.insert("finish_reason".into(), Dynamic::from(reason)); - } - Dynamic::from_map(map) - } else { - Dynamic::from(text) - } -} - -mod authoring; -mod batched; -mod capabilities; - -use authoring::*; -use batched::*; -use capabilities::*; - -// ── Engine construction ───────────────────────────────────────────────────── - -/// Sentinel exception value `on_progress` terminates a script with when the -/// per-cell [`ReplPolicy::timeout`] deadline elapses. `eval_cell` recognizes -/// this exact string and maps it to `TinyAgentsError::Timeout` instead of the -/// generic runtime-error path. -pub(super) const DEADLINE_EXCEEDED_TOKEN: &str = "ragsh cell exceeded its wall-clock timeout"; - -/// Sentinel exception value `on_progress` terminates a script with when an -/// external [`ReplCancelFlag`] is tripped mid-script. `eval_cell`'s -/// `map_rhai_error` recognizes this exact string and maps it to -/// [`TinyAgentsError::Cancelled`] instead of the generic runtime-error path. -pub(super) const CANCELLED_TOKEN: &str = "ragsh cell cancelled by host"; - -/// Builds a sandboxed Rhai engine for a session, registering every host-backed -/// built-in against the session's live registries and policy. -/// -/// The engine is configured with the policy operation limit (fail-closed on -/// runaway scripts) and is granted no filesystem, network, or process access — -/// the only host surface is the capability functions registered here. -pub(super) fn build_engine(ctx: Arc>) -> Engine { - let mut engine = Engine::new(); - engine.set_max_operations(ctx.policy.max_operations); - - // Fail-closed wall-clock deadline: `eval_cell` arms `ctx.buffers`'s - // per-cell deadline before running the script. `on_progress` is polled - // between Rhai statements/operations, so this catches runaway *script* - // loops (a busy `while true {}` with no host calls) that `max_operations` - // alone might not bound tightly enough in wall-clock terms. Host - // capability calls (`model_query`, `tool_call`, …) are bounded separately - // by `bridge_block_on`, since a blocked native call never yields back to - // `on_progress`. - let deadline_ctx = ctx.clone(); - engine.on_progress(move |_ops| { - // External cancellation takes precedence: a host that tripped the - // cancel flag mid-script terminates the cell at the next - // statement/operation with the cancellation sentinel, which - // `map_rhai_error` maps to `TinyAgentsError::Cancelled`. - if deadline_ctx.cancel.is_cancelled() { - return Some(Dynamic::from(CANCELLED_TOKEN.to_string())); - } - // A fail-closed host check (currently: push_stdout_line's - // max_output_bytes enforcement) may have stashed an error without - // Rhai itself failing; abort promptly instead of letting the script - // keep running until it happens to yield control back naturally. - // `eval_cell` prefers the stashed error over this sentinel's text. - if deadline_ctx.buffers.host_error_pending() { - return Some(Dynamic::from(DEADLINE_EXCEEDED_TOKEN.to_string())); - } - match deadline_ctx.buffers.deadline() { - Some(deadline) if Instant::now() >= deadline => { - Some(Dynamic::from(DEADLINE_EXCEEDED_TOKEN.to_string())) - } - _ => None, - } - }); - - // ── stdout capture ── - let stdout_ctx = ctx.clone(); - engine.on_print(move |text| stdout_ctx.buffers.push_stdout_line(text)); - let debug_ctx = ctx.clone(); - engine.on_debug(move |text, _source, _pos| debug_ctx.buffers.push_stdout_line(text)); - - // ── emit(name) / emit(name, #{ ... }) ── - let emit_ctx = ctx.clone(); - engine.register_fn("emit", move |name: &str| { - record( - &emit_ctx, - new_call_id(), - ReplCallKind::Emit, - name, - Value::Null, - Duration::default(), - ); - }); - let emit_payload_ctx = ctx.clone(); - engine.register_fn("emit", move |name: &str, data: Map| { - let detail = dynamic_to_repl_value(&Dynamic::from_map(data)).to_json(); - record( - &emit_payload_ctx, - new_call_id(), - ReplCallKind::Emit, - name, - detail, - Duration::default(), - ); - }); - - // ── answer(content) ── - let answer_ctx = ctx.clone(); - engine.register_fn("answer", move |content: &str| { - answer_ctx.buffers.set_answer(content.to_string()); - }); - - // ── show_vars() ── - let show_ctx = ctx.clone(); - engine.register_fn("show_vars", move || { - show_ctx.buffers.push_stdout_line("# vars"); - for (name, value) in show_ctx.buffers.vars_snapshot() { - show_ctx - .buffers - .push_stdout_line(&format!("{name} = {value}")); - } - }); - - // ── model capabilities ── - let model_ctx = ctx.clone(); - engine.register_fn("model_query", move |params: Map| { - model_query_impl(&model_ctx, ¶ms) - }); - let model_batch_ctx = ctx.clone(); - engine.register_fn("model_query_batched", move |items: Array| { - model_query_batched_impl(&model_batch_ctx, &items) - }); - - // ── tool capabilities ── - let tool_ctx = ctx.clone(); - engine.register_fn("tool_call", move |params: Map| { - tool_call_impl(&tool_ctx, ¶ms) - }); - let tool_batch_ctx = ctx.clone(); - engine.register_fn("tool_call_batched", move |items: Array| { - tool_call_batched_impl(&tool_batch_ctx, &items) - }); - - // ── agent capabilities ── - let agent_ctx = ctx.clone(); - engine.register_fn("agent_query", move |params: Map| { - agent_query_impl(&agent_ctx, ¶ms) - }); - let agent_batch_ctx = ctx.clone(); - engine.register_fn("agent_query_batched", move |items: Array| { - agent_query_batched_impl(&agent_batch_ctx, &items) - }); - - // ── graph run capabilities ── - let graph_ctx = ctx.clone(); - engine.register_fn("graph_run", move |params: Map| { - graph_run_impl(&graph_ctx, ¶ms) - }); - let graph_batch_ctx = ctx.clone(); - engine.register_fn("graph_run_batched", move |items: Array| { - graph_run_batched_impl(&graph_batch_ctx, &items) - }); - - // ── graph authoring (lowering through the `.rag` compiler) ── - let define_ctx = ctx.clone(); - engine.register_fn("graph_define", move |params: Map| { - graph_define_impl(&define_ctx, ¶ms) - }); - let validate_ctx = ctx.clone(); - engine.register_fn("graph_validate", move |descriptor: Map| { - graph_validate_impl(&validate_ctx, &descriptor) - }); - let compile_ctx = ctx.clone(); - engine.register_fn("graph_compile", move |descriptor: Map| { - graph_compile_impl(&compile_ctx, &descriptor) - }); - let diff_name_ctx = ctx.clone(); - engine.register_fn( - "graph_diff", - move |name: &str, draft: Map| -> Result> { - let old = diff_name_ctx - .registry - .graph_blueprint(name) - .ok_or_else(|| { - invalid( - &diff_name_ctx, - format!("graph_diff: graph `{name}` is not registered"), - ) - })? - .clone(); - let new = lookup_draft(&diff_name_ctx, &draft, "graph_diff")?; - graph_diff_handles(&diff_name_ctx, &old, &new.blueprint) - }, - ); - let diff_draft_ctx = ctx.clone(); - engine.register_fn( - "graph_diff", - move |old: Map, new: Map| -> Result> { - let old = lookup_draft(&diff_draft_ctx, &old, "graph_diff")?; - let new = lookup_draft(&diff_draft_ctx, &new, "graph_diff")?; - graph_diff_handles(&diff_draft_ctx, &old.blueprint, &new.blueprint) - }, - ); - let register_ctx = ctx.clone(); - engine.register_fn("graph_register", move |params: Map| { - graph_register_impl(®ister_ctx, ¶ms) - }); - - engine -} - -#[cfg(test)] -mod bridge_deadline_test { - use super::*; - - #[test] - fn no_deadline_awaits_to_completion() { - let out = bridge_block_on::(None, &ReplCancelFlag::new(), async { Ok(7) }) - .expect("no deadline"); - assert_eq!(out, 7); - } - - #[test] - fn future_finishing_before_the_deadline_succeeds() { - let deadline = Instant::now() + Duration::from_secs(5); - let out = - bridge_block_on::(Some(deadline), &ReplCancelFlag::new(), async { Ok(9) }) - .expect("within deadline"); - assert_eq!(out, 9); - } - - #[test] - fn deadline_already_elapsed_fails_closed_without_starting_the_call() { - // Regression test: `ReplPolicy::timeout` used to be parsed but never - // enforced anywhere a host capability call could hang forever. - let deadline = Instant::now() - Duration::from_millis(1); - let err = - bridge_block_on::(Some(deadline), &ReplCancelFlag::new(), async { Ok(1) }) - .expect_err("deadline already passed"); - assert!(matches!(err, TinyAgentsError::Timeout(_)), "got {err:?}"); - } - - #[test] - fn a_hanging_call_is_cut_off_at_the_deadline_instead_of_blocking_forever() { - // A future that never resolves models a hung provider/tool call. The - // deadline must still return control promptly rather than hanging the - // whole `eval_cell` (and therefore the session) forever. - let start = Instant::now(); - let deadline = start + Duration::from_millis(30); - let err = bridge_block_on::( - Some(deadline), - &ReplCancelFlag::new(), - futures::future::pending::>(), - ) - .expect_err("hanging call must be cut off at the deadline"); - assert!(matches!(err, TinyAgentsError::Timeout(_)), "got {err:?}"); - assert!( - start.elapsed() < Duration::from_secs(5), - "took {:?}, should return promptly at the 30ms deadline", - start.elapsed() - ); - } - - #[test] - fn cancel_already_set_fails_closed_without_starting_the_call() { - // A flag tripped before the bridge runs must short-circuit to - // `Cancelled` without ever polling the (here, never-resolving) future. - let cancel = ReplCancelFlag::new(); - cancel.cancel(); - let err = bridge_block_on::( - None, - &cancel, - futures::future::pending::>(), - ) - .expect_err("pre-cancelled call must not start"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - } - - #[test] - fn a_hanging_call_is_cut_off_promptly_when_the_cancel_flag_trips() { - // With no deadline, a hung capability future must still be released - // once a host trips the cancel flag from another thread — the watcher - // polls the flag and drops the future. - let start = Instant::now(); - let cancel = ReplCancelFlag::new(); - let trigger = cancel.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(40)); - trigger.cancel(); - }); - let err = bridge_block_on::( - None, - &cancel, - futures::future::pending::>(), - ) - .expect_err("hanging call must be cut off on cancel"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - assert!( - start.elapsed() < Duration::from_secs(5), - "took {:?}, should return promptly after the ~40ms cancel", - start.elapsed() - ); - } -} diff --git a/src/repl/session/mod.rs b/src/repl/session/mod.rs deleted file mode 100644 index e7ec3ffa..00000000 --- a/src/repl/session/mod.rs +++ /dev/null @@ -1,870 +0,0 @@ -//! Rhai-backed `.ragsh` session runtime (design milestone R2). -//! -//! [`ReplSession`] is the imperative counterpart to the declarative `.rag` -//! language: an orchestrator (a human, or a model acting as one) drives a -//! session one *cell* at a time. Each cell is a small Rhai script evaluated -//! against a **persistent namespace** — top-level `let` bindings survive into -//! the next cell, exactly like the persistent locals of a Recursive Language -//! Model REPL — while model, tool, and graph capabilities are exposed as -//! host-registered functions rather than script-native side effects. -//! -//! This module implements the runtime core of milestone R2: -//! -//! - an [`rhai::Engine`] configured with -//! [`set_max_operations`](rhai::Engine::set_max_operations) so a runaway -//! script *fails closed* instead of hanging the host; -//! - a persistent [`rhai::Scope`] shared across cells; -//! - captured `print` output, returned values, and changed variables; -//! - `emit(...)` and `answer(...)` built-ins recorded as typed data; -//! - byte limits on both script input and captured output; -//! - restoration of reserved core names after every cell so a script can add -//! locals but cannot permanently replace `context`, `answer`, `model_query`, -//! `graph_run`, or any other reserved capability. -//! -//! Capability functions (`model_query`, `tool_call`, `graph_run`, the -//! `graph_*` blueprint surface, …) are wired to the real registries by later -//! slices; this slice establishes the session, policy, and result types they -//! plug into. Generated graph topology is never installed directly — it must -//! pass through the `.rag` compiler, the capability resolver, and the policy -//! review gate. -//! -//! The whole module is gated behind the `repl` cargo feature so the default -//! build does not pull in the Rhai engine. - -mod builtins; -mod types; - -#[cfg(test)] -mod test; - -pub use types::*; - -use builtins::{CallCounters, HostContext}; - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use rhai::{Dynamic, Engine, EvalAltResult, Scope}; - -use crate::error::{Result, TinyAgentsError}; -use crate::harness::context::RunContext; -use crate::harness::events::EventSink; -use crate::harness::ids::{SessionId, new_session_id}; - -/// Shared host-side buffers the registered Rhai built-ins write into. -/// -/// Cloned (cheaply, via `Arc`) into the engine's `on_print`, `emit`, -/// `answer`, and capability closures at construction, and read back by -/// [`ReplSession::eval_cell`] after each cell. -/// -/// `host_error` lets a fallible capability function (which can only surface a -/// stringly-typed [`rhai::EvalAltResult`] across the engine boundary) stash the -/// precise [`TinyAgentsError`] it failed with; [`ReplSession::eval_cell`] -/// prefers that error over the generic Rhai runtime error so callers see the -/// real diagnostic (`ModelNotFound`, `LimitExceeded`, …). `vars_snapshot` holds -/// the persistent namespace as of the start of the current cell so the -/// `show_vars()` built-in can print it. -#[derive(Clone, Default)] -pub(super) struct CellBuffers { - stdout: Arc>, - calls: Arc>>, - answer: Arc>>, - host_error: Arc>>, - /// The most recent *recoverable* capability error raised this cell (see - /// `builtins::raise`/`builtins::is_fatal`), regardless of whether the - /// script caught it. Unlike `host_error`, this is never consulted by - /// `on_progress` or the success path, so a `try`/`catch`ed error has no - /// further effect once the script continues normally — only - /// [`ReplSession::eval_cell`]'s error path reads it, to recover the typed - /// error for a capability failure the script left uncaught instead of - /// falling back to a stringly-wrapped [`TinyAgentsError::Validation`]. - last_capability_error: Arc>>, - vars_snapshot: Arc>>, - /// The wall-clock instant the current cell's [`ReplPolicy::timeout`] - /// expires at, if the policy configures one. Set at the start of - /// [`ReplSession::eval_cell`] and read by every host capability call (via - /// [`builtins::bridge_block_on`]) and the engine's `on_progress` hook, so - /// the deadline is enforced fail-closed both for pure script loops and for - /// in-flight model/tool/agent/graph calls. - deadline: Arc>>, - /// The current cell's [`ReplPolicy::max_output_bytes`] budget, armed at - /// the start of every [`ReplSession::eval_cell`] call and enforced - /// fail-closed inside [`CellBuffers::push_stdout_line`] itself, so a - /// print-heavy runaway script cannot buffer unbounded output before the - /// end-of-cell check in `eval_cell` ever runs. - max_output_bytes: Arc>>, -} - -/// The persistent variable namespace of a session. -/// -/// Wraps the Rhai [`Scope`] that survives across cells together with the -/// baseline values of the reserved names, which are restored after each cell. -pub struct ReplVariables { - scope: Scope<'static>, - reserved_baseline: BTreeMap, -} - -impl ReplVariables { - /// Seeds a fresh namespace with the reserved built-in variables set to unit. - fn seeded() -> Self { - let mut scope = Scope::new(); - let mut reserved_baseline = BTreeMap::new(); - for name in reserved_names() { - let value = Dynamic::UNIT; - scope.push(name.to_string(), value.clone()); - reserved_baseline.insert(name.to_string(), value); - } - Self { - scope, - reserved_baseline, - } - } - - /// Sets a persistent (non-reserved) variable from a [`ReplValue`]. - /// - /// Reserved names are rejected so callers cannot smuggle a capability - /// replacement through the variable surface; use [`ReplSession::set_context`] - /// and friends for the reserved data slots. - pub fn set(&mut self, name: impl Into, value: ReplValue) -> Result<()> { - let name = name.into(); - if reserved_names().any(|r| name == r) { - return Err(TinyAgentsError::Capability(format!( - "`{name}` is a reserved REPL name and cannot be set as a variable" - ))); - } - self.scope.set_value(name, repl_value_to_dynamic(&value)); - Ok(()) - } - - /// Returns the current value of a variable, if present. - pub fn get(&self, name: &str) -> Option { - self.scope - .get_value::(name) - .map(|d| dynamic_to_repl_value(&d)) - } - - /// Overwrites a reserved data slot's baseline and current value. - fn set_reserved(&mut self, name: &str, value: Dynamic) { - self.scope.set_value(name.to_string(), value.clone()); - self.reserved_baseline.insert(name.to_string(), value); - } - - /// Snapshots the current `name -> debug-string` view of the scope, used to - /// detect which variables a cell changed. - fn snapshot(&self) -> BTreeMap { - let mut map = BTreeMap::new(); - for (name, _is_const, value) in self.scope.iter() { - map.insert(name.to_string(), format!("{value:?}")); - } - map - } - - /// Restores every reserved name to its session baseline, discarding any - /// script-level reassignment or shadowing from the cell just evaluated. - fn restore_reserved(&mut self) { - for (name, value) in &self.reserved_baseline { - self.scope.set_value(name.clone(), value.clone()); - } - } -} - -impl Default for ReplVariables { - fn default() -> Self { - Self::seeded() - } -} - -/// An interactive Rhai-backed `.ragsh` session. -/// -/// See the [module docs](self) for the runtime model. Construct a default, -/// stateless session with [`ReplSession::new`]; supply registries, a custom -/// policy, or a run context with [`ReplSession::builder`]-style `with_*` -/// methods. -/// -/// # Not to be confused with `repl::ReplSession` -/// -/// This crate has two distinct types named `ReplSession`: -/// -/// - **This type** (`repl::session::ReplSession`, feature `repl` only) — the -/// Rhai-backed scripting session described above; also reachable as -/// `crate::ReplSession` (the crate-root re-export) when the `repl` feature -/// is enabled. -/// - [`crate::repl::ReplSession`] (always available, no feature required) — -/// the line-oriented command skeleton (verbs like `set`/`get`/`run`/`call` -/// parsed from a single line). It is *not* re-exported at the crate root -/// under this feature, so `crate::ReplSession` only ever means this -/// scripting session once `repl` is enabled. -/// -/// The two are unrelated types serving different layers of the `.ragsh` -/// design; always check which module path (`crate::repl::session::ReplSession` -/// vs. `crate::repl::ReplSession`) you imported from. -pub struct ReplSession -where - State: Send + Sync, -{ - /// Unique id for this session. - pub session_id: SessionId, - /// The harness run context this session executes within. - pub run_context: RunContext, - /// The persistent variable namespace. - pub variables: ReplVariables, - /// The named capabilities this session may bind against. - pub capabilities: ReplCapabilities, - /// The resource limits bounding this session. - pub policy: ReplPolicy, - /// The event sink REPL events are emitted on. - pub events: EventSink, - /// The application state capability calls (`model_query`, `tool_call`, - /// `agent_query`, …) are invoked against. For a stateless session this is - /// `Arc::new(())`. Distinct from the reserved Rhai `state` *variable*, which - /// is a script-visible data slot. - state: Arc, - /// Session-cumulative capability-call counters, enforced against the - /// `max_*_calls` policy limits. Shared with the engine's capability - /// closures and persisted across cells. - counters: Arc>, - /// Graph blueprints drafted by `graph_define` in this session, keyed by - /// graph name. Persisted across cells so a graph defined in one cell can be - /// validated, compiled, diffed, or registered in another. The actual - /// topology is never installed here — it stays a draft until it passes the - /// `.rag` compiler, the capability resolver, and the policy review gate. - drafts: Arc>>, - /// The configured Rhai engine. Private: its registered functions are the - /// capability boundary and must not be mutated by callers. - engine: Engine, - /// Shared buffers the engine's built-ins write into. - buffers: CellBuffers, - /// Number of cells evaluated so far this session, enforced fail-closed - /// against [`ReplPolicy::max_iterations`]. Each `eval_cell` call is one - /// CodeAct-style iteration of a model-driven session. - iterations: usize, - /// External cancellation flag. A host holding a clone can abort an in-flight - /// cell (see [`ReplCancelFlag`]); enforced fail-closed in both the engine - /// `on_progress` hook and the blocking capability bridge. Cloned into the - /// engine's [`HostContext`] on every [`rebuild_engine`](Self::rebuild_engine). - cancel: ReplCancelFlag, -} - -impl ReplSession { - /// Creates a default, stateless session with empty capabilities and the - /// default [`ReplPolicy`]. - pub fn new() -> Self { - Self::from_parts( - ReplCapabilities::default(), - ReplPolicy::default(), - RunContext::new( - crate::harness::context::RunConfig::new(format!( - "repl-run-{}", - crate::harness::ids::next_seq() - )), - (), - ), - ) - } -} - -impl Default for ReplSession { - fn default() -> Self { - Self::new() - } -} - -impl ReplSession { - /// Assembles a session from its capabilities, policy, and run context, with - /// a default application state. - /// - /// The session id is generated from the crate's monotonic id source (no - /// wall-clock time or randomness), and the session's [`EventSink`] is shared - /// with the run context so REPL events compose with harness events. Supply a - /// non-default application state with [`with_state`](Self::with_state). - pub fn from_parts( - capabilities: ReplCapabilities, - policy: ReplPolicy, - run_context: RunContext, - ) -> Self { - let buffers = CellBuffers::default(); - let events = run_context.events.clone(); - let mut session = Self { - session_id: new_session_id(), - run_context, - variables: ReplVariables::seeded(), - capabilities, - policy, - events, - state: Arc::new(State::default()), - counters: Arc::new(Mutex::new(CallCounters::default())), - drafts: Arc::new(Mutex::new(BTreeMap::new())), - engine: Engine::new(), - buffers, - iterations: 0, - cancel: ReplCancelFlag::new(), - }; - session.rebuild_engine(); - session - } -} - -impl ReplSession { - /// (Re)builds the sandboxed Rhai engine from the session's current policy, - /// capabilities, and application state, registering every host-backed - /// built-in function against the live registries. Called after any change to - /// policy, capabilities, or state. - fn rebuild_engine(&mut self) { - let ctx = Arc::new(HostContext { - registry: self.capabilities.registry.clone(), - state: self.state.clone(), - policy: self.policy.clone(), - language: self.capabilities.language.clone(), - session_label: self.session_id.as_str().to_string(), - run_depth: self.run_context.config.depth, - events: self.events.clone(), - buffers: self.buffers.clone(), - counters: self.counters.clone(), - drafts: self.drafts.clone(), - cancel: self.cancel.clone(), - }); - self.engine = builtins::build_engine(ctx); - } - - /// Installs an external [`ReplCancelFlag`] and rebuilds the engine so the - /// `on_progress` hook and the blocking capability bridge observe it. - /// - /// The host keeps a clone of `flag` and calls [`ReplCancelFlag::cancel`] to - /// abort an in-flight cell; the cell then fails with - /// [`TinyAgentsError::Cancelled`]. Because a cancelled flag is sticky, pass a - /// **fresh** flag when reusing a session whose previous run was cancelled. - pub fn with_cancel_flag(mut self, flag: ReplCancelFlag) -> Self { - self.cancel = flag; - self.rebuild_engine(); - self - } - - /// Returns a clone of this session's cancellation flag, so a host that did - /// not supply one via [`with_cancel_flag`](Self::with_cancel_flag) can still - /// obtain the handle needed to abort an in-flight cell. - pub fn cancel_flag(&self) -> ReplCancelFlag { - self.cancel.clone() - } - - /// Installs a fresh cancellation flag in place (`&mut self`), rebuilding the - /// engine so the `on_progress` hook and the blocking bridge observe it. - /// - /// Unlike the consuming [`with_cancel_flag`](Self::with_cancel_flag), this - /// swaps the flag on a session already owned behind a lock — the shape a - /// long-lived session manager needs. Because a cancelled flag is sticky, - /// installing a **fresh** flag before each cell lets a persistent session - /// stay resumable after a prior cell was cancelled. The persistent variable - /// namespace is untouched (only the engine is rebuilt), so `let` bindings - /// survive the swap. - pub fn set_cancel_flag(&mut self, flag: ReplCancelFlag) { - self.cancel = flag; - self.rebuild_engine(); - } - - /// Replaces the session policy and rebuilds the engine to honor the new - /// operation and call limits. - pub fn with_policy(mut self, policy: ReplPolicy) -> Self { - self.policy = policy; - self.rebuild_engine(); - self - } - - /// Replaces the session capabilities and rebuilds the engine so the - /// capability functions resolve against the new registries. - pub fn with_capabilities(mut self, capabilities: ReplCapabilities) -> Self { - self.capabilities = capabilities; - self.rebuild_engine(); - self - } - - /// Replaces the application state capability calls are invoked against and - /// rebuilds the engine. - pub fn with_state(mut self, state: Arc) -> Self { - self.state = state; - self.rebuild_engine(); - self - } - - /// Returns a shared handle to the application state capability calls are - /// invoked against. - /// - /// A CodeAct-style driver loop needs this to invoke the session's driver - /// model through the same state the in-cell capability functions use, - /// without exposing the private field. - pub fn app_state(&self) -> Arc { - self.state.clone() - } - - /// Sets the reserved `context` variable. - pub fn set_context(&mut self, value: ReplValue) { - self.variables - .set_reserved("context", repl_value_to_dynamic(&value)); - } - - /// Sets the reserved `state` variable. - pub fn set_state_var(&mut self, value: ReplValue) { - self.variables - .set_reserved("state", repl_value_to_dynamic(&value)); - } - - /// Evaluates a single `.ragsh` cell against the persistent namespace. - /// - /// Captures stdout, the cell's return value, the persistent variables it - /// changed, recorded `emit`/`answer` calls, and elapsed time. Reserved core - /// names are restored afterward so the next cell starts from a clean - /// capability baseline. - /// - /// # Blocking — driving this from an async host - /// - /// **This method blocks the calling thread.** The Rhai engine is - /// synchronous, so each `model_query`/`tool_call`/`agent_query` a cell - /// performs is driven to completion by an internal - /// [`futures::executor::block_on`] (the "blocking bridge"; see - /// [`builtins`](self::builtins)). Calling `eval_cell` directly on an async - /// worker therefore blocks that worker for the whole cell, and on a - /// **current-thread** Tokio runtime it deadlocks — `block_on` parks the only - /// worker the in-flight capability future needs to make progress. - /// - /// An async host **must** run `eval_cell` off the async workers, on a - /// blocking-safe thread: - /// - /// ```ignore - /// let result = tokio::task::spawn_blocking(move || session.eval_cell(&script)).await?; - /// ``` - /// - /// A multi-threaded runtime with a spare worker also works, but - /// `spawn_blocking` (or a dedicated thread) is the contract. Because - /// `eval_cell` takes `&mut self`, only one cell runs per session at a time; - /// the host serializes concurrent calls to the same session. To bound a - /// cell's wall clock from the async side as well, wrap the join handle in a - /// [`tokio::time::timeout`] and install a [`ReplCancelFlag`] via - /// [`with_cancel_flag`](Self::with_cancel_flag) so the blocked worker is - /// released rather than leaked. - /// - /// # Errors - /// - /// * [`TinyAgentsError::LimitExceeded`] — the script exceeds - /// [`ReplPolicy::max_script_bytes`], the output exceeds - /// [`ReplPolicy::max_output_bytes`], the engine operation limit - /// (fail-closed runaway protection), or the session has already - /// evaluated [`ReplPolicy::max_iterations`] cells. - /// * [`TinyAgentsError::Timeout`] — the cell's wall-clock deadline - /// ([`ReplPolicy::timeout`]) elapsed, either mid-script or during a - /// model/tool/agent/graph call. - /// * [`TinyAgentsError::Validation`] — the script failed to compile or - /// raised a runtime error. - /// * [`TinyAgentsError::Cancelled`] — an external [`ReplCancelFlag`] was - /// tripped before or during the cell (mid-script via the `on_progress` - /// hook, or during an in-flight capability call via the blocking bridge). - pub fn eval_cell(&mut self, script: &str) -> Result { - let start = Instant::now(); - - // Fail closed if cancellation was requested before this cell even - // starts: a host that cancels between cells must not have its next cell - // begin any script or capability work. (Mid-cell cancellation is - // enforced separately by the `on_progress` hook and the capability - // bridge.) The iteration counter is left untouched so a cancelled, - // never-run cell does not consume the session's `max_iterations` budget. - if self.cancel.is_cancelled() { - return Err(TinyAgentsError::Cancelled); - } - - // Each call is one CodeAct-style iteration of a model-driven session; - // enforce the cap fail-closed before doing any other work. - if self.iterations >= self.policy.max_iterations { - return Err(TinyAgentsError::LimitExceeded(format!( - "ragsh session has evaluated {} cells, reaching the max_iterations limit of {}", - self.iterations, self.policy.max_iterations - ))); - } - self.iterations += 1; - - if script.len() > self.policy.max_script_bytes { - return Err(TinyAgentsError::LimitExceeded(format!( - "ragsh cell is {} bytes, exceeding the max_script_bytes limit of {}", - script.len(), - self.policy.max_script_bytes - ))); - } - - // Reset per-cell shared buffers and arm the wall-clock deadline (if - // the policy configures one) before any script or host-capability - // work begins. `on_progress` (see `builtins::build_engine`) enforces - // it for pure script execution; `bridge_block_on` enforces it around - // every model/tool/agent/graph call so a hanging host call cannot - // block the session forever either. - self.buffers.reset(); - self.buffers - .arm_deadline(self.policy.timeout.map(|d| start + d)); - self.buffers.arm_output_limit(self.policy.max_output_bytes); - - // Snapshot the pre-cell namespace once and move it into the shared - // `vars_snapshot` (read by `show_vars()` during the cell). The diff below - // reads this same baseline back rather than keeping a second full copy, - // so a cell pays one baseline snapshot instead of a snapshot *plus* a - // full O(namespace-bytes) clone. - *self - .buffers - .vars_snapshot - .lock() - .expect("vars_snapshot poisoned") = self.variables.snapshot(); - - // Disjoint field borrows: the engine is read-only while the scope is - // mutated in place, so top-level `let` bindings persist into the scope. - let eval = self - .engine - .eval_with_scope::(&mut self.variables.scope, script); - - // Always restore reserved names, even on error, so a failed cell cannot - // leave a half-overwritten capability baseline behind. - self.variables.restore_reserved(); - - let value_dynamic = match eval { - Ok(value) => { - // The script may have completed "successfully" from Rhai's - // point of view even though a host-side fail-closed check - // (e.g. push_stdout_line's max_output_bytes enforcement) - // stashed an error — `on_print`/`on_debug` cannot themselves - // fail a script, so this is the only place that catches it. - if let Some(host_err) = self.buffers.take_host_error() { - return Err(host_err); - } - value - } - Err(err) => { - // A fallible capability function stashes its precise crate error - // here; prefer it over the generic Rhai runtime wrapper so the - // caller sees the real diagnostic. - if let Some(host_err) = self.buffers.take_host_error() { - return Err(host_err); - } - // The script left a *recoverable* capability error uncaught. - // `raise` stashed its typed form (without aborting the cell); - // recover it here rather than reporting the generic, - // stringly-wrapped Rhai runtime error — but only when the - // propagated error is actually that same failure (an - // unrelated later error must not be misreported as the - // earlier, already-handled one). - let mapped = map_rhai_error(*err); - if let Some(last) = self.buffers.take_last_capability_error() - && matches!(&mapped, TinyAgentsError::Validation(msg) if msg.contains(&last.to_string())) - { - return Err(last); - } - return Err(mapped); - } - }; - - let value = if value_dynamic.is_unit() { - None - } else { - Some(dynamic_to_repl_value(&value_dynamic)) - }; - - let stdout = self.buffers.stdout(); - let calls = self.buffers.take_calls(); - let final_answer = self.buffers.answer(); - - // Enforce the output byte limit fail-closed. - let value_bytes = value.as_ref().map(ReplValue::byte_len).unwrap_or(0); - if stdout.len() + value_bytes > self.policy.max_output_bytes { - return Err(TinyAgentsError::LimitExceeded(format!( - "ragsh cell produced {} bytes of output, exceeding the max_output_bytes limit of {}", - stdout.len() + value_bytes, - self.policy.max_output_bytes - ))); - } - - let after = self.variables.snapshot(); - // Diff against the baseline stored in `vars_snapshot` instead of a - // separately retained `before` map, avoiding a redundant full copy. - let variables_changed = { - let before = self - .buffers - .vars_snapshot - .lock() - .expect("vars_snapshot poisoned"); - diff_changed(&before, &after) - }; - - Ok(ReplResult { - stdout, - value, - variables_changed, - calls, - final_answer, - elapsed: start.elapsed(), - }) - } -} - -impl CellBuffers { - fn reset(&self) { - self.stdout.lock().expect("stdout poisoned").clear(); - self.calls.lock().expect("calls poisoned").clear(); - *self.answer.lock().expect("answer poisoned") = None; - *self.host_error.lock().expect("host_error poisoned") = None; - *self - .last_capability_error - .lock() - .expect("last_capability_error poisoned") = None; - *self.deadline.lock().expect("deadline poisoned") = None; - *self - .max_output_bytes - .lock() - .expect("max_output_bytes poisoned") = None; - } - - /// Arms the per-cell wall-clock deadline, replacing any previous one. - fn arm_deadline(&self, deadline: Option) { - *self.deadline.lock().expect("deadline poisoned") = deadline; - } - - /// Arms the per-cell output-byte budget, replacing any previous one. - /// Read by [`CellBuffers::push_stdout_line`] on every captured line. - fn arm_output_limit(&self, max_bytes: usize) { - *self - .max_output_bytes - .lock() - .expect("max_output_bytes poisoned") = Some(max_bytes); - } - - /// Returns the current cell's wall-clock deadline, if the policy - /// configured a timeout. Read by every host capability call and the - /// engine's `on_progress` hook (see [`builtins::bridge_block_on`]). - pub(super) fn deadline(&self) -> Option { - *self.deadline.lock().expect("deadline poisoned") - } - - fn stdout(&self) -> String { - self.stdout.lock().expect("stdout poisoned").clone() - } - - fn take_calls(&self) -> Vec { - std::mem::take(&mut *self.calls.lock().expect("calls poisoned")) - } - - fn answer(&self) -> Option { - self.answer.lock().expect("answer poisoned").clone() - } - - fn take_host_error(&self) -> Option { - self.host_error.lock().expect("host_error poisoned").take() - } - - /// Takes the most recently stashed recoverable capability error, if any. - fn take_last_capability_error(&self) -> Option { - self.last_capability_error - .lock() - .expect("last_capability_error poisoned") - .take() - } - - // ── Accessors used by the capability built-ins (in `builtins.rs`). ── - - /// Pushes a recorded capability call/event. - pub(super) fn push_call(&self, record: ReplCallRecord) { - self.calls.lock().expect("calls poisoned").push(record); - } - - /// Appends a line to the captured stdout buffer, enforcing the armed - /// [`ReplPolicy::max_output_bytes`] budget fail-closed: once appending - /// would exceed the budget, the line is dropped (not buffered) and a - /// [`TinyAgentsError::LimitExceeded`] is stashed for `eval_cell` to - /// surface, instead of growing the buffer without bound for the rest of - /// the cell. - pub(super) fn push_stdout_line(&self, line: &str) { - let limit = *self - .max_output_bytes - .lock() - .expect("max_output_bytes poisoned"); - let mut out = self.stdout.lock().expect("stdout poisoned"); - if let Some(limit) = limit { - let projected = out.len() + line.len() + 1; - if projected > limit { - drop(out); - self.set_host_error(TinyAgentsError::LimitExceeded(format!( - "ragsh cell produced more than {limit} bytes of output, exceeding the max_output_bytes limit" - ))); - return; - } - } - out.push_str(line); - out.push('\n'); - } - - /// Returns whether a host error is currently stashed, without consuming - /// it. Used by the engine's `on_progress` hook to abort a script promptly - /// once [`push_stdout_line`](Self::push_stdout_line) has flagged the - /// output budget as exceeded, rather than letting the script keep running - /// until it happens to yield control back naturally. - pub(super) fn host_error_pending(&self) -> bool { - self.host_error - .lock() - .expect("host_error poisoned") - .is_some() - } - - /// Sets the session's final answer. - pub(super) fn set_answer(&self, content: String) { - *self.answer.lock().expect("answer poisoned") = Some(content); - } - - /// Stashes the precise crate error a fallible capability function failed - /// with, so `eval_cell` can surface it verbatim. - pub(super) fn set_host_error(&self, err: TinyAgentsError) { - *self.host_error.lock().expect("host_error poisoned") = Some(err); - } - - /// Stashes a *recoverable* capability error (see `builtins::is_fatal`) - /// without aborting the cell, so an uncaught occurrence can still be - /// reported with its precise type by [`ReplSession::eval_cell`]'s error - /// path. - pub(super) fn set_last_capability_error(&self, err: TinyAgentsError) { - *self - .last_capability_error - .lock() - .expect("last_capability_error poisoned") = Some(err); - } - - /// Returns the pre-cell namespace snapshot for `show_vars()`. - pub(super) fn vars_snapshot(&self) -> BTreeMap { - self.vars_snapshot - .lock() - .expect("vars_snapshot poisoned") - .clone() - } -} - -/// Maps a Rhai evaluation error to a crate error, distinguishing the -/// fail-closed operation-limit case from other compile/runtime failures. -fn map_rhai_error(err: EvalAltResult) -> TinyAgentsError { - match err { - EvalAltResult::ErrorTooManyOperations(pos) => TinyAgentsError::LimitExceeded(format!( - "ragsh cell exceeded the operation limit (max_operations) at {pos}" - )), - // The engine's `on_progress` hook (see `builtins::build_engine`) - // terminates the script with this exact sentinel value once an external - // [`ReplCancelFlag`] is tripped mid-script; map it to `Cancelled` so the - // host sees a cancellation rather than a generic validation error. - EvalAltResult::ErrorTerminated(token, _pos) - if token.clone().into_string().ok().as_deref() == Some(builtins::CANCELLED_TOKEN) => - { - TinyAgentsError::Cancelled - } - // The engine's `on_progress` hook (see `builtins::build_engine`) - // terminates the script with this exact sentinel value once the - // per-cell `ReplPolicy::timeout` deadline elapses. - EvalAltResult::ErrorTerminated(token, pos) - if token.clone().into_string().ok().as_deref() - == Some(builtins::DEADLINE_EXCEEDED_TOKEN) => - { - TinyAgentsError::Timeout(format!("{} at {pos}", builtins::DEADLINE_EXCEEDED_TOKEN)) - } - other => TinyAgentsError::Validation(format!("ragsh evaluation error: {other}")), - } -} - -/// Returns the names whose values were added or changed between two snapshots, -/// excluding reserved names (which are restored after each cell). -fn diff_changed( - before: &BTreeMap, - after: &BTreeMap, -) -> Vec { - let mut changed: Vec = after - .iter() - .filter(|(name, value)| { - !reserved_names().any(|r| r == name.as_str()) - && before.get(*name).map(|b| b != *value).unwrap_or(true) - }) - .map(|(name, _)| name.clone()) - .collect(); - changed.sort(); - changed.dedup(); - changed -} - -/// Converts a [`ReplValue`] into a Rhai [`Dynamic`]. -pub(super) fn repl_value_to_dynamic(value: &ReplValue) -> Dynamic { - match value { - ReplValue::Unit => Dynamic::UNIT, - ReplValue::Bool(b) => Dynamic::from_bool(*b), - ReplValue::Int(i) => Dynamic::from_int(*i), - ReplValue::Float(f) => Dynamic::from_float(*f), - ReplValue::String(s) => Dynamic::from(s.clone()), - ReplValue::Array(items) => { - let arr: rhai::Array = items.iter().map(repl_value_to_dynamic).collect(); - Dynamic::from_array(arr) - } - ReplValue::Map(map) => { - let mut rmap = rhai::Map::new(); - for (k, v) in map { - rmap.insert(k.as_str().into(), repl_value_to_dynamic(v)); - } - Dynamic::from_map(rmap) - } - } -} - -/// Converts a Rhai [`Dynamic`] into a typed [`ReplValue`]. -/// -/// Unsupported or opaque host values are stringified rather than leaking a Rhai -/// type across the capability boundary. -pub(super) fn dynamic_to_repl_value(value: &Dynamic) -> ReplValue { - if value.is_unit() { - return ReplValue::Unit; - } - if value.is_bool() { - return ReplValue::Bool(value.as_bool().unwrap_or(false)); - } - if value.is_int() { - return ReplValue::Int(value.as_int().unwrap_or(0)); - } - if value.is_float() { - return ReplValue::Float(value.as_float().unwrap_or(0.0)); - } - if value.is_string() { - return ReplValue::String(value.clone().into_string().unwrap_or_default()); - } - if value.is_array() { - let arr = value.clone().into_array().unwrap_or_default(); - return ReplValue::Array(arr.iter().map(dynamic_to_repl_value).collect()); - } - if value.is_map() - && let Some(map) = value.read_lock::() - { - let mut out = BTreeMap::new(); - for (k, v) in map.iter() { - out.insert(k.to_string(), dynamic_to_repl_value(v)); - } - return ReplValue::Map(out); - } - ReplValue::String(value.to_string()) -} - -/// Converts a [`serde_json::Value`] (as returned by a tool or model call) into -/// a typed [`ReplValue`] so capability results cross back into the script as -/// native Rhai values. -pub(super) fn json_to_repl_value(value: &serde_json::Value) -> ReplValue { - match value { - serde_json::Value::Null => ReplValue::Unit, - serde_json::Value::Bool(b) => ReplValue::Bool(*b), - serde_json::Value::Number(n) => { - if let Some(i) = n.as_i64() { - ReplValue::Int(i) - } else { - ReplValue::Float(n.as_f64().unwrap_or(0.0)) - } - } - serde_json::Value::String(s) => ReplValue::String(s.clone()), - serde_json::Value::Array(items) => { - ReplValue::Array(items.iter().map(json_to_repl_value).collect()) - } - serde_json::Value::Object(map) => ReplValue::Map( - map.iter() - .map(|(k, v)| (k.clone(), json_to_repl_value(v))) - .collect(), - ), - } -} diff --git a/src/repl/session/test.rs b/src/repl/session/test.rs deleted file mode 100644 index 2baa46b0..00000000 --- a/src/repl/session/test.rs +++ /dev/null @@ -1,753 +0,0 @@ -//! Unit tests for the Rhai-backed `.ragsh` session runtime. - -use std::time::{Duration, Instant}; - -use super::*; -use crate::error::TinyAgentsError; - -/// A fresh stateless session for tests. -fn session() -> ReplSession { - ReplSession::new() -} - -#[test] -fn evaluates_an_expression_and_returns_the_value() { - let mut s = session(); - let result = s.eval_cell("1 + 2").expect("eval"); - assert_eq!(result.value, Some(ReplValue::Int(3))); - assert!(result.calls.is_empty()); - assert_eq!(result.final_answer, None); -} - -#[test] -fn variables_persist_across_cells() { - let mut s = session(); - - let first = s.eval_cell("let counter = 5; counter").expect("cell 1"); - assert_eq!(first.value, Some(ReplValue::Int(5))); - assert!(first.variables_changed.contains(&"counter".to_string())); - - // The binding from cell 1 is visible in cell 2. - let second = s.eval_cell("counter + 1").expect("cell 2"); - assert_eq!(second.value, Some(ReplValue::Int(6))); - - // And can be reassigned, persisting again. `counter` is still 5 (cell 2 did - // not mutate it), so doubling yields 10. - let third = s - .eval_cell("counter = counter * 2; counter") - .expect("cell 3"); - assert_eq!(third.value, Some(ReplValue::Int(10))); - - // The reassignment persists into a fourth cell. - let fourth = s.eval_cell("counter").expect("cell 4"); - assert_eq!(fourth.value, Some(ReplValue::Int(10))); -} - -#[test] -fn variables_changed_diffs_against_shared_baseline() { - // The pre-cell baseline is snapshotted once into the shared `vars_snapshot` - // and the change diff reads it back from there (no second retained copy). - // A cell that introduces several new bindings and mutates an existing one - // must still report every changed name, and leave an unchanged binding out. - let mut s = session(); - s.eval_cell("let kept = 1; let touched = 2;").expect("seed"); - - let result = s - .eval_cell("let a = 10; let b = 20; touched = 99; kept") - .expect("cell"); - - assert!(result.variables_changed.contains(&"a".to_string())); - assert!(result.variables_changed.contains(&"b".to_string())); - assert!(result.variables_changed.contains(&"touched".to_string())); - assert!( - !result.variables_changed.contains(&"kept".to_string()), - "an unmodified binding is not reported as changed" - ); -} - -#[test] -fn over_limit_script_fails_closed() { - // A tiny operation budget makes an otherwise-bounded loop trip the limit. - let policy = ReplPolicy { - max_operations: 100, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let err = s - .eval_cell("let total = 0; for i in 0..1000000 { total += i; } total") - .expect_err("should exceed the operation limit"); - - match err { - TinyAgentsError::LimitExceeded(msg) => { - assert!(msg.contains("operation limit"), "unexpected message: {msg}"); - } - other => panic!("expected LimitExceeded, got {other:?}"), - } -} - -#[test] -fn timeout_fails_closed_on_a_runaway_script() { - // Regression test: `ReplPolicy::timeout` used to be parsed but never - // enforced — a runaway or hanging cell could block the session forever. - // `max_operations` is left effectively unbounded here so only the - // wall-clock deadline (enforced via the engine's `on_progress` hook) can - // stop the loop. - let policy = ReplPolicy { - timeout: Some(Duration::from_millis(30)), - max_operations: 0, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let start = Instant::now(); - let err = s - .eval_cell("let total = 0; loop { total += 1; }") - .expect_err("should exceed the wall-clock deadline"); - assert!(matches!(err, TinyAgentsError::Timeout(_)), "got {err:?}"); - // The property under test: `eval_cell` returns promptly once the - // deadline elapses rather than running the loop forever. - assert!( - start.elapsed() < Duration::from_secs(5), - "eval_cell took {:?}, should have returned near the 30ms deadline", - start.elapsed() - ); -} - -#[test] -fn max_iterations_limit_fails_closed() { - // Regression test: `ReplPolicy::max_iterations` was parsed and defaulted - // but no code path ever checked it. - let policy = ReplPolicy { - max_iterations: 2, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - s.eval_cell("1").expect("cell 1 within the limit"); - s.eval_cell("2").expect("cell 2 within the limit"); - - let err = s.eval_cell("3").expect_err("cell 3 exceeds max_iterations"); - assert!( - matches!(err, TinyAgentsError::LimitExceeded(_)), - "got {err:?}" - ); -} - -#[test] -fn script_byte_limit_fails_closed() { - let policy = ReplPolicy { - max_script_bytes: 8, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let err = s - .eval_cell("let a = 1234567890;") - .expect_err("should exceed the script byte limit"); - assert!(matches!(err, TinyAgentsError::LimitExceeded(_))); -} - -#[test] -fn reserved_names_are_restored_after_each_cell() { - let mut s = session(); - s.set_context(ReplValue::String("original".to_string())); - - // A cell may read and temporarily overwrite a reserved name. - let result = s - .eval_cell(r#"context = "tampered"; context"#) - .expect("cell"); - assert_eq!( - result.value, - Some(ReplValue::String("tampered".to_string())) - ); - - // But the next cell sees the restored baseline, not the tampered value. - let after = s.eval_cell("context").expect("read context"); - assert_eq!(after.value, Some(ReplValue::String("original".to_string()))); - - // Reserved names never show up as persistent changed variables. - assert!(!result.variables_changed.contains(&"context".to_string())); -} - -#[test] -fn reserved_names_contains_no_duplicates() { - // `answer` is a capability function (see RESERVED_FUNCTIONS), not a - // readable session variable; it must not also appear in - // RESERVED_VARIABLES, or `ReplVariables::seeded` double-pushes the same - // scope entry. - let names: Vec<&str> = reserved_names().collect(); - let mut seen = std::collections::HashSet::new(); - for name in &names { - assert!(seen.insert(*name), "duplicate reserved name: {name}"); - } - assert!(names.contains(&"answer")); -} - -#[test] -fn answer_variable_is_seeded_exactly_once_in_scope() { - let s = session(); - let count = s - .variables - .scope - .iter() - .filter(|(name, _, _)| *name == "answer") - .count(); - assert_eq!(count, 1, "`answer` must be seeded into scope exactly once"); -} - -#[test] -fn reserved_capability_name_cannot_be_set_as_a_variable() { - let mut s = session(); - let err = s - .variables - .set("model_query", ReplValue::Int(1)) - .expect_err("reserved name"); - assert!(matches!(err, TinyAgentsError::Capability(_))); -} - -#[test] -fn print_is_captured_as_stdout() { - let mut s = session(); - let result = s - .eval_cell(r#"print("hello"); print("world");"#) - .expect("cell"); - assert_eq!(result.stdout, "hello\nworld\n"); -} - -#[test] -fn emit_records_a_call() { - let mut s = session(); - let result = s - .eval_cell(r#"emit("found", #{ count: 3 }); 1"#) - .expect("cell"); - assert_eq!(result.calls.len(), 1); - let call = &result.calls[0]; - assert_eq!(call.kind, ReplCallKind::Emit); - assert_eq!(call.name, "found"); - assert_eq!(call.detail, serde_json::json!({ "count": 3 })); -} - -#[test] -fn answer_records_the_final_answer() { - let mut s = session(); - let result = s - .eval_cell(r#"answer("escalate to a human"); ()"#) - .expect("cell"); - assert_eq!(result.final_answer, Some("escalate to a human".to_string())); -} - -#[test] -fn output_byte_limit_fails_closed() { - let policy = ReplPolicy { - max_output_bytes: 4, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let err = s - .eval_cell(r#"print("this is definitely longer than four bytes");"#) - .expect_err("should exceed the output byte limit"); - assert!(matches!(err, TinyAgentsError::LimitExceeded(_))); -} - -#[test] -fn output_byte_limit_bounds_intra_cell_buffering_in_a_print_loop() { - // A script that prints in a tight loop must not be allowed to buffer - // unbounded output before the limit is noticed: push_stdout_line itself - // must stop growing the buffer (and eval_cell must fail closed) well - // before the loop's total output would otherwise reach many times the - // configured budget. - let policy = ReplPolicy { - max_output_bytes: 100, - max_operations: 1_000_000, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let err = s - .eval_cell( - r#"for i in 0..100000 { print("0123456789012345678901234567890123456789012345"); }"#, - ) - .expect_err("should exceed the output byte limit"); - assert!(matches!(err, TinyAgentsError::LimitExceeded(_)), "{err:?}"); -} - -#[test] -fn graph_define_does_not_consume_the_limit_on_a_failed_draft() { - // A `graph_define` call whose source parses but names a graph that isn't - // in the source must not consume a definition slot: only a successfully - // recorded draft should count against `max_graph_definitions`. - let policy = ReplPolicy { - max_graph_definitions: 1, - ..ReplPolicy::default() - }; - let mut s = ReplSession::<()>::new().with_policy(policy); - - let source = r#"graph g { start a node a { kind model next END } }"#; - - // First call: wrong graph name, so the draft is never recorded — this - // must fail without spending the one available slot. - let bad = s.eval_cell(&format!( - r#"graph_define(#{{ name: "missing", source: `{source}` }})"# - )); - assert!( - bad.is_err(), - "expected a failure for the unknown graph name" - ); - - // Second call: the correct graph name must still succeed, proving the - // failed attempt above did not consume the definition budget. - let good = s - .eval_cell(&format!( - r#"graph_define(#{{ name: "g", source: `{source}` }})"# - )) - .expect("a valid graph_define should still have a slot available"); - assert!(good.value.is_some()); - - // A third attempt now must fail: the one slot has genuinely been spent. - let over_limit = s.eval_cell(&format!( - r#"graph_define(#{{ name: "g", source: `{source}` }})"# - )); - assert!( - over_limit.is_err(), - "the definition limit must be enforced once a slot is actually consumed" - ); -} - -/// A tool that succeeds for every call except one whose `arguments.id` -/// matches `fail_id`, for which it returns a *tool-reported* error (a -/// `ToolResult` with `error: Some(..)`), not a `Result::Err` — exercising the -/// per-item error path distinct from a harness/transport-level failure. -struct SometimesFailingTool { - fail_id: String, -} - -#[async_trait::async_trait] -impl crate::harness::tool::Tool<()> for SometimesFailingTool { - fn name(&self) -> &str { - "sometimes_fails" - } - - fn description(&self) -> &str { - "Succeeds unless called with the configured failing id." - } - - fn schema(&self) -> crate::harness::tool::ToolSchema { - crate::harness::tool::ToolSchema { - name: self.name().to_string(), - description: self.description().to_string(), - parameters: serde_json::json!({ "type": "object" }), - format: Default::default(), - } - } - - async fn call( - &self, - _state: &(), - call: crate::harness::tool::ToolCall, - ) -> crate::Result { - let id = call - .arguments - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or_default(); - if id == self.fail_id { - Ok(crate::harness::tool::ToolResult::error( - call.id, - call.name, - format!("tool reported an error for id {id}"), - )) - } else { - Ok(crate::harness::tool::ToolResult::text( - call.id, - call.name, - format!("ok:{id}"), - )) - } - } -} - -fn session_with_sometimes_failing_tool(fail_id: &str) -> ReplSession { - let mut registry = crate::registry::CapabilityRegistry::<()>::new(); - registry - .register_tool(std::sync::Arc::new(SometimesFailingTool { - fail_id: fail_id.to_string(), - })) - .expect("register tool"); - let capabilities = ReplCapabilities::new(std::sync::Arc::new(registry)); - ReplSession::<()>::new().with_capabilities(capabilities) -} - -#[test] -fn tool_call_batched_keeps_successes_when_one_item_tool_errors() { - // Regression test: a per-item *tool-reported* error (ToolResult::error, - // as opposed to a harness/transport-level Err) used to abort the whole - // batch, discarding every other item's already-computed successful - // result. Each item's outcome must be reported independently. - let mut s = session_with_sometimes_failing_tool("2"); - - let script = r#" - tool_call_batched([ - #{ tool: "sometimes_fails", arguments: #{ id: "1" } }, - #{ tool: "sometimes_fails", arguments: #{ id: "2" } }, - #{ tool: "sometimes_fails", arguments: #{ id: "3" } }, - ]) - "#; - let result = s.eval_cell(script).expect("batch call should not abort"); - let value = result.value.expect("value").to_json(); - let items = value.as_array().expect("array result"); - assert_eq!(items.len(), 3, "{items:?}"); - - assert_eq!(items[0]["ok"], serde_json::json!(true)); - assert_eq!(items[0]["content"], serde_json::json!("ok:1")); - - assert_eq!(items[1]["ok"], serde_json::json!(false)); - assert!( - items[1]["error"] - .as_str() - .unwrap() - .contains("tool reported an error"), - "{items:?}" - ); - - assert_eq!(items[2]["ok"], serde_json::json!(true)); - assert_eq!(items[2]["content"], serde_json::json!("ok:3")); -} - -#[test] -fn a_recoverable_capability_error_is_catchable_by_try_catch() { - // Regression test: `raise()` used to stash *every* capability error - // (recoverable or not) into `host_error`, which `eval_cell`'s success - // path and `on_progress` both treat as fatal — so a script that caught - // the error and recovered still failed the whole cell. An unknown tool - // name is a recoverable failure (`TinyAgentsError::ToolNotFound`), not a - // policy bound, so `try`/`catch` around it must actually work. - let mut s = session(); - - let result = s - .eval_cell(r#"let ok = 0; try { tool_call(#{ tool: "nope" }); } catch(e) { ok = 1; } ok"#) - .expect("a caught recoverable capability error must not fail the cell"); - - assert_eq!(result.value, Some(ReplValue::Int(1))); -} - -#[test] -fn an_uncaught_recoverable_capability_error_still_reports_its_typed_form() { - // The typed-error contract for an *uncaught* recoverable failure must - // survive the fix above: `eval_cell` should still report - // `TinyAgentsError::ToolNotFound`, not a generic stringly-wrapped - // `Validation` error. - let mut s = session(); - - let err = s - .eval_cell(r#"tool_call(#{ tool: "nope" })"#) - .expect_err("an unregistered tool must fail the cell when uncaught"); - - assert!( - matches!(err, TinyAgentsError::ToolNotFound(ref t) if t == "nope"), - "expected ToolNotFound(nope), got {err:?}" - ); -} - -/// A trivial [`HarnessAgent`] that returns a fixed response, for exercising -/// `agent_query` without a real model/harness run. -struct StubAgent; - -#[async_trait::async_trait] -impl crate::graph::subagent_node::HarnessAgent for StubAgent { - fn name(&self) -> &str { - "stub" - } - - async fn run( - &self, - input: crate::graph::subagent_node::SubAgentInput, - _events: crate::harness::events::EventSink, - ) -> crate::Result { - Ok(crate::graph::subagent_node::SubAgentOutput { - text: format!("stub replied to: {}", input.prompt), - ..Default::default() - }) - } -} - -fn session_with_stub_agent(policy: ReplPolicy) -> ReplSession { - let mut registry = crate::registry::CapabilityRegistry::<()>::new(); - registry - .register_agent(std::sync::Arc::new(StubAgent)) - .expect("register stub agent"); - let capabilities = ReplCapabilities::new(std::sync::Arc::new(registry)); - ReplSession::<()>::new() - .with_policy(policy) - .with_capabilities(capabilities) -} - -#[test] -fn agent_call_limit_is_independent_of_the_model_call_limit() { - // Regression test: `bump_agent` used to compare the agent-call counter - // against `max_model_calls` (with an "agent call limit" message quoting - // that same number), so a session's *combined* model spend — direct - // `model_query` calls plus every model call a delegated `agent_query` - // itself drives — could reach roughly twice the configured - // `max_model_calls` before anything failed closed. `max_agent_calls` is - // now tracked and enforced independently. - let policy = ReplPolicy { - max_model_calls: 64, - max_agent_calls: 2, - ..ReplPolicy::default() - }; - let mut s = session_with_stub_agent(policy); - - let script = r#"agent_query(#{ agent: "stub", prompt: "hi" })"#; - s.eval_cell(script).expect("call 1 within the limit"); - s.eval_cell(script).expect("call 2 within the limit"); - - let err = s - .eval_cell(script) - .expect_err("call 3 exceeds max_agent_calls"); - match err { - TinyAgentsError::LimitExceeded(msg) => { - assert!( - msg.contains("agent call limit (2)"), - "expected the message to cite max_agent_calls (2), got: {msg}" - ); - } - other => panic!("expected LimitExceeded, got {other:?}"), - } -} - -// ── External cancellation (Phase 2) ────────────────────────────────────────── - -/// A tool whose call never resolves, modelling a hung provider/tool so a test -/// can prove external cancellation drops the in-flight future via the blocking -/// bridge rather than blocking the session forever. -struct HangingTool; - -#[async_trait::async_trait] -impl crate::harness::tool::Tool<()> for HangingTool { - fn name(&self) -> &str { - "hangs" - } - - fn description(&self) -> &str { - "Never returns; used to test the cancellation bridge." - } - - fn schema(&self) -> crate::harness::tool::ToolSchema { - crate::harness::tool::ToolSchema { - name: self.name().to_string(), - description: self.description().to_string(), - parameters: serde_json::json!({ "type": "object" }), - format: Default::default(), - } - } - - async fn call( - &self, - _state: &(), - _call: crate::harness::tool::ToolCall, - ) -> crate::Result { - futures::future::pending().await - } -} - -fn session_with_hanging_tool(policy: ReplPolicy) -> ReplSession { - let mut registry = crate::registry::CapabilityRegistry::<()>::new(); - registry - .register_tool(std::sync::Arc::new(HangingTool)) - .expect("register hanging tool"); - let capabilities = ReplCapabilities::new(std::sync::Arc::new(registry)); - ReplSession::<()>::new() - .with_policy(policy) - .with_capabilities(capabilities) -} - -#[test] -fn cancel_set_before_eval_fails_closed_without_running_the_cell() { - // A flag tripped before the cell starts short-circuits to `Cancelled`, and - // must not consume the session's iteration budget: a later cell with a - // fresh flag still runs. - let flag = ReplCancelFlag::new(); - let mut s = session().with_cancel_flag(flag.clone()); - flag.cancel(); - - let err = s.eval_cell("let x = 1; x").expect_err("pre-cancelled"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - - // A fresh flag re-enables the session — the cancelled cell did not burn an - // iteration or otherwise poison the namespace. - let mut s = s.with_cancel_flag(ReplCancelFlag::new()); - let ok = s.eval_cell("1 + 1").expect("session usable after cancel"); - assert_eq!(ok.value, Some(ReplValue::Int(2))); -} - -#[test] -fn set_cancel_flag_swaps_in_place_and_preserves_the_namespace() { - // A long-lived session behind a lock swaps its flag with `set_cancel_flag` - // (not the consuming builder). A fresh flag re-enables a session whose - // prior cell was cancelled, and the persistent namespace survives the swap. - let mut s = session(); - s.eval_cell("let kept = 42;").expect("seed binding"); - - let tripped = ReplCancelFlag::new(); - tripped.cancel(); - s.set_cancel_flag(tripped); - let err = s - .eval_cell("kept") - .expect_err("cancelled flag blocks the cell"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - - // A fresh flag re-enables the session, and `kept` is still in scope. - s.set_cancel_flag(ReplCancelFlag::new()); - let ok = s.eval_cell("kept").expect("session usable again"); - assert_eq!(ok.value, Some(ReplValue::Int(42))); -} - -#[test] -fn cancel_mid_script_loop_terminates_promptly() { - // A pure `loop {}` with no timeout and an unbounded operation budget can - // only be stopped by the cancel flag, enforced via the `on_progress` hook. - let policy = ReplPolicy { - timeout: None, - max_operations: 0, - ..ReplPolicy::default() - }; - let flag = ReplCancelFlag::new(); - let mut s = session().with_policy(policy).with_cancel_flag(flag.clone()); - - let trigger = flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(40)); - trigger.cancel(); - }); - - let start = Instant::now(); - let err = s - .eval_cell("let total = 0; loop { total += 1; }") - .expect_err("cancel must terminate the loop"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - assert!( - start.elapsed() < Duration::from_secs(5), - "eval_cell took {:?}, should return near the ~40ms cancel", - start.elapsed() - ); -} - -#[test] -fn cancel_during_a_hanging_capability_call_terminates_promptly() { - // A tool call that never resolves can only be released by the cancel flag, - // enforced via the blocking bridge (`on_progress` never fires inside a - // blocked native call). No timeout is configured so only cancel can stop it. - let policy = ReplPolicy { - timeout: None, - ..ReplPolicy::default() - }; - let flag = ReplCancelFlag::new(); - let mut s = session_with_hanging_tool(policy).with_cancel_flag(flag.clone()); - - let trigger = flag.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(40)); - trigger.cancel(); - }); - - let start = Instant::now(); - let err = s - .eval_cell(r#"tool_call(#{ tool: "hangs" })"#) - .expect_err("cancel must drop the hung tool future"); - assert!(matches!(err, TinyAgentsError::Cancelled), "got {err:?}"); - assert!( - start.elapsed() < Duration::from_secs(5), - "eval_cell took {:?}, should return near the ~40ms cancel", - start.elapsed() - ); -} - -// ── Live capability-call events (Phase 2) ──────────────────────────────────── - -#[test] -fn capability_calls_stream_started_and_completed_events() { - use crate::harness::events::{AgentEvent, ReplCallPhase}; - - // `fail_id` "none" never matches, so the "1" call succeeds. - let mut s = session_with_sometimes_failing_tool("none"); - let recorder = std::sync::Arc::new(crate::harness::events::RecordingListener::new()); - s.events.subscribe(recorder.clone()); - - let session_label = s.session_id.as_str().to_string(); - s.eval_cell(r#"tool_call(#{ tool: "sometimes_fails", arguments: #{ id: "1" } })"#) - .expect("tool call"); - - // Exactly one Started and one Completed ReplCall event, paired by call_id, - // both naming the tool and correlated back to this session. - let repl_calls: Vec<(ReplCallPhase, String, String)> = recorder - .events() - .into_iter() - .filter_map(|rec| match rec.event { - AgentEvent::ReplCall { - session_id, - record, - phase, - } => Some((phase, session_id, record.call_id.as_str().to_string())), - _ => None, - }) - .collect(); - - assert_eq!( - repl_calls.len(), - 2, - "expected start + completion: {repl_calls:?}" - ); - assert_eq!(repl_calls[0].0, ReplCallPhase::Started); - assert_eq!(repl_calls[1].0, ReplCallPhase::Completed); - assert_eq!(repl_calls[0].1, session_label, "session_id correlates"); - assert_eq!(repl_calls[1].1, session_label); - assert_eq!( - repl_calls[0].2, repl_calls[1].2, - "start and completion share one call_id" - ); -} - -#[test] -fn map_and_array_values_round_trip_to_json() { - let mut s = session(); - let result = s.eval_cell(r#"#{ a: 1, b: [true, "x"] }"#).expect("cell"); - let value = result.value.expect("value"); - assert_eq!( - value.to_json(), - serde_json::json!({ "a": 1, "b": [true, "x"] }) - ); -} - -#[test] -fn syntax_error_maps_to_validation() { - let mut s = session(); - let err = s.eval_cell("let = ;").expect_err("syntax error"); - assert!(matches!(err, TinyAgentsError::Validation(_))); -} - -#[test] -fn variables_helper_reads_persistent_value() { - let mut s = session(); - s.eval_cell("let note = \"hi\";").expect("cell"); - assert_eq!( - s.variables.get("note"), - Some(ReplValue::String("hi".to_string())) - ); -} - -#[test] -fn default_policy_has_review_gate_enabled() { - let policy = ReplPolicy::default(); - assert!(policy.generated_graphs_require_review); - assert_eq!(policy.max_depth, 8); -} - -#[test] -fn capabilities_expose_registered_names() { - let caps = ReplCapabilities::<()>::default(); - assert!(caps.models().is_empty()); - assert!(caps.tools().is_empty()); - assert!(caps.language.is_none()); -} diff --git a/src/repl/session/types.rs b/src/repl/session/types.rs deleted file mode 100644 index 1c984eca..00000000 --- a/src/repl/session/types.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Public data types for the Rhai-backed `.ragsh` session runtime. -//! -//! These are the typed values that cross the host/script boundary: the -//! [`ReplPolicy`] limits that bound a session, the [`ReplCapabilities`] that -//! wire a session to the named registries, and the [`ReplResult`] / -//! [`ReplValue`] / [`ReplCallRecord`] values a single evaluated cell produces. -//! -//! Logic (engine construction, cell evaluation, reserved-name restoration) -//! lives in [`super`]; tests live in `test.rs`. - -use std::collections::BTreeMap; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::language::types::{Blueprint, Origin}; -use crate::registry::CapabilityRegistry; - -// ── External cancellation ──────────────────────────────────────────────────── - -/// A shared, cheaply-cloneable cancellation flag for a [`super::ReplSession`]. -/// -/// A cell can otherwise only be stopped by its wall-clock -/// [`ReplPolicy::timeout`]. A host that drives sessions from an async runtime -/// (for example when a user cancels the surrounding run) needs to abort an -/// **in-flight** cell on demand, without waiting out the timeout. It holds a -/// clone of this flag and calls [`ReplCancelFlag::cancel`]; the session observes -/// it fail-closed at the same two enforcement points as the deadline: -/// -/// - the engine `on_progress` hook, which terminates a running *script* at the -/// next statement/operation (a pure `while true {}` loop with no host calls); -/// - the blocking bridge around every `model_query`/`tool_call`/`agent_query` -/// call, which drops an in-flight (possibly hung) capability future promptly. -/// -/// In both cases [`super::ReplSession::eval_cell`] returns -/// [`TinyAgentsError::Cancelled`](crate::error::TinyAgentsError::Cancelled). The -/// flag is *sticky*: once cancelled it stays cancelled, so a session observed as -/// cancelled will refuse to start further cells until a fresh flag is installed. -/// Construct one with [`ReplCancelFlag::new`] and install it with -/// [`super::ReplSession::with_cancel_flag`]. -#[derive(Clone, Debug, Default)] -pub struct ReplCancelFlag(Arc); - -impl ReplCancelFlag { - /// Creates a fresh, un-cancelled flag. - pub fn new() -> Self { - Self(Arc::new(AtomicBool::new(false))) - } - - /// Requests cancellation. Idempotent and safe to call from any thread; every - /// clone of this flag observes the change. - pub fn cancel(&self) { - self.0.store(true, Ordering::SeqCst); - } - - /// Returns whether cancellation has been requested. - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::SeqCst) - } -} - -// ── Reserved names ────────────────────────────────────────────────────────── - -/// Reserved built-in *variable* names seeded into every session scope. -/// -/// These are restored to their session baseline after each cell so a script -/// can read or temporarily shadow them but cannot permanently replace the -/// session's context, state, or run slots. `answer` is *not* included here: -/// it is a capability function only (see [`RESERVED_FUNCTIONS`]), never a -/// readable session variable, so listing it in both would seed the scope -/// with a duplicate entry for the same name. -pub const RESERVED_VARIABLES: &[&str] = &["context", "state", "messages", "history", "run"]; - -/// Reserved built-in *capability function* names. -/// -/// Rhai resolves a call expression against the registered-function namespace, -/// which is independent of the variable namespace, so these names cannot be -/// replaced by a script-level `let`. They are listed here so the runtime can -/// also scrub any same-named variable a script introduces, matching the design -/// document's "scripts may add locals but not permanently replace -/// capabilities" rule. -pub const RESERVED_FUNCTIONS: &[&str] = &[ - "model_query", - "model_query_batched", - "agent_query", - "agent_query_batched", - "graph_run", - "graph_run_batched", - "graph_define", - "graph_validate", - "graph_compile", - "graph_diff", - "graph_register", - "tool_call", - "tool_call_batched", - "emit", - "show_vars", - "answer", -]; - -/// Returns every reserved name (variables and capability functions) the -/// runtime must protect across cells, each name yielded at most once even if -/// it were (accidentally) listed in both [`RESERVED_VARIABLES`] and -/// [`RESERVED_FUNCTIONS`] — callers seed one scope entry per yielded name, so -/// a duplicate here would silently double-push the same variable. -pub fn reserved_names() -> impl Iterator { - let mut seen = std::collections::HashSet::new(); - RESERVED_VARIABLES - .iter() - .copied() - .chain(RESERVED_FUNCTIONS.iter().copied()) - .filter(move |name| seen.insert(*name)) -} - -// ── Policy ────────────────────────────────────────────────────────────────── - -/// Resource limits that bound a [`super::ReplSession`]. -/// -/// Every limit is enforced "fail closed": when a script would exceed a bound, -/// cell evaluation returns an error rather than truncating silently or running -/// unbounded work. The defaults are conservative and tuned for an in-process, -/// model-driven orchestration loop. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReplPolicy { - /// Maximum Rhai operations per cell, wired to - /// [`rhai::Engine::set_max_operations`]. `0` means unlimited. - pub max_operations: u64, - /// Maximum CodeAct loop iterations a model-driven session may run. - pub max_iterations: usize, - /// Maximum source size, in bytes, of a single cell. - pub max_script_bytes: usize, - /// Maximum captured stdout/value size, in bytes, per cell. - pub max_output_bytes: usize, - /// Maximum `model_query` calls per session. - pub max_model_calls: usize, - /// Maximum `agent_run`/sub-agent calls per session. - /// - /// Enforced independently of [`max_model_calls`][Self::max_model_calls]: - /// each sub-agent call itself drives one or more model calls, so - /// capping agent calls at the model-call budget (as an earlier - /// implementation did) let a session's *combined* model spend reach - /// roughly twice the configured `max_model_calls`. - pub max_agent_calls: usize, - /// Maximum `tool_call` calls per session. - pub max_tool_calls: usize, - /// Maximum `graph_run` calls per session. - pub max_graph_calls: usize, - /// Maximum `graph_define` blueprints per session. - pub max_graph_definitions: usize, - /// Maximum recursion depth for sub-model/sub-agent/sub-graph calls. - pub max_depth: usize, - /// Optional wall-clock timeout per cell. - pub timeout: Option, - /// Maximum concurrency for batched capability calls. - pub max_concurrency: usize, - /// When `true`, model-generated graphs require a review token before they - /// can be registered. - pub generated_graphs_require_review: bool, -} - -impl Default for ReplPolicy { - fn default() -> Self { - Self { - max_operations: 1_000_000, - max_iterations: 16, - max_script_bytes: 64 * 1024, - max_output_bytes: 256 * 1024, - max_model_calls: 64, - max_agent_calls: 32, - max_tool_calls: 128, - max_graph_calls: 32, - max_graph_definitions: 8, - max_depth: 8, - timeout: Some(Duration::from_secs(30)), - max_concurrency: 4, - generated_graphs_require_review: true, - } - } -} - -// ── Values ────────────────────────────────────────────────────────────────── - -/// A typed, serializable projection of a Rhai value returned from a cell. -/// -/// The Rhai engine is dynamically typed; `ReplValue` is the explicit conversion -/// at the capability boundary the design document requires. Unsupported or -/// opaque Rhai values are stringified rather than leaking host types. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "type", content = "value")] -pub enum ReplValue { - /// Rhai unit `()` — used when a cell produces no meaningful value. - Unit, - /// A boolean. - Bool(bool), - /// A 64-bit signed integer. - Int(i64), - /// A 64-bit float. - Float(f64), - /// A string. - String(String), - /// An ordered array of values. - Array(Vec), - /// A string-keyed map of values. - Map(BTreeMap), -} - -impl ReplValue { - /// Converts this value into a [`serde_json::Value`] for event/store writes. - pub fn to_json(&self) -> serde_json::Value { - match self { - ReplValue::Unit => serde_json::Value::Null, - ReplValue::Bool(b) => serde_json::Value::Bool(*b), - ReplValue::Int(i) => serde_json::Value::from(*i), - ReplValue::Float(f) => serde_json::Number::from_f64(*f) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - ReplValue::String(s) => serde_json::Value::String(s.clone()), - ReplValue::Array(items) => { - serde_json::Value::Array(items.iter().map(ReplValue::to_json).collect()) - } - ReplValue::Map(map) => serde_json::Value::Object( - map.iter().map(|(k, v)| (k.clone(), v.to_json())).collect(), - ), - } - } - - /// Returns the approximate serialized size of this value, in bytes, used - /// to enforce [`ReplPolicy::max_output_bytes`]. - pub fn byte_len(&self) -> usize { - serde_json::to_string(&self.to_json()) - .map(|s| s.len()) - .unwrap_or(0) - } -} - -// ── Call records ──────────────────────────────────────────────────────────── - -/// The kind of capability a [`ReplCallRecord`] describes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReplCallKind { - /// A `model_query` call. - Model, - /// A `tool_call` call. - Tool, - /// A `graph_run` call. - Graph, - /// An `agent_query` call. - Agent, - /// A custom `emit` event. - Emit, -} - -/// A record of one capability call (or emitted event) a cell performed. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ReplCallRecord { - /// Unique id for this call within the session. - pub call_id: crate::harness::ids::CallId, - /// Which capability kind was invoked. - pub kind: ReplCallKind, - /// The capability or event name. - pub name: String, - /// Structured detail about the call (arguments or payload). - pub detail: serde_json::Value, - /// Wall-clock time the call took. - pub elapsed: Duration, -} - -// ── Cell result ───────────────────────────────────────────────────────────── - -/// The structured result of evaluating one `.ragsh` cell. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct ReplResult { - /// Captured `print`/`debug` output, truncated-free up to the policy bound. - pub stdout: String, - /// The cell's final expression value, if it produced one. - pub value: Option, - /// Names of persistent variables created or changed by this cell. - pub variables_changed: Vec, - /// Capability calls and emitted events recorded during the cell. - pub calls: Vec, - /// The final answer, if the cell called `answer(...)`. - pub final_answer: Option, - /// Wall-clock time the cell took to evaluate. - pub elapsed: Duration, -} - -// ── Graph blueprint handle ────────────────────────────────────────────────── - -/// An opaque, script-carryable handle to a `.rag` graph blueprint drafted -/// inside a session. -/// -/// `graph_define` lowers `.rag` source through the Cluster H compiler and -/// returns one of these as a Rhai value; `graph_validate`, `graph_compile`, -/// `graph_diff`, and `graph_register` accept it back. The handle carries the -/// compiled [`Blueprint`] together with the original source and its -/// [`Origin`] provenance (always [`Origin::Generated`] for REPL-authored -/// graphs) so a review tool can trace topology back to the producing session. -/// -/// Generated topology is **never** installed directly: a handle is only marked -/// `compiled` after passing the capability resolver, and registration through -/// `graph_register` still honors [`ReplPolicy::generated_graphs_require_review`]. -#[derive(Debug, Clone)] -pub struct GraphBlueprintHandle { - /// The graph name (its `graph_id`). - pub name: String, - /// The original `.rag` source the blueprint was drafted from. - pub source: String, - /// The compiled blueprint. - pub blueprint: Blueprint, - /// Source provenance — generated, labelled with the session id. - pub origin: Origin, - /// `true` once the handle has passed `graph_compile` (resolver-bound). - pub compiled: bool, - /// Whether registering this generated graph requires a review token, copied - /// from the session policy at compile time. - pub requires_review: bool, -} - -// ── Language compiler handle ──────────────────────────────────────────────── - -/// A thin handle marking that a session may draft and compile `.rag` graph -/// blueprints through the expressive-language compiler. -/// -/// Generated graph topology is never installed directly: the actual -/// `graph_define`/`graph_compile`/`graph_register` wiring routes through the -/// `.rag` compiler, the capability resolver, and the policy review gate. This -/// handle records the provenance label applied to generated blueprints and is -/// fleshed out by the graph-capability slice; here it establishes the typed -/// slot the design document's `ReplCapabilities::language` field describes. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LanguageCompiler { - /// Provenance label stamped on blueprints generated in this session. - pub provenance_label: String, -} - -impl Default for LanguageCompiler { - fn default() -> Self { - Self { - provenance_label: "ragsh-generated".to_string(), - } - } -} - -// ── Capabilities ──────────────────────────────────────────────────────────── - -/// The named capabilities a session may bind against. -/// -/// The design document sketches separate `ModelRegistry`, `ToolRegistry`, -/// `GraphRegistry`, and `AgentRegistry` fields. In this crate those four kinds -/// are already unified under the single name-addressable -/// [`CapabilityRegistry`], so `ReplCapabilities` wraps that registry (shared via -/// `Arc` so a session can be cheaply cloned into a graph node) plus an optional -/// [`LanguageCompiler`]. The per-kind accessors ([`models`](Self::models), -/// [`tools`](Self::tools), [`graphs`](Self::graphs), [`agents`](Self::agents)) -/// preserve the documented surface. -/// -/// A prior revision also carried a [`crate::harness::store::StoreRegistry`] -/// field, but no built-in -/// (`model_query`, `tool_call`, …) ever read or wrote through it — it was dead -/// weight advertising a capability the engine did not actually expose. It was -/// removed rather than left half-wired; long-term store access can be added -/// back as real `store_get`/`store_set` built-ins (see [`super::builtins`]) -/// once that surface is designed. -pub struct ReplCapabilities -where - State: Send + Sync, -{ - /// The unified capability catalog (models, tools, graphs, agents). - pub registry: Arc>, - /// Optional expressive-language compiler handle for graph drafting. - pub language: Option, -} - -impl ReplCapabilities { - /// Builds capabilities over an existing capability registry. - pub fn new(registry: Arc>) -> Self { - Self { - registry, - language: None, - } - } - - /// Enables the expressive-language compiler handle for this session. - pub fn with_language(mut self, language: LanguageCompiler) -> Self { - self.language = Some(language); - self - } - - /// Returns the registered model names. - pub fn models(&self) -> Vec { - self.registry.names(crate::registry::ComponentKind::Model) - } - - /// Returns the registered tool names. - pub fn tools(&self) -> Vec { - self.registry.names(crate::registry::ComponentKind::Tool) - } - - /// Returns the registered graph-blueprint names. - pub fn graphs(&self) -> Vec { - self.registry.names(crate::registry::ComponentKind::Graph) - } - - /// Returns the registered agent names. - pub fn agents(&self) -> Vec { - self.registry.names(crate::registry::ComponentKind::Agent) - } -} - -impl Default for ReplCapabilities { - fn default() -> Self { - Self::new(Arc::new(CapabilityRegistry::new())) - } -} diff --git a/src/repl/test.rs b/src/repl/test.rs deleted file mode 100644 index 07c67d73..00000000 --- a/src/repl/test.rs +++ /dev/null @@ -1,708 +0,0 @@ -//! Tests for the `.ragsh` REPL skeleton: command-line parsing (verbs, quoting, -//! JSON `call` arguments, and error cases), session variable get/set/show, -//! capability-policy enforcement, and the structured [`ReplOutcome`] returned -//! for side-effect-free versus policy-gated commands. - -use super::{CapabilityPolicy, ReplCommand, ReplOutcome, ReplSession, parse_command}; -use crate::error::TinyAgentsError; - -// ── Parser tests ────────────────────────────────────────────────────────────── - -#[test] -fn parses_help() { - assert_eq!(parse_command("help").unwrap(), ReplCommand::Help); - assert_eq!(parse_command("HELP").unwrap(), ReplCommand::Help); - assert_eq!(parse_command("?").unwrap(), ReplCommand::Help); -} - -#[test] -fn parses_quit() { - assert_eq!(parse_command("quit").unwrap(), ReplCommand::Quit); - assert_eq!(parse_command("exit").unwrap(), ReplCommand::Quit); - assert_eq!(parse_command("q").unwrap(), ReplCommand::Quit); - assert_eq!(parse_command("QUIT").unwrap(), ReplCommand::Quit); -} - -#[test] -fn parses_load() { - let cmd = parse_command("load ./blueprints/support.rag").unwrap(); - assert_eq!( - cmd, - ReplCommand::Load { - path: "./blueprints/support.rag".to_string() - } - ); -} - -#[test] -fn parses_load_quoted_path() { - let cmd = parse_command(r#"load "path with spaces/my blueprint.rag""#).unwrap(); - assert_eq!( - cmd, - ReplCommand::Load { - path: "path with spaces/my blueprint.rag".to_string() - } - ); -} - -#[test] -fn parses_compile() { - let cmd = parse_command("compile support_flow").unwrap(); - assert_eq!( - cmd, - ReplCommand::Compile { - name: "support_flow".to_string() - } - ); -} - -#[test] -fn parses_run() { - let cmd = parse_command(r#"run my_graph "{\"user\":1}""#).unwrap(); - assert_eq!( - cmd, - ReplCommand::Run { - graph: "my_graph".to_string(), - input: r#"{"user":1}"#.to_string() - } - ); -} - -#[test] -fn parses_run_bare_input() { - let cmd = parse_command("run approval_flow initial").unwrap(); - assert_eq!( - cmd, - ReplCommand::Run { - graph: "approval_flow".to_string(), - input: "initial".to_string() - } - ); -} - -#[test] -fn parses_set() { - let cmd = parse_command("set my_var hello").unwrap(); - assert_eq!( - cmd, - ReplCommand::Set { - key: "my_var".to_string(), - value: "hello".to_string() - } - ); -} - -#[test] -fn parses_set_quoted_value() { - let cmd = parse_command(r#"set greeting "hello world""#).unwrap(); - assert_eq!( - cmd, - ReplCommand::Set { - key: "greeting".to_string(), - value: "hello world".to_string() - } - ); -} - -#[test] -fn parses_get() { - let cmd = parse_command("get my_var").unwrap(); - assert_eq!( - cmd, - ReplCommand::Get { - key: "my_var".to_string() - } - ); -} - -#[test] -fn parses_show_vars() { - let cmd = parse_command("show vars").unwrap(); - assert_eq!( - cmd, - ReplCommand::Show { - what: "vars".to_string() - } - ); -} - -#[test] -fn parses_show_graphs() { - let cmd = parse_command("show graphs").unwrap(); - assert_eq!( - cmd, - ReplCommand::Show { - what: "graphs".to_string() - } - ); -} - -#[test] -fn parses_show_status() { - let cmd = parse_command("show status").unwrap(); - assert_eq!( - cmd, - ReplCommand::Show { - what: "status".to_string() - } - ); -} - -#[test] -fn parses_call_with_json_object() { - let cmd = parse_command(r#"call lookup_user {"user_id": "usr_123"}"#).unwrap(); - assert_eq!( - cmd, - ReplCommand::Call { - capability: "lookup_user".to_string(), - args: serde_json::json!({"user_id": "usr_123"}) - } - ); -} - -#[test] -fn parses_call_with_json_array() { - let cmd = parse_command(r#"call batch_tool [1, 2, 3]"#).unwrap(); - assert_eq!( - cmd, - ReplCommand::Call { - capability: "batch_tool".to_string(), - args: serde_json::json!([1, 2, 3]) - } - ); -} - -#[test] -fn parses_call_with_json_null() { - let cmd = parse_command("call noop null").unwrap(); - assert_eq!( - cmd, - ReplCommand::Call { - capability: "noop".to_string(), - args: serde_json::Value::Null, - } - ); -} - -#[test] -fn error_on_unknown_verb() { - let err = parse_command("frobnicate something").unwrap_err(); - match err { - TinyAgentsError::Parse { message, .. } => { - assert!( - message.contains("frobnicate"), - "expected verb in message: {message}" - ); - } - other => panic!("expected Parse error, got {other:?}"), - } -} - -#[test] -fn error_on_empty_input() { - assert!(parse_command("").is_err()); - assert!(parse_command(" ").is_err()); -} - -#[test] -fn error_on_missing_load_argument() { - let err = parse_command("load").unwrap_err(); - assert!(matches!(err, TinyAgentsError::Parse { .. })); -} - -#[test] -fn error_on_missing_run_input() { - let err = parse_command("run my_graph").unwrap_err(); - assert!(matches!(err, TinyAgentsError::Parse { .. })); -} - -#[test] -fn error_on_call_missing_json() { - let err = parse_command("call my_cap").unwrap_err(); - assert!(matches!(err, TinyAgentsError::Parse { .. })); -} - -#[test] -fn error_on_call_invalid_json() { - let err = parse_command("call my_cap not-valid-json").unwrap_err(); - match err { - TinyAgentsError::Parse { message, .. } => { - assert!(message.contains("JSON"), "expected JSON mention: {message}"); - } - other => panic!("expected Parse error, got {other:?}"), - } -} - -#[test] -fn error_on_unterminated_quoted_string() { - let err = parse_command(r#"load "unclosed"#).unwrap_err(); - assert!(matches!(err, TinyAgentsError::Parse { .. })); -} - -#[test] -fn parse_errors_report_real_positions_not_0_0() { - // `parse_command` always parses one line, so `line` is always 1; `column` - // must point at the offending token rather than always reporting (0, 0). - match parse_command("frobnicate something").unwrap_err() { - TinyAgentsError::Parse { line, column, .. } => { - assert_eq!(line, 1); - assert_eq!(column, 1, "unknown verb starts at column 1"); - } - other => panic!("expected Parse error, got {other:?}"), - } - - match parse_command("run my_graph").unwrap_err() { - TinyAgentsError::Parse { line, column, .. } => { - assert_eq!(line, 1); - // "run my_graph" is 12 chars; the missing second argument is - // reported at the end of input, not column 0. - assert_eq!(column, 13); - } - other => panic!("expected Parse error, got {other:?}"), - } - - match parse_command("call my_cap not-valid-json").unwrap_err() { - TinyAgentsError::Parse { line, column, .. } => { - assert_eq!(line, 1); - // "not-valid-json" starts right after "call my_cap ". - assert_eq!(column, "call my_cap ".chars().count() + 1); - } - other => panic!("expected Parse error, got {other:?}"), - } - - match parse_command(r#"load "unclosed"#).unwrap_err() { - TinyAgentsError::Parse { line, column, .. } => { - assert_eq!(line, 1); - // The unterminated string starts right after "load ". - assert_eq!(column, "load ".chars().count() + 1); - } - other => panic!("expected Parse error, got {other:?}"), - } -} - -#[test] -fn quoted_string_escape_sequences() { - let cmd = parse_command(r#"set msg "hello \"world\"\nnewline""#).unwrap(); - if let ReplCommand::Set { value, .. } = cmd { - assert!(value.contains('"')); - assert!(value.contains('\n')); - } else { - panic!("expected Set command"); - } -} - -// ── ReplCommand helpers ─────────────────────────────────────────────────────── - -#[test] -fn command_name_returns_verb() { - assert_eq!(ReplCommand::Help.name(), "help"); - assert_eq!(ReplCommand::Quit.name(), "quit"); - assert_eq!(ReplCommand::Load { path: "x".into() }.name(), "load"); - assert_eq!( - ReplCommand::Call { - capability: "x".into(), - args: serde_json::Value::Null - } - .name(), - "call" - ); -} - -#[test] -fn command_is_serde_roundtrip() { - let cmd = ReplCommand::Call { - capability: "my_tool".to_string(), - args: serde_json::json!({"k": 1}), - }; - let json = serde_json::to_string(&cmd).unwrap(); - let back: ReplCommand = serde_json::from_str(&json).unwrap(); - assert_eq!(cmd, back); -} - -// ── CapabilityPolicy tests ──────────────────────────────────────────────────── - -#[test] -fn policy_deny_all_by_default() { - let policy = CapabilityPolicy::new(); - assert!(!policy.is_allowed("anything")); - assert!(policy.is_empty()); - assert_eq!(policy.len(), 0); -} - -#[test] -fn policy_allow_and_check() { - let mut policy = CapabilityPolicy::new(); - policy.allow("lookup_user"); - assert!(policy.is_allowed("lookup_user")); - assert!(!policy.is_allowed("other_cap")); - assert_eq!(policy.len(), 1); -} - -#[test] -fn policy_from_list() { - let policy = CapabilityPolicy::from_list(["a", "b", "c"]); - assert!(policy.is_allowed("a")); - assert!(policy.is_allowed("b")); - assert!(policy.is_allowed("c")); - assert!(!policy.is_allowed("d")); - assert_eq!(policy.len(), 3); -} - -// ── ReplSession tests ───────────────────────────────────────────────────────── - -#[test] -fn session_set_and_get() { - let mut session = ReplSession::new(); - session.set("x", serde_json::json!(42)); - assert_eq!(session.get("x"), Some(&serde_json::json!(42))); - assert_eq!(session.get("missing"), None); -} - -#[test] -fn session_vars_returns_all() { - let mut session = ReplSession::new(); - session.set("a", serde_json::json!(1)); - session.set("b", serde_json::json!("hello")); - assert_eq!(session.vars().len(), 2); -} - -#[test] -fn session_execute_help() { - let mut session = ReplSession::new(); - let outcome = session.execute(ReplCommand::Help).unwrap(); - assert!(matches!(outcome, ReplOutcome::Message(_))); - if let ReplOutcome::Message(text) = outcome { - assert!(text.contains("help"), "help text should mention commands"); - assert!(text.contains("quit")); - assert!(text.contains("call")); - } -} - -#[test] -fn session_execute_quit() { - let mut session = ReplSession::new(); - let outcome = session.execute(ReplCommand::Quit).unwrap(); - assert_eq!(outcome, ReplOutcome::Quit); -} - -#[test] -fn session_execute_set_and_get() { - let mut session = ReplSession::new(); - - let set_outcome = session - .execute(ReplCommand::Set { - key: "env".to_string(), - value: "production".to_string(), - }) - .unwrap(); - assert!(matches!(set_outcome, ReplOutcome::Message(_))); - - let get_outcome = session - .execute(ReplCommand::Get { - key: "env".to_string(), - }) - .unwrap(); - assert_eq!( - get_outcome, - ReplOutcome::Value(serde_json::json!("production")) - ); -} - -#[test] -fn session_execute_get_missing_key_returns_null() { - let mut session = ReplSession::new(); - let outcome = session - .execute(ReplCommand::Get { - key: "nope".to_string(), - }) - .unwrap(); - assert_eq!(outcome, ReplOutcome::Value(serde_json::Value::Null)); -} - -#[test] -fn session_execute_show_vars() { - let mut session = ReplSession::new(); - session.set("color", serde_json::json!("blue")); - - let outcome = session - .execute(ReplCommand::Show { - what: "vars".to_string(), - }) - .unwrap(); - if let ReplOutcome::Value(v) = outcome { - assert_eq!(v["color"], serde_json::json!("blue")); - } else { - panic!("expected Value outcome"); - } -} - -#[test] -fn session_execute_show_status() { - let mut session = ReplSession::new(); - session.set("x", serde_json::json!(1)); - - let outcome = session - .execute(ReplCommand::Show { - what: "status".to_string(), - }) - .unwrap(); - if let ReplOutcome::Value(v) = outcome { - assert_eq!(v["variables"], serde_json::json!(1)); - } else { - panic!("expected Value outcome for show status"); - } -} - -#[test] -fn session_execute_show_graphs() { - let mut session = ReplSession::new(); - let outcome = session - .execute(ReplCommand::Show { - what: "graphs".to_string(), - }) - .unwrap(); - assert!(matches!(outcome, ReplOutcome::Message(_))); -} - -#[test] -fn session_execute_show_unknown_subject() { - let mut session = ReplSession::new(); - let outcome = session - .execute(ReplCommand::Show { - what: "widgets".to_string(), - }) - .unwrap(); - if let ReplOutcome::Message(msg) = outcome { - assert!(msg.contains("widgets")); - } else { - panic!("expected Message outcome"); - } -} - -// ── Capability policy enforcement ───────────────────────────────────────────── - -#[test] -fn call_disallowed_capability_returns_error() { - let mut session = ReplSession::new(); // deny-all policy - - let err = session - .execute(ReplCommand::Call { - capability: "secret_tool".to_string(), - args: serde_json::Value::Null, - }) - .unwrap_err(); - - match err { - TinyAgentsError::Capability(msg) => { - assert!( - msg.contains("secret_tool"), - "error should name the capability: {msg}" - ); - } - other => panic!("expected Capability error, got {other:?}"), - } -} - -#[test] -fn call_allowed_capability_returns_planned() { - let policy = CapabilityPolicy::from_list(["lookup_user"]); - let mut session = ReplSession::new().with_policy(policy); - - let outcome = session - .execute(ReplCommand::Call { - capability: "lookup_user".to_string(), - args: serde_json::json!({"user_id": "usr_42"}), - }) - .unwrap(); - - match outcome { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "capability_call"); - assert_eq!(detail["capability"], "lookup_user"); - assert_eq!(detail["args"]["user_id"], "usr_42"); - } - other => panic!("expected Planned outcome, got {other:?}"), - } -} - -#[test] -fn load_disallowed_returns_capability_error() { - let mut session = ReplSession::new(); - let err = session - .execute(ReplCommand::Load { - path: "x.rag".to_string(), - }) - .unwrap_err(); - assert!(matches!(err, TinyAgentsError::Capability(_))); -} - -#[test] -fn load_allowed_returns_planned() { - let policy = CapabilityPolicy::from_list(["load"]); - let mut session = ReplSession::new().with_policy(policy); - let outcome = session - .execute(ReplCommand::Load { - path: "x.rag".to_string(), - }) - .unwrap(); - match outcome { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "load"); - assert_eq!(detail["path"], "x.rag"); - } - other => panic!("expected Planned, got {other:?}"), - } -} - -#[test] -fn run_disallowed_returns_capability_error() { - let mut session = ReplSession::new(); - let err = session - .execute(ReplCommand::Run { - graph: "g".to_string(), - input: "{}".to_string(), - }) - .unwrap_err(); - assert!(matches!(err, TinyAgentsError::Capability(_))); -} - -#[test] -fn run_allowed_returns_planned() { - let policy = CapabilityPolicy::from_list(["run"]); - let mut session = ReplSession::new().with_policy(policy); - let outcome = session - .execute(ReplCommand::Run { - graph: "approval_flow".to_string(), - input: r#"{"step":1}"#.to_string(), - }) - .unwrap(); - match outcome { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "graph_run"); - assert_eq!(detail["graph"], "approval_flow"); - } - other => panic!("expected Planned, got {other:?}"), - } -} - -// ── History tracking ────────────────────────────────────────────────────────── - -#[test] -fn session_records_history() { - let mut session = ReplSession::new(); - session.execute(ReplCommand::Help).unwrap(); - session.execute(ReplCommand::Quit).unwrap(); - assert_eq!(session.history.len(), 2); - assert_eq!(session.history[0].name(), "help"); - assert_eq!(session.history[1].name(), "quit"); -} - -// ── ReplOutcome serde ───────────────────────────────────────────────────────── - -#[test] -fn outcome_is_serde_roundtrip() { - let outcomes = vec![ - ReplOutcome::Message("hi".to_string()), - ReplOutcome::Value(serde_json::json!({"k": 1})), - ReplOutcome::Planned { - action: "graph_run".to_string(), - detail: serde_json::json!({}), - }, - ReplOutcome::Quit, - ]; - for o in outcomes { - let json = serde_json::to_string(&o).unwrap(); - let back: ReplOutcome = serde_json::from_str(&json).unwrap(); - assert_eq!(o, back); - } -} - -// ── Full parse-then-execute round-trip ─────────────────────────────────────── - -#[test] -fn full_session_workflow() { - let policy = CapabilityPolicy::from_list(["lookup_user", "run"]); - let mut session = ReplSession::new().with_policy(policy); - - // help - let h = parse_command("help").unwrap(); - assert!(matches!( - session.execute(h).unwrap(), - ReplOutcome::Message(_) - )); - - // set + get - let s = parse_command("set region us-east").unwrap(); - session.execute(s).unwrap(); - let g = parse_command("get region").unwrap(); - let v = session.execute(g).unwrap(); - assert_eq!(v, ReplOutcome::Value(serde_json::json!("us-east"))); - - // show vars - let sv = parse_command("show vars").unwrap(); - let vars = session.execute(sv).unwrap(); - assert!(matches!(vars, ReplOutcome::Value(_))); - - // call — allowed - let c = parse_command(r#"call lookup_user {"id": "u1"}"#).unwrap(); - assert!(matches!( - session.execute(c).unwrap(), - ReplOutcome::Planned { .. } - )); - - // quit - let q = parse_command("quit").unwrap(); - assert_eq!(session.execute(q).unwrap(), ReplOutcome::Quit); -} - -// ── History cap ─────────────────────────────────────────────────────────────── - -#[test] -fn history_is_capped_and_drops_oldest_entries() { - let mut session = ReplSession::new().with_history_capacity(3); - for i in 0..5 { - session - .execute(ReplCommand::Set { - key: format!("k{i}"), - value: i.to_string(), - }) - .unwrap(); - } - - // Only the newest 3 commands are retained, oldest first. - assert_eq!(session.history.len(), 3); - let keys: Vec<_> = session - .history - .iter() - .map(|cmd| match cmd { - ReplCommand::Set { key, .. } => key.clone(), - other => panic!("unexpected command in history: {other:?}"), - }) - .collect(); - assert_eq!(keys, vec!["k2", "k3", "k4"]); -} - -#[test] -fn history_capacity_defaults_to_documented_cap() { - let mut session = ReplSession::new(); - session.execute(ReplCommand::Help).unwrap(); - assert_eq!(session.history.len(), 1); - assert_eq!(super::DEFAULT_HISTORY_CAPACITY, 1000); -} - -#[test] -fn zero_history_capacity_disables_recording() { - let mut session = ReplSession::new().with_history_capacity(0); - session.execute(ReplCommand::Help).unwrap(); - assert!(session.history.is_empty()); -} - -#[test] -fn shrinking_history_capacity_trims_existing_overflow() { - let mut session = ReplSession::new(); - for _ in 0..4 { - session.execute(ReplCommand::Help).unwrap(); - } - let session = session.with_history_capacity(2); - assert_eq!(session.history.len(), 2); -} diff --git a/src/repl/types.rs b/src/repl/types.rs deleted file mode 100644 index 45b651cb..00000000 --- a/src/repl/types.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! REPL command and session types for the `.ragsh` interactive language. -//! -//! These types model the RLM/CodeAct loop as data: a [`ReplCommand`] is one -//! step an orchestrator issues, a [`ReplSession`] holds the durable values and -//! command history that step runs against, a [`CapabilityPolicy`] is the -//! allowlist that bounds what a (possibly model-driven) session may invoke, and -//! a [`ReplOutcome`] is the structured, inspectable result fed back into the -//! next step. -//! -//! All public types for the REPL skeleton live here. Logic (parsing) lives in -//! [`super`]; tests live in `test.rs`. - -use std::collections::{HashMap, HashSet, VecDeque}; - -use serde::{Deserialize, Serialize}; - -// ── Command model ───────────────────────────────────────────────────────────── - -/// The set of commands understood by the `.ragsh` REPL. -/// -/// Each variant maps to one command verb. Serde is derived so that command -/// values can be logged or replayed as JSON. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "command", rename_all = "snake_case")] -pub enum ReplCommand { - /// Print command help listing all verbs and their signatures. - Help, - - /// Load a `.rag` blueprint from the given file path into the session. - Load { - /// Filesystem path to the `.rag` source file. - path: String, - }, - - /// Compile a named blueprint that has already been loaded into the session. - Compile { - /// Name of the blueprint to compile. - name: String, - }, - - /// Run a named compiled graph with a JSON-encoded input payload. - /// - /// In the skeleton this produces a [`ReplOutcome::Planned`]; live wiring - /// to the graph runtime is a follow-up milestone. - Run { - /// Name of the registered compiled graph. - graph: String, - /// JSON-encoded input payload to pass to the graph. - input: String, - }, - - /// Set a named session variable to a string value. - /// - /// The value is stored internally as a [`serde_json::Value::String`]. - /// Use [`ReplSession::set`] directly for richer JSON values. - Set { - /// Variable name. - key: String, - /// String representation of the value. - value: String, - }, - - /// Retrieve a named session variable and return its value. - Get { - /// Variable name to look up. - key: String, - }, - - /// Show session information. - /// - /// Recognised subjects: `vars`, `graphs`, `status`. - Show { - /// The subject to display (`vars`, `graphs`, or `status`). - what: String, - }, - - /// Invoke a registered capability by name with a JSON argument object. - /// - /// In the skeleton this is policy-checked and returned as - /// [`ReplOutcome::Planned`] rather than executed immediately. - Call { - /// Name of the registered capability (must be on the [`CapabilityPolicy`] - /// allowlist). - capability: String, - /// Arbitrary JSON arguments forwarded to the capability. - args: serde_json::Value, - }, - - /// Exit the REPL session. - Quit, -} - -impl ReplCommand { - /// Returns the canonical command verb name used in the grammar. - pub fn name(&self) -> &'static str { - match self { - ReplCommand::Help => "help", - ReplCommand::Load { .. } => "load", - ReplCommand::Compile { .. } => "compile", - ReplCommand::Run { .. } => "run", - ReplCommand::Set { .. } => "set", - ReplCommand::Get { .. } => "get", - ReplCommand::Show { .. } => "show", - ReplCommand::Call { .. } => "call", - ReplCommand::Quit => "quit", - } - } -} - -// ── Outcome ─────────────────────────────────────────────────────────────────── - -/// The result produced by executing a [`ReplCommand`] in a [`ReplSession`]. -/// -/// Uses adjacent tagging (`tag = "kind", content = "data"`) so that newtype -/// variants containing non-map values (such as `Message` holding a `String`) -/// serialize correctly alongside struct variants like `Planned`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "data", rename_all = "snake_case")] -pub enum ReplOutcome { - /// A human-readable message from a side-effect-free command. - Message(String), - - /// A JSON value retrieved from the session namespace. - Value(serde_json::Value), - - /// The command was policy-checked and recorded; live harness/graph - /// execution is deferred until the REPL skeleton is wired to a runtime - /// (milestones R2–R6 in the design document). - Planned { - /// Short label of the intended action (e.g. `"graph_run"`). - action: String, - /// Structured parameters describing the planned call. - detail: serde_json::Value, - }, - - /// The session has been asked to terminate. - Quit, -} - -// ── Capability policy ───────────────────────────────────────────────────────── - -/// An allowlist that controls which capability names a [`ReplSession`] may -/// invoke. -/// -/// By default nothing is allowed. Use [`CapabilityPolicy::allow`] or -/// [`CapabilityPolicy::from_list`] to grant access. Attempting to invoke a -/// capability that is not on the list produces a -/// [`crate::error::TinyAgentsError::Capability`] error. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct CapabilityPolicy { - allowed: HashSet, -} - -impl CapabilityPolicy { - /// Create an empty policy (no capabilities allowed). - pub fn new() -> Self { - Self::default() - } - - /// Add a capability name to the allowlist. - pub fn allow(&mut self, name: impl Into) -> &mut Self { - self.allowed.insert(name.into()); - self - } - - /// Returns `true` if the given capability name is on the allowlist. - pub fn is_allowed(&self, name: &str) -> bool { - self.allowed.contains(name) - } - - /// Build a policy from an iterable of allowed names. - pub fn from_list(names: I) -> Self - where - I: IntoIterator, - S: Into, - { - let mut policy = Self::new(); - for name in names { - policy.allow(name.into()); - } - policy - } - - /// Returns the number of capabilities currently on the allowlist. - pub fn len(&self) -> usize { - self.allowed.len() - } - - /// Returns `true` if no capabilities are allowed. - pub fn is_empty(&self) -> bool { - self.allowed.is_empty() - } -} - -// ── Session ─────────────────────────────────────────────────────────────────── - -/// An interactive REPL session holding session variables, a capability policy, -/// and a command history. -/// -/// `ReplSession` is the primary entry point for driving the `.ragsh` skeleton -/// — the **line-oriented command REPL** (verbs like `set`/`get`/`run`/`call` -/// parsed from a single line; see the [`repl`](crate::repl) module docs for -/// the grammar). -/// -/// # Not to be confused with `repl::session::ReplSession` -/// -/// There are two distinct types named `ReplSession` in this crate, gated -/// differently and serving different layers of the `.ragsh` design: -/// -/// - **This type** (`repl::ReplSession`, always available) — the -/// line-oriented command skeleton documented here. -/// - [`crate::repl::session::ReplSession`] (feature `repl` only) — the -/// Rhai-backed scripting session: a persistent namespace evaluated one cell -/// (small script) at a time, with capability calls (`model_query`, -/// `tool_call`, `graph_run`, …) wired to live registries. -/// -/// Only **this** type is re-exported as `repl::ReplSession`; the scripting -/// session is deliberately *not* re-exported there to avoid shadowing it, and -/// must be reached via `repl::session::ReplSession`. With the `repl` feature -/// enabled, `crate::ReplSession` (the crate-root re-export) resolves to the -/// **scripting** session instead — the crate root and the `repl` module -/// re-export different types under the same final path segment, so always -/// check which path (`crate::ReplSession` vs. `crate::repl::ReplSession`) you -/// actually imported from. -/// -/// ## Execution model -/// -/// Side-effect-free commands (`Set`, `Get`, `Show`, `Help`, `Quit`) run fully -/// inside `execute`. Commands that need live harness/graph integration -/// (`Load`, `Compile`, `Run`, `Call`) are policy-checked first — a -/// [`crate::error::TinyAgentsError::Capability`] error is returned immediately -/// if the operation is not on the allowlist — and, when allowed, the method -/// returns [`ReplOutcome::Planned`] describing the intended action without -/// performing it. The wiring to the live runtime is a follow-up milestone -/// (R2–R6 in the design document). -/// -/// ## Example -/// -/// ```rust -/// use tinyagents::repl::{ReplSession, CapabilityPolicy, ReplOutcome}; -/// -/// let policy = CapabilityPolicy::from_list(["my_tool"]); -/// let mut session = ReplSession::new().with_policy(policy); -/// -/// session.set("x", serde_json::json!(42)); -/// assert_eq!(session.get("x"), Some(&serde_json::json!(42))); -/// ``` -pub struct ReplSession { - /// Session-scoped variables, keyed by name and stored as JSON values. - variables: HashMap, - /// The capability allowlist governing this session. - policy: CapabilityPolicy, - /// Ordered history of the most recent commands submitted to this session - /// (oldest first). Bounded by the session's history capacity — - /// [`DEFAULT_HISTORY_CAPACITY`] unless overridden with - /// [`ReplSession::with_history_capacity`] — with the **oldest** entries - /// dropped once the cap is reached, so a long-lived session does not grow - /// without bound (`Call` commands clone their full JSON args into history). - pub history: VecDeque, - /// Maximum number of retained history entries. - history_capacity: usize, -} - -/// Default cap on [`ReplSession::history`] entries. -/// -/// Chosen to comfortably cover interactive and replay sessions while bounding -/// memory in long-lived processes; override per session with -/// [`ReplSession::with_history_capacity`]. -pub const DEFAULT_HISTORY_CAPACITY: usize = 1000; - -impl ReplSession { - /// Create a new session with an empty namespace, a deny-all policy, and - /// the default history capacity ([`DEFAULT_HISTORY_CAPACITY`]). - pub fn new() -> Self { - Self { - variables: HashMap::new(), - policy: CapabilityPolicy::new(), - history: VecDeque::new(), - history_capacity: DEFAULT_HISTORY_CAPACITY, - } - } - - /// Replace the session's capability policy, returning the updated session. - pub fn with_policy(mut self, policy: CapabilityPolicy) -> Self { - self.policy = policy; - self - } - - /// Set the maximum number of history entries retained by this session, - /// returning the updated session. - /// - /// Once the cap is reached the **oldest** entry is dropped per new - /// command. A capacity of `0` disables history recording entirely. Any - /// existing overflow is trimmed immediately. - pub fn with_history_capacity(mut self, capacity: usize) -> Self { - self.history_capacity = capacity; - while self.history.len() > capacity { - self.history.pop_front(); - } - self - } - - /// Set a session variable to any JSON value. - pub fn set(&mut self, key: impl Into, value: serde_json::Value) { - self.variables.insert(key.into(), value); - } - - /// Get a session variable by name. Returns `None` if it has not been set. - pub fn get(&self, key: &str) -> Option<&serde_json::Value> { - self.variables.get(key) - } - - /// Return a reference to the full variable map. - pub fn vars(&self) -> &HashMap { - &self.variables - } - - /// Execute a command against this session and return a [`ReplOutcome`]. - /// - /// The command is appended to [`ReplSession::history`] before execution - /// begins; when the history is at capacity the oldest entry is dropped - /// first (see [`ReplSession::with_history_capacity`]). - /// - /// # Errors - /// - /// * [`crate::error::TinyAgentsError::Capability`] — the command requires - /// a capability that is not on the allowlist. - /// * [`crate::error::TinyAgentsError::Serialization`] — an internal - /// serialization step failed (e.g. serialising variables for `show vars`). - pub fn execute(&mut self, cmd: ReplCommand) -> crate::error::Result { - if self.history_capacity > 0 { - if self.history.len() == self.history_capacity { - self.history.pop_front(); - } - self.history.push_back(cmd.clone()); - } - - match cmd { - ReplCommand::Help => { - let text = concat!( - "Commands:\n", - " help — show this help\n", - " load — load a .rag blueprint\n", - " compile — compile a loaded blueprint\n", - " run — run a compiled graph\n", - " set — set a session variable\n", - " get — retrieve a session variable\n", - " show — show session info\n", - " call — invoke a registered capability\n", - " quit — exit the session", - ); - Ok(ReplOutcome::Message(text.to_string())) - } - - ReplCommand::Quit => Ok(ReplOutcome::Quit), - - ReplCommand::Set { key, value } => { - self.variables.insert(key, serde_json::Value::String(value)); - Ok(ReplOutcome::Message("ok".to_string())) - } - - ReplCommand::Get { key } => { - let val = self - .variables - .get(&key) - .cloned() - .unwrap_or(serde_json::Value::Null); - Ok(ReplOutcome::Value(val)) - } - - ReplCommand::Show { what } => match what.as_str() { - "vars" => { - let map = serde_json::to_value(&self.variables)?; - Ok(ReplOutcome::Value(map)) - } - "graphs" => Ok(ReplOutcome::Message( - "(graph registry not yet wired in skeleton)".to_string(), - )), - "status" => { - let status = serde_json::json!({ - "variables": self.variables.len(), - "history": self.history.len(), - "policy_allowed": self.policy.len(), - }); - Ok(ReplOutcome::Value(status)) - } - other => Ok(ReplOutcome::Message(format!( - "unknown show subject `{other}`; recognised subjects: vars, graphs, status" - ))), - }, - - ReplCommand::Load { path } => { - self.check_capability("load")?; - Ok(ReplOutcome::Planned { - action: "load".to_string(), - detail: serde_json::json!({ "path": path }), - }) - } - - ReplCommand::Compile { name } => { - self.check_capability("compile")?; - Ok(ReplOutcome::Planned { - action: "compile".to_string(), - detail: serde_json::json!({ "name": name }), - }) - } - - ReplCommand::Run { graph, input } => { - self.check_capability("run")?; - Ok(ReplOutcome::Planned { - action: "graph_run".to_string(), - detail: serde_json::json!({ "graph": graph, "input": input }), - }) - } - - ReplCommand::Call { capability, args } => { - self.check_capability(&capability)?; - Ok(ReplOutcome::Planned { - action: "capability_call".to_string(), - detail: serde_json::json!({ "capability": capability, "args": args }), - }) - } - } - } - - // ── Private helpers ─────────────────────────────────────────────────────── - - fn check_capability(&self, name: &str) -> crate::error::Result<()> { - if self.policy.is_allowed(name) { - Ok(()) - } else { - Err(crate::error::TinyAgentsError::Capability(format!( - "capability `{name}` is not in the session allowlist" - ))) - } - } -} - -impl Default for ReplSession { - fn default() -> Self { - Self::new() - } -} diff --git a/src/rlm/host.rs b/src/rlm/host.rs deleted file mode 100644 index 406b2a12..00000000 --- a/src/rlm/host.rs +++ /dev/null @@ -1,412 +0,0 @@ -//! The host side of the RLM capability boundary. -//! -//! Every interpreter backend — embedded or external — funnels script -//! capability calls through one object-safe seam, [`RlmHostApi::handle`]. -//! [`RlmHost`] is the standard implementation: it resolves names through the -//! session's [`CapabilityRegistry`], enforces the [`RlmPolicy`] call and -//! recursion limits fail-closed, records an [`RlmCallRecord`] per call, and -//! lowers to the real harness runtime (`ChatModel::invoke`, `Tool::call`, -//! `HarnessAgent::run`). -//! -//! ## Fatal vs script-visible errors -//! -//! A capability call can fail two ways, and the distinction is the sandbox -//! contract: -//! -//! - **Script-visible** failures (unknown tool, tool returned an error, model -//! provider error) surface *inside* the script as a raised -//! exception/runtime error. The driving model observes them in the cell -//! outcome and may adapt — that feedback loop is the whole point of an RLM. -//! - **Fatal** failures ([`TinyAgentsError::LimitExceeded`], -//! [`Timeout`](TinyAgentsError::Timeout), -//! [`Cancelled`](TinyAgentsError::Cancelled), -//! [`SubAgentDepth`](TinyAgentsError::SubAgentDepth)) mean a policy bound -//! tripped; the cell is aborted (and an external interpreter's child -//! process killed) rather than letting the script observe and route around -//! its own resource limits. - -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use serde_json::{Value, json}; - -use super::types::{HostCall, RlmCallRecord, RlmCancelFlag, RlmPolicy}; -use crate::error::{Result, TinyAgentsError}; -use crate::graph::subagent_node::SubAgentInput; -use crate::harness::events::EventSink; -use crate::harness::message::Message; -use crate::harness::model::ModelRequest; -use crate::harness::tool::ToolCall; -use crate::registry::{CapabilityRegistry, ComponentKind}; - -/// Returns whether a capability error must abort the cell (policy bound -/// tripped) instead of surfacing inside the script. -pub fn is_fatal(err: &TinyAgentsError) -> bool { - matches!( - err, - TinyAgentsError::LimitExceeded(_) - | TinyAgentsError::Timeout(_) - | TinyAgentsError::Cancelled - | TinyAgentsError::SubAgentDepth(_) - ) -} - -/// A snapshot of the registered capability names a session may reach, -/// rendered into the driver prompt so the model knows what it can call. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CapabilityListing { - /// Registered model names. - pub models: Vec, - /// Registered tool names (with descriptions when available). - pub tools: Vec<(String, String)>, - /// Registered agent names. - pub agents: Vec, -} - -/// The object-safe host seam every interpreter backend calls through. -/// -/// Implementations must be cheap to share (`Arc`): the -/// embedded Rhai engine clones the handle into each capability closure, and -/// the external-interpreter driver holds it across protocol frames. -#[async_trait] -pub trait RlmHostApi: Send + Sync { - /// Executes one capability call on behalf of a script. - /// - /// A `Ok(value)` is handed back to the script; an `Err` is surfaced into - /// the script when recoverable (see [`is_fatal`]) or aborts the cell when - /// fatal. - async fn handle(&self, call: HostCall) -> Result; - - /// The capability names available to scripts (for prompt rendering). - fn capabilities(&self) -> CapabilityListing; - - /// The wall-clock deadline of the cell currently being evaluated. - fn deadline(&self) -> Option; - - /// The session cancellation flag. - fn cancel_flag(&self) -> RlmCancelFlag; -} - -/// Session-cumulative counters enforced against [`RlmPolicy`]. -#[derive(Debug, Default, Clone, Copy)] -struct CallCounters { - llm: usize, - tool: usize, - agent: usize, -} - -/// Per-cell mutable buffers the session arms before evaluating a cell and -/// drains after. -#[derive(Debug, Default)] -struct CellBuffers { - deadline: Option, - calls: Vec, - final_answer: Option, -} - -/// The standard capability host, bound to a [`CapabilityRegistry`]. -pub struct RlmHost { - registry: Arc>, - state: Arc, - policy: RlmPolicy, - /// The model `llm(...)` reaches when the script names none. - default_model: Option, - /// The session's run depth — the parent depth for sub-agent runs. - run_depth: usize, - events: EventSink, - cancel: RlmCancelFlag, - counters: Mutex, - cell: Mutex, -} - -impl RlmHost { - /// Builds a host over a capability registry and application state. - pub fn new(registry: Arc>, state: Arc) -> Self { - Self { - registry, - state, - policy: RlmPolicy::default(), - default_model: None, - run_depth: 0, - events: EventSink::default(), - cancel: RlmCancelFlag::new(), - counters: Mutex::new(CallCounters::default()), - cell: Mutex::new(CellBuffers::default()), - } - } - - /// Sets the session policy. - pub fn with_policy(mut self, policy: RlmPolicy) -> Self { - self.policy = policy; - self - } - - /// Sets the default model `llm(...)` reaches when the script names none. - pub fn with_default_model(mut self, model: impl Into) -> Self { - self.default_model = Some(model.into()); - self - } - - /// Sets the run depth sub-agent calls recurse below. - pub fn with_run_depth(mut self, depth: usize) -> Self { - self.run_depth = depth; - self - } - - /// Installs an event sink child agent runs fan onto. - pub fn with_events(mut self, events: EventSink) -> Self { - self.events = events; - self - } - - /// Installs an external cancellation flag. - pub fn with_cancel_flag(mut self, cancel: RlmCancelFlag) -> Self { - self.cancel = cancel; - self - } - - /// The session policy. - pub fn policy(&self) -> &RlmPolicy { - &self.policy - } - - /// The application state capability calls run against. - pub fn app_state(&self) -> Arc { - self.state.clone() - } - - /// Arms the per-cell buffers before a cell is evaluated. - pub(super) fn begin_cell(&self) { - let mut cell = self.cell.lock().expect("cell buffers poisoned"); - cell.deadline = self.policy.cell_timeout.map(|t| Instant::now() + t); - cell.calls.clear(); - cell.final_answer = None; - } - - /// Drains the per-cell buffers after a cell finished: the recorded calls - /// and the final answer, if `final_answer(...)` was called. - pub(super) fn end_cell(&self) -> (Vec, Option) { - let mut cell = self.cell.lock().expect("cell buffers poisoned"); - cell.deadline = None; - (std::mem::take(&mut cell.calls), cell.final_answer.take()) - } - - fn record(&self, record: RlmCallRecord) { - self.cell - .lock() - .expect("cell buffers poisoned") - .calls - .push(record); - } - - fn bump(&self, call: &HostCall) -> Result<()> { - let mut counters = self.counters.lock().expect("counters poisoned"); - match call { - HostCall::Llm { .. } => { - if counters.llm >= self.policy.max_llm_calls { - return Err(TinyAgentsError::LimitExceeded(format!( - "llm call limit ({}) exceeded", - self.policy.max_llm_calls - ))); - } - counters.llm += 1; - } - HostCall::Tool { .. } => { - if counters.tool >= self.policy.max_tool_calls { - return Err(TinyAgentsError::LimitExceeded(format!( - "tool call limit ({}) exceeded", - self.policy.max_tool_calls - ))); - } - counters.tool += 1; - } - HostCall::Agent { .. } => { - if counters.agent >= self.policy.max_agent_calls { - return Err(TinyAgentsError::LimitExceeded(format!( - "agent call limit ({}) exceeded", - self.policy.max_agent_calls - ))); - } - counters.agent += 1; - } - HostCall::FinalAnswer { .. } => {} - } - Ok(()) - } - - /// Session-cumulative `(llm, tool, agent)` call counts. - pub fn call_counts(&self) -> (usize, usize, usize) { - let counters = self.counters.lock().expect("counters poisoned"); - (counters.llm, counters.tool, counters.agent) - } - - async fn handle_llm( - &self, - model: Option, - prompt: String, - system: Option, - ) -> Result { - let model_name = model - .or_else(|| self.default_model.clone()) - .ok_or_else(|| { - TinyAgentsError::Validation( - "llm: no model named and the session has no default model".to_string(), - ) - })?; - let model = self - .registry - .model(&model_name) - .ok_or_else(|| TinyAgentsError::ModelNotFound(model_name.clone()))?; - let mut messages = Vec::new(); - if let Some(system) = system { - messages.push(Message::system(system)); - } - messages.push(Message::user(prompt)); - // `model_name` is the registry name, not a provider model id; the - // resolved ChatModel carries its own provider configuration. - let request = ModelRequest { - messages, - ..Default::default() - }; - let start = Instant::now(); - let response = model.invoke(&self.state, request).await?; - let text = Message::Assistant(response.message).text(); - self.record(RlmCallRecord { - kind: super::types::RlmCallKind::Llm, - name: model_name, - detail: json!({ "chars": text.len() }), - elapsed: start.elapsed(), - }); - Ok(Value::String(text)) - } - - async fn handle_tool(&self, tool_name: String, arguments: Value) -> Result { - let tool = self - .registry - .tool(&tool_name) - .ok_or_else(|| TinyAgentsError::ToolNotFound(tool_name.clone()))?; - let call = ToolCall::new( - crate::harness::ids::new_call_id().as_str().to_string(), - tool_name.clone(), - arguments.clone(), - ); - // Validate against the tool's schema up front so the script gets a - // precise, catchable error instead of tool-dependent behavior. - tool.schema().validate_call(&call)?; - let start = Instant::now(); - let result = tool.call(&self.state, call).await?; - self.record(RlmCallRecord { - kind: super::types::RlmCallKind::Tool, - name: tool_name, - detail: json!({ "arguments": arguments }), - elapsed: start.elapsed(), - }); - if let Some(error) = result.error { - return Err(TinyAgentsError::Tool(error)); - } - match result.raw { - Some(raw) => Ok(raw), - None => Ok(Value::String(result.content)), - } - } - - async fn handle_agent( - &self, - agent_name: String, - input: String, - data: Option, - ) -> Result { - // Reuse the shared harness depth guard so RLM sub-runs stay in - // lock-step with SubAgent / SubAgentTool / the REPL. - crate::harness::context::RunConfig::checked_child_depth( - self.run_depth, - self.policy.max_depth, - )?; - let agent = self.registry.agent(&agent_name).ok_or_else(|| { - TinyAgentsError::Capability(format!("agent `{agent_name}` is not registered")) - })?; - let mut sub_input = SubAgentInput::prompt(input); - if let Some(data) = data { - sub_input = sub_input.with_data(data); - } - let start = Instant::now(); - let output = agent.run(sub_input, self.events.clone()).await?; - self.record(RlmCallRecord { - kind: super::types::RlmCallKind::Agent, - name: agent_name, - detail: json!({ - "model_calls": output.model_calls, - "tool_calls": output.tool_calls, - }), - elapsed: start.elapsed(), - }); - Ok(Value::String(output.text)) - } -} - -#[async_trait] -impl RlmHostApi for RlmHost { - async fn handle(&self, call: HostCall) -> Result { - if self.cancel.is_cancelled() { - return Err(TinyAgentsError::Cancelled); - } - if let Some(deadline) = self.deadline() - && Instant::now() >= deadline - { - return Err(TinyAgentsError::Timeout( - "rlm cell exceeded its wall-clock timeout".to_string(), - )); - } - self.bump(&call)?; - match call { - HostCall::Llm { - model, - prompt, - system, - } => self.handle_llm(model, prompt, system).await, - HostCall::Tool { tool, arguments } => self.handle_tool(tool, arguments).await, - HostCall::Agent { agent, input, data } => self.handle_agent(agent, input, data).await, - HostCall::FinalAnswer { answer } => { - let mut cell = self.cell.lock().expect("cell buffers poisoned"); - cell.final_answer = Some(answer); - cell.calls.push(RlmCallRecord { - kind: super::types::RlmCallKind::FinalAnswer, - name: "final_answer".to_string(), - detail: Value::Null, - elapsed: Duration::default(), - }); - Ok(Value::Null) - } - } - } - - fn capabilities(&self) -> CapabilityListing { - let tools = self - .registry - .names(ComponentKind::Tool) - .into_iter() - .map(|name| { - let description = self - .registry - .tool(&name) - .map(|tool| tool.description().to_string()) - .unwrap_or_default(); - (name, description) - }) - .collect(); - CapabilityListing { - models: self.registry.names(ComponentKind::Model), - tools, - agents: self.registry.names(ComponentKind::Agent), - } - } - - fn deadline(&self) -> Option { - self.cell.lock().expect("cell buffers poisoned").deadline - } - - fn cancel_flag(&self) -> RlmCancelFlag { - self.cancel.clone() - } -} diff --git a/src/rlm/interpreter/external.rs b/src/rlm/interpreter/external.rs deleted file mode 100644 index fc5bd1cf..00000000 --- a/src/rlm/interpreter/external.rs +++ /dev/null @@ -1,547 +0,0 @@ -//! The external-process interpreter backend (Python, Node.js, or any command -//! speaking the wire protocol). -//! -//! ## Wire protocol -//! -//! Line-delimited JSON over the child's stdin/stdout. The child is a -//! long-lived REPL: globals persist across cells. Frames, host → child: -//! -//! - `{"op":"eval","code":"..."}` — evaluate one cell. -//! - `{"op":"set_var","name":"...","value":}` — set a global. -//! - `{"op":"call_result","ok":true,"value":}` / -//! `{"op":"call_result","ok":false,"error":"..."}` — the reply to a -//! capability call (script-visible failures arrive as `ok:false`; *fatal* -//! failures never get a reply — the host kills the child instead). -//! - `{"op":"shutdown"}` — exit cleanly. -//! -//! Child → host: -//! -//! - `{"op":"ready"}` — emitted once after bootstrap. -//! - `{"op":"call","call":{"capability":"llm"|"tool"|"agent"|"final_answer",...}}` -//! — a capability call (the `call` payload is a serialized -//! [`HostCall`]); the child blocks until the matching `call_result`. -//! - `{"op":"result","stdout":"...","value":,"error":}` — -//! the cell outcome. -//! - `{"op":"var_set"}` — acknowledges `set_var`. -//! -//! Calls are strictly sequential (one cell evaluates at a time and blocks on -//! each capability call), so frames need no correlation ids. -//! -//! ## Sandboxing honesty -//! -//! Unlike the embedded Rhai backend, a child process has whatever OS access -//! the embedder's environment grants it. The host still enforces every -//! [`RlmPolicy`](crate::rlm::RlmPolicy) limit fail-closed — a cell that -//! exceeds its deadline or trips a policy bound gets its child **killed**, -//! not asked nicely — but filesystem/network isolation for untrusted models -//! must come from the embedder (container, jail, seccomp, a locked-down -//! `InterpreterSpec::Command` runner). - -use std::process::Stdio; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use serde_json::{Value, json}; -use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStdin, ChildStdout}; - -use super::{CellEval, RlmInterpreter}; -use crate::error::{Result, TinyAgentsError}; -use crate::rlm::host::{RlmHostApi, is_fatal}; -use crate::rlm::types::HostCall; - -/// The Python bootstrap program (`python3 -u -c `), a minimal REPL -/// speaking the wire protocol. User prints are captured per cell; the -/// protocol channel is the real stdout, saved before any redirection. -const PYTHON_PRELUDE: &str = r#" -import ast, contextlib, io, json, sys, traceback -_out, _in = sys.stdout, sys.stdin - -def _send(obj): - _out.write(json.dumps(obj) + "\n"); _out.flush() - -class RlmError(Exception): - pass - -def _call(call): - _send({"op": "call", "call": call}) - while True: - line = _in.readline() - if not line: - sys.exit(0) - msg = json.loads(line) - if msg.get("op") == "call_result": - if msg.get("ok"): - return msg.get("value") - raise RlmError(msg.get("error") or "capability call failed") - -def llm(prompt, model=None, system=None): - if isinstance(prompt, dict): - model, system, prompt = prompt.get("model"), prompt.get("system"), prompt.get("prompt") - return _call({"capability": "llm", "prompt": prompt, "model": model, "system": system}) - -def tool(name, arguments=None): - return _call({"capability": "tool", "tool": name, "arguments": arguments}) - -def agent(name, input, data=None): - return _call({"capability": "agent", "agent": name, "input": str(input), "data": data}) - -def final_answer(answer): - _call({"capability": "final_answer", "answer": str(answer)}) - -_g = {"__name__": "__rlm__", "llm": llm, "tool": tool, "agent": agent, - "final_answer": final_answer, "RlmError": RlmError} - -def _eval(code): - buf = io.StringIO() - value, error = None, None - try: - with contextlib.redirect_stdout(buf): - tree = ast.parse(code, mode="exec") - if tree.body and isinstance(tree.body[-1], ast.Expr): - last = ast.Expression(tree.body[-1].value) - body = ast.Module(body=tree.body[:-1], type_ignores=[]) - exec(compile(body, "", "exec"), _g) - value = eval(compile(last, "", "eval"), _g) - else: - exec(compile(tree, "", "exec"), _g) - except Exception: - error = traceback.format_exc(limit=4) - try: - json.dumps(value) - except Exception: - value = repr(value) - _send({"op": "result", "stdout": buf.getvalue(), "value": value, "error": error}) - -_send({"op": "ready"}) -for _line in _in: - _msg = json.loads(_line) - _op = _msg.get("op") - if _op == "eval": - _eval(_msg.get("code") or "") - elif _op == "set_var": - _g[_msg["name"]] = _msg.get("value") - _send({"op": "var_set"}) - elif _op == "shutdown": - break -"#; - -/// The Node.js bootstrap program (`node -e `). Cells run in a -/// persistent `vm` context; `console.log` is captured per cell; capability -/// calls block on stdin with `fs.readSync`. -const JAVASCRIPT_PRELUDE: &str = r#" -const fs = require('fs'); -const vm = require('vm'); -let inbuf = Buffer.alloc(0); -function readLine() { - for (;;) { - const idx = inbuf.indexOf(10); - if (idx >= 0) { - const line = inbuf.slice(0, idx).toString('utf8'); - inbuf = inbuf.slice(idx + 1); - return line; - } - const chunk = Buffer.alloc(65536); - let n; - try { n = fs.readSync(0, chunk, 0, chunk.length, null); } - catch (e) { if (e.code === 'EAGAIN') continue; throw e; } - if (n === 0) process.exit(0); - inbuf = Buffer.concat([inbuf, chunk.slice(0, n)]); - } -} -function send(obj) { fs.writeSync(1, JSON.stringify(obj) + '\n'); } -class RlmError extends Error {} -function call(c) { - send({ op: 'call', call: c }); - for (;;) { - const msg = JSON.parse(readLine()); - if (msg.op === 'call_result') { - if (msg.ok) return msg.value === undefined ? null : msg.value; - throw new RlmError(msg.error || 'capability call failed'); - } - } -} -let stdoutBuf = ''; -function logLine(args) { - stdoutBuf += args.map(x => (typeof x === 'string' ? x : JSON.stringify(x))).join(' ') + '\n'; -} -const sandbox = { - llm: p => (typeof p === 'string' - ? call({ capability: 'llm', prompt: p, model: null, system: null }) - : call({ capability: 'llm', prompt: p.prompt, model: p.model || null, system: p.system || null })), - tool: (name, args) => call({ capability: 'tool', tool: name, arguments: args === undefined ? null : args }), - agent: (name, input) => call({ capability: 'agent', agent: name, input: String(input), data: null }), - final_answer: a => { call({ capability: 'final_answer', answer: String(a) }); }, - console: { log: (...xs) => logLine(xs), error: (...xs) => logLine(xs), info: (...xs) => logLine(xs) }, - RlmError, - JSON, Math, -}; -const ctx = vm.createContext(sandbox); -send({ op: 'ready' }); -for (;;) { - const msg = JSON.parse(readLine()); - if (msg.op === 'eval') { - stdoutBuf = ''; - let value = null, error = null; - try { - value = vm.runInContext(msg.code, ctx, { filename: '' }); - if (value === undefined) value = null; - if (typeof value === 'function') value = String(value); - try { JSON.stringify(value); } catch { value = String(value); } - } catch (e) { - error = e && e.stack ? String(e.stack).split('\n').slice(0, 4).join('\n') : String(e); - } - send({ op: 'result', stdout: stdoutBuf, value, error }); - } else if (msg.op === 'set_var') { - sandbox[msg.name] = msg.value; - send({ op: 'var_set' }); - } else if (msg.op === 'shutdown') { - process.exit(0); - } -} -"#; - -/// How long the child gets to print its `ready` frame after spawning. -const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); - -/// The fallback bound for protocol exchanges when the session armed no cell -/// deadline (`RlmPolicy::cell_timeout: None`). -const DEFAULT_EXCHANGE_TIMEOUT: Duration = Duration::from_secs(300); - -/// A live child process with its protocol streams. -struct ChildHandle { - child: Child, - stdin: ChildStdin, - stdout: BufReader, - /// Rolling capture of the child's stderr, included in error messages. - stderr: Arc>, -} - -/// The external-process backend. See the [module docs](self). -pub struct ExternalInterpreter { - language: String, - binary: String, - args: Vec, - child: Option, -} - -impl ExternalInterpreter { - /// A CPython child (`binary` defaults to `python3`). - pub fn python(binary: Option<&str>, extra_args: Vec) -> Self { - let mut args = extra_args; - args.extend([ - "-u".to_string(), - "-c".to_string(), - PYTHON_PRELUDE.to_string(), - ]); - Self { - language: "python".to_string(), - binary: binary.unwrap_or("python3").to_string(), - args, - child: None, - } - } - - /// A Node.js child (`binary` defaults to `node`). - pub fn javascript(binary: Option<&str>, extra_args: Vec) -> Self { - let mut args = extra_args; - args.extend(["-e".to_string(), JAVASCRIPT_PRELUDE.to_string()]); - Self { - language: "javascript".to_string(), - binary: binary.unwrap_or("node").to_string(), - args, - child: None, - } - } - - /// An arbitrary command that speaks the wire protocol itself. Scripts are - /// assumed to be Python-flavored for prompt purposes. - pub fn command(binary: String, args: Vec) -> Self { - Self { - language: "python".to_string(), - binary, - args, - child: None, - } - } - - fn stderr_tail(&self) -> String { - self.child - .as_ref() - .map(|c| { - let text = c.stderr.lock().expect("stderr buffer poisoned"); - let tail: String = text.chars().rev().take(2000).collect(); - tail.chars().rev().collect() - }) - .unwrap_or_default() - } - - fn broken(&mut self, context: &str) -> TinyAgentsError { - let stderr = self.stderr_tail(); - self.child = None; - let mut message = format!("rlm external interpreter ({}): {context}", self.binary); - if !stderr.is_empty() { - message.push_str(&format!("; stderr tail: {stderr}")); - } - TinyAgentsError::Capability(message) - } - - async fn ensure_started(&mut self) -> Result<()> { - if self.child.is_some() { - return Ok(()); - } - let mut child = tokio::process::Command::new(&self.binary) - .args(&self.args) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true) - .spawn() - .map_err(|err| { - TinyAgentsError::Capability(format!( - "rlm external interpreter: failed to spawn `{}`: {err}", - self.binary - )) - })?; - let stdin = child.stdin.take().expect("piped stdin"); - let stdout = BufReader::new(child.stdout.take().expect("piped stdout")); - let stderr_pipe = child.stderr.take().expect("piped stderr"); - let stderr = Arc::new(Mutex::new(String::new())); - let stderr_buf = stderr.clone(); - tokio::spawn(async move { - let mut reader = BufReader::new(stderr_pipe); - let mut buffer = [0u8; 4096]; - loop { - match reader.read(&mut buffer).await { - Ok(0) | Err(_) => break, - Ok(n) => { - let mut text = stderr_buf.lock().expect("stderr buffer poisoned"); - text.push_str(&String::from_utf8_lossy(&buffer[..n])); - // Keep only a bounded tail. - if text.len() > 16 * 1024 { - let cut = text.len() - 8 * 1024; - *text = text[cut..].to_string(); - } - } - } - } - }); - self.child = Some(ChildHandle { - child, - stdin, - stdout, - stderr, - }); - - // Wait for the bootstrap's `ready` frame. - match self.read_frame(Instant::now() + STARTUP_TIMEOUT).await { - Ok(frame) if frame.get("op").and_then(Value::as_str) == Some("ready") => Ok(()), - Ok(frame) => Err(self.broken(&format!("unexpected startup frame: {frame}"))), - Err(err) => { - self.kill().await; - Err(self.broken(&format!("did not become ready: {err}"))) - } - } - } - - async fn send_frame(&mut self, frame: Value) -> Result<()> { - let handle = self - .child - .as_mut() - .ok_or_else(|| TinyAgentsError::Capability("rlm interpreter not running".into()))?; - let mut line = frame.to_string(); - line.push('\n'); - if handle.stdin.write_all(line.as_bytes()).await.is_err() { - return Err(self.broken("stdin closed")); - } - let _ = handle.stdin.flush().await; - Ok(()) - } - - async fn read_frame(&mut self, deadline: Instant) -> Result { - let handle = self - .child - .as_mut() - .ok_or_else(|| TinyAgentsError::Capability("rlm interpreter not running".into()))?; - loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(TinyAgentsError::Timeout( - "rlm cell exceeded its wall-clock timeout".to_string(), - )); - } - let mut line = String::new(); - let read = tokio::time::timeout(remaining, handle.stdout.read_line(&mut line)).await; - match read { - Err(_) => { - return Err(TinyAgentsError::Timeout( - "rlm cell exceeded its wall-clock timeout".to_string(), - )); - } - Ok(Err(err)) => return Err(self.broken(&format!("stdout read failed: {err}"))), - Ok(Ok(0)) => return Err(self.broken("exited unexpectedly")), - Ok(Ok(_)) => { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - match serde_json::from_str::(trimmed) { - Ok(frame) => return Ok(frame), - // Non-protocol noise on stdout (a library printing - // directly to fd 1) is ignored rather than fatal. - Err(_) => continue, - } - } - } - } - } - - async fn kill(&mut self) { - if let Some(mut handle) = self.child.take() { - let _ = handle.child.kill().await; - } - } -} - -#[async_trait] -impl RlmInterpreter for ExternalInterpreter { - fn language(&self) -> &str { - &self.language - } - - fn usage_guide(&self) -> String { - match self.language.as_str() { - "javascript" => r#"Write JavaScript (Node vm context; globals persist across cells). Host functions: -- llm("prompt") or llm({model, prompt, system}) -> string // ask a sub-LLM -- tool("name", {arg: value, ...}) -> result // call a registered tool -- agent("name", "input") -> string // delegate to a sub-agent -- final_answer("...") // end the task with this answer -- console.log(x) // observe a value next turn -Capability failures throw RlmError; catch them with try/catch. The completion -value of the cell (its last expression) is echoed back to you."# - .to_string(), - _ => r#"Write Python (a persistent exec namespace; globals survive across cells). Host functions: -- llm("prompt") or llm(prompt, model=..., system=...) -> str # ask a sub-LLM -- tool("name", {"arg": value, ...}) -> result # call a registered tool -- agent("name", "input") -> str # delegate to a sub-agent -- final_answer("...") # end the task with this answer -- print(x) # observe a value next turn -Capability failures raise RlmError; catch them with try/except. If the last -statement of a cell is an expression, its value is echoed back to you."# - .to_string(), - } - } - - async fn set_variable(&mut self, name: &str, value: Value) -> Result<()> { - self.ensure_started().await?; - self.send_frame(json!({"op": "set_var", "name": name, "value": value})) - .await?; - let deadline = Instant::now() + STARTUP_TIMEOUT; - loop { - let frame = self.read_frame(deadline).await?; - if frame.get("op").and_then(Value::as_str) == Some("var_set") { - return Ok(()); - } - } - } - - async fn eval_cell(&mut self, code: &str, host: Arc) -> Result { - self.ensure_started().await?; - self.send_frame(json!({"op": "eval", "code": code})).await?; - let deadline = host - .deadline() - .unwrap_or_else(|| Instant::now() + DEFAULT_EXCHANGE_TIMEOUT); - - loop { - let frame = match self.read_frame(deadline).await { - Ok(frame) => frame, - Err(err) => { - // Fail closed: a cell that timed out (or broke the - // protocol) leaves the child in an unknown state. - self.kill().await; - return Err(err); - } - }; - match frame.get("op").and_then(Value::as_str) { - Some("call") => { - let call: HostCall = match serde_json::from_value( - frame.get("call").cloned().unwrap_or_default(), - ) { - Ok(call) => call, - Err(err) => { - self.send_frame(json!({ - "op": "call_result", - "ok": false, - "error": format!("malformed capability call: {err}"), - })) - .await?; - continue; - } - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - let outcome = tokio::time::timeout(remaining, host.handle(call)).await; - match outcome { - Err(_) => { - self.kill().await; - return Err(TinyAgentsError::Timeout( - "rlm cell exceeded its wall-clock timeout".to_string(), - )); - } - Ok(Ok(value)) => { - self.send_frame( - json!({"op": "call_result", "ok": true, "value": value}), - ) - .await?; - } - Ok(Err(err)) if is_fatal(&err) => { - // Policy bound tripped: kill the child rather than - // letting the script observe its own limits. - self.kill().await; - return Err(err); - } - Ok(Err(err)) => { - self.send_frame(json!({ - "op": "call_result", - "ok": false, - "error": err.to_string(), - })) - .await?; - } - } - } - Some("result") => { - let value = frame.get("value").cloned().unwrap_or(Value::Null); - return Ok(CellEval { - stdout: frame - .get("stdout") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - value: (!value.is_null()).then_some(value), - error: frame - .get("error") - .and_then(Value::as_str) - .map(str::to_string), - }); - } - _ => continue, - } - } - } - - async fn shutdown(&mut self) -> Result<()> { - if self.child.is_some() { - let _ = self.send_frame(json!({"op": "shutdown"})).await; - self.kill().await; - } - Ok(()) - } -} - -impl Drop for ExternalInterpreter { - fn drop(&mut self) { - // `kill_on_drop(true)` reaps the child if shutdown was never called. - self.child = None; - } -} diff --git a/src/rlm/interpreter/mod.rs b/src/rlm/interpreter/mod.rs deleted file mode 100644 index e6af2127..00000000 --- a/src/rlm/interpreter/mod.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Interpreter backends for the RLM runtime. -//! -//! [`RlmInterpreter`] is the pluggable execution API: the session hands a -//! backend one code cell at a time plus the shared [`RlmHostApi`] handle, and -//! the backend returns a raw [`CellEval`]. State (variables, imports, -//! definitions) persists across cells within one backend instance, so the -//! driving model can build up a workspace incrementally like a notebook. -//! -//! Two backends ship built in: -//! -//! - [`rhai_cell::RhaiInterpreter`] — the embedded Rhai engine. Hermetic: no -//! filesystem, network, or process access; the registered capability -//! functions are its entire host surface. -//! - [`external::ExternalInterpreter`] — a child process (Python, Node, or -//! any command speaking the wire protocol). The binary is configuration, -//! so embedders choose the exact interpreter (virtualenv Python, Deno, a -//! containerized runner, …). -//! -//! Construct a backend from configuration with [`build_interpreter`]. - -pub mod external; -pub mod rhai_cell; - -use std::future::Future; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use serde_json::Value; - -use super::host::RlmHostApi; -use super::types::{InterpreterSpec, RlmCancelFlag}; -use crate::error::{Result, TinyAgentsError}; - -/// The raw output of evaluating one cell, before the session merges in the -/// host-side call records and final answer. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CellEval { - /// Captured print/console output. - pub stdout: String, - /// The cell's final expression value, if any. - pub value: Option, - /// A recoverable script error (exception / runtime error). - pub error: Option, -} - -/// A pluggable code-cell execution backend. -#[async_trait] -pub trait RlmInterpreter: Send { - /// The language cells are written in (`"rhai"`, `"python"`, …), used for - /// prompt rendering and code-fence extraction. - fn language(&self) -> &str; - - /// A prompt fragment teaching the driver model how to reach the host - /// capabilities *in this language* (function signatures, examples). - fn usage_guide(&self) -> String; - - /// Sets (or replaces) a global variable visible to subsequent cells. - /// - /// Used to inject the task context (`context`) without string-splicing - /// user data into script source. - async fn set_variable(&mut self, name: &str, value: Value) -> Result<()>; - - /// Evaluates one code cell against the host. - /// - /// Returns `Ok(CellEval)` for both success and *recoverable* script - /// errors (carried in [`CellEval::error`]); returns `Err` only for fatal - /// conditions (policy limits, timeout, cancellation, a dead backend). - async fn eval_cell(&mut self, code: &str, host: Arc) -> Result; - - /// Releases backend resources (kills a child process). Idempotent. - async fn shutdown(&mut self) -> Result<()>; -} - -/// Builds the interpreter backend described by an [`InterpreterSpec`]. -/// -/// `max_operations` bounds the embedded Rhai engine; external backends are -/// bounded by the cell deadline instead (a wedged child is killed). -pub fn build_interpreter( - spec: &InterpreterSpec, - max_operations: u64, -) -> Result> { - match spec { - InterpreterSpec::Rhai => Ok(Box::new(rhai_cell::RhaiInterpreter::new(max_operations))), - InterpreterSpec::Python { binary, args } => Ok(Box::new( - external::ExternalInterpreter::python(binary.as_deref(), args.clone()), - )), - InterpreterSpec::Javascript { binary, args } => Ok(Box::new( - external::ExternalInterpreter::javascript(binary.as_deref(), args.clone()), - )), - InterpreterSpec::Command { binary, args } => Ok(Box::new( - external::ExternalInterpreter::command(binary.clone(), args.clone()), - )), - } -} - -/// How often the watcher thread wakes to observe cancellation while a -/// capability call is in flight inside the blocking bridge. -const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Why the watcher tripped a bounded bridge call. -enum BridgeStop { - Deadline, - Cancelled, -} - -/// Drives an async host-capability future to completion **synchronously**, -/// bounded by the cell deadline and the session cancel flag. -/// -/// This is the same fail-closed blocking bridge the `.ragsh` REPL uses (see -/// `repl::session::builtins`): the embedded Rhai engine is synchronous, so a -/// capability closure must block its thread — but never unboundedly. A -/// detached watcher thread races the future; if the deadline elapses or the -/// flag trips first, the future is dropped (cancelling the underlying -/// request) and a `Timeout` / `Cancelled` error is returned. -pub(super) fn bridge_block_on( - deadline: Option, - cancel: &RlmCancelFlag, - future: F, -) -> Result -where - F: Future>, -{ - if cancel.is_cancelled() { - return Err(TinyAgentsError::Cancelled); - } - if let Some(deadline) = deadline - && Instant::now() >= deadline - { - return Err(TinyAgentsError::Timeout( - "rlm cell deadline elapsed before a host capability call could start".to_string(), - )); - } - - let (tx, rx) = futures::channel::oneshot::channel::(); - let watcher_cancel = cancel.clone(); - std::thread::spawn(move || { - loop { - if tx.is_canceled() { - return; - } - if watcher_cancel.is_cancelled() { - let _ = tx.send(BridgeStop::Cancelled); - return; - } - match deadline { - Some(deadline) => { - let now = Instant::now(); - if now >= deadline { - let _ = tx.send(BridgeStop::Deadline); - return; - } - std::thread::sleep((deadline - now).min(CANCEL_POLL_INTERVAL)); - } - None => std::thread::sleep(CANCEL_POLL_INTERVAL), - } - } - }); - - match futures::executor::block_on(futures::future::select(Box::pin(future), rx)) { - futures::future::Either::Left((output, _watcher)) => output, - futures::future::Either::Right((stop, _fut)) => match stop { - Ok(BridgeStop::Cancelled) => Err(TinyAgentsError::Cancelled), - Ok(BridgeStop::Deadline) | Err(_) => Err(TinyAgentsError::Timeout( - "rlm cell deadline elapsed during a host capability call".to_string(), - )), - }, - } -} diff --git a/src/rlm/interpreter/rhai_cell.rs b/src/rlm/interpreter/rhai_cell.rs deleted file mode 100644 index 5db44784..00000000 --- a/src/rlm/interpreter/rhai_cell.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! The embedded Rhai interpreter backend. -//! -//! The engine is built fresh for every cell over a **persistent scope**, so -//! variables survive across cells while the capability closures always see -//! the current cell's deadline. The engine has no filesystem, network, or -//! process access — the closures registered here are its entire host -//! surface, which is what makes this backend the hermetic default. -//! -//! Capability calls block the evaluating thread through the fail-closed -//! [`bridge_block_on`](super::bridge_block_on) adapter; the session drives -//! `eval_cell` inside [`tokio::task::spawn_blocking`], so blocking here never -//! starves the async runtime driving the underlying provider I/O. - -use std::sync::{Arc, Mutex}; -use std::time::Instant; - -use async_trait::async_trait; -use rhai::{Array, Dynamic, Engine, EvalAltResult, Map, Position, Scope}; -use serde_json::Value; - -use super::{CellEval, RlmInterpreter, bridge_block_on}; -use crate::error::{Result, TinyAgentsError}; -use crate::rlm::host::{RlmHostApi, is_fatal}; -use crate::rlm::types::HostCall; - -/// Sentinel runtime-error text `on_progress` terminates a cell with when the -/// wall-clock deadline elapses mid-script. -const DEADLINE_TOKEN: &str = "rlm cell exceeded its wall-clock timeout"; - -/// Sentinel runtime-error text `on_progress` terminates a cell with when the -/// session cancel flag trips mid-script. -const CANCELLED_TOKEN: &str = "rlm cell cancelled by host"; - -/// The embedded Rhai backend. See the [module docs](self). -pub struct RhaiInterpreter { - max_operations: u64, - /// The persistent notebook scope, behind a shared handle rather than - /// owned outright by `Self`. - /// - /// `eval_cell` hands the scope to a `spawn_blocking` task. If that were a - /// bare `Scope` moved in and out via `mem::take`, dropping the - /// `eval_cell` future (a caller-side `timeout`/`select!`/abort — the - /// documented cancellation shape) would detach the blocking task and - /// silently discard the scope it was about to write back, leaving `self` - /// with the empty scope `mem::take` left behind and no indication - /// anything was lost. Keeping the scope behind `Arc>` instead - /// means a dropped future no longer owns the only copy: the orphaned - /// task still writes into the shared scope, and the mutex serializes any - /// next cell behind it rather than starting from empty. A poisoned lock - /// (the blocking closure panicked mid-eval) is treated as an - /// unrecoverable session error instead of silently falling back to an - /// empty namespace. - scope: Arc>>, -} - -impl RhaiInterpreter { - /// Creates a backend bounded by `max_operations` Rhai operations per cell - /// (`0` means unlimited). - pub fn new(max_operations: u64) -> Self { - Self { - max_operations, - scope: Arc::new(Mutex::new(Scope::new())), - } - } -} - -/// Shared per-cell buffers the capability closures write into. -#[derive(Default)] -struct CellState { - stdout: String, - /// A fatal host error (limit/timeout/cancel) stashed so `eval_cell` can - /// surface the precise crate error instead of its stringified form. - fatal: Option, -} - -type SharedCellState = Arc>; - -/// Dispatches one host call from a synchronous capability closure, blocking -/// through the bridge and splitting fatal from script-visible failures. -fn dispatch( - host: &Arc, - cell: &SharedCellState, - call: HostCall, -) -> std::result::Result> { - let deadline = host.deadline(); - let cancel = host.cancel_flag(); - match bridge_block_on(deadline, &cancel, host.handle(call)) { - Ok(value) => Ok(value), - Err(err) => { - let message = err.to_string(); - if is_fatal(&err) { - cell.lock().expect("cell state poisoned").fatal = Some(err); - } - Err(Box::new(EvalAltResult::ErrorRuntime( - Dynamic::from(message), - Position::NONE, - ))) - } - } -} - -// ── Dynamic ⇄ JSON conversion ─────────────────────────────────────────────── - -/// Converts a Rhai value into JSON. Opaque host types are stringified rather -/// than leaked. -pub(crate) fn dynamic_to_json(value: &Dynamic) -> Value { - if value.is_unit() { - Value::Null - } else if let Ok(b) = value.as_bool() { - Value::Bool(b) - } else if let Ok(i) = value.as_int() { - Value::from(i) - } else if let Ok(f) = value.as_float() { - serde_json::Number::from_f64(f) - .map(Value::Number) - .unwrap_or(Value::Null) - } else if let Some(s) = value.read_lock::() { - Value::String(s.to_string()) - } else if let Some(array) = value.read_lock::() { - Value::Array(array.iter().map(dynamic_to_json).collect()) - } else if let Some(map) = value.read_lock::() { - Value::Object( - map.iter() - .map(|(k, v)| (k.to_string(), dynamic_to_json(v))) - .collect(), - ) - } else { - Value::String(value.to_string()) - } -} - -/// Converts JSON into a Rhai value. -pub(crate) fn json_to_dynamic(value: &Value) -> Dynamic { - match value { - Value::Null => Dynamic::UNIT, - Value::Bool(b) => Dynamic::from(*b), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Dynamic::from(i) - } else { - Dynamic::from(n.as_f64().unwrap_or(0.0)) - } - } - Value::String(s) => Dynamic::from(s.clone()), - Value::Array(items) => { - Dynamic::from_array(items.iter().map(json_to_dynamic).collect::()) - } - Value::Object(map) => { - let mut out = Map::new(); - for (k, v) in map { - out.insert(k.clone().into(), json_to_dynamic(v)); - } - Dynamic::from_map(out) - } - } -} - -/// Builds the sandboxed engine for one cell, registering the capability -/// closures against `host` and the shared cell buffers. -fn build_engine(host: Arc, cell: SharedCellState, max_operations: u64) -> Engine { - let mut engine = Engine::new(); - engine.set_max_operations(max_operations); - - // Fail-closed mid-script enforcement: `on_progress` fires between Rhai - // statements/operations, catching runaway script loops with no host - // calls. In-flight capability calls are bounded separately by the - // blocking bridge. - let progress_host = host.clone(); - let progress_cell = cell.clone(); - engine.on_progress(move |_ops| { - if progress_host.cancel_flag().is_cancelled() { - return Some(Dynamic::from(CANCELLED_TOKEN.to_string())); - } - if progress_cell - .lock() - .expect("cell state poisoned") - .fatal - .is_some() - { - return Some(Dynamic::from(DEADLINE_TOKEN.to_string())); - } - match progress_host.deadline() { - Some(deadline) if Instant::now() >= deadline => { - Some(Dynamic::from(DEADLINE_TOKEN.to_string())) - } - _ => None, - } - }); - - // ── print / debug capture ── - let print_cell = cell.clone(); - engine.on_print(move |text| { - let mut state = print_cell.lock().expect("cell state poisoned"); - state.stdout.push_str(text); - state.stdout.push('\n'); - }); - let debug_cell = cell.clone(); - engine.on_debug(move |text, _source, _pos| { - let mut state = debug_cell.lock().expect("cell state poisoned"); - state.stdout.push_str(text); - state.stdout.push('\n'); - }); - - // ── llm(prompt) / llm(#{ model, prompt, system }) ── - let llm_host = host.clone(); - let llm_cell = cell.clone(); - engine.register_fn( - "llm", - move |prompt: &str| -> std::result::Result> { - let value = dispatch( - &llm_host, - &llm_cell, - HostCall::Llm { - model: None, - prompt: prompt.to_string(), - system: None, - }, - )?; - Ok(value.as_str().unwrap_or_default().to_string()) - }, - ); - let llm_map_host = host.clone(); - let llm_map_cell = cell.clone(); - engine.register_fn( - "llm", - move |params: Map| -> std::result::Result> { - let get = |key: &str| params.get(key).and_then(|d| d.clone().into_string().ok()); - let prompt = get("prompt").ok_or_else(|| { - Box::new(EvalAltResult::ErrorRuntime( - Dynamic::from("llm: missing `prompt`".to_string()), - Position::NONE, - )) - })?; - let value = dispatch( - &llm_map_host, - &llm_map_cell, - HostCall::Llm { - model: get("model"), - prompt, - system: get("system"), - }, - )?; - Ok(value.as_str().unwrap_or_default().to_string()) - }, - ); - - // ── tool(name) / tool(name, #{ ... }) ── - let tool_host = host.clone(); - let tool_cell = cell.clone(); - engine.register_fn( - "tool", - move |name: &str| -> std::result::Result> { - let value = dispatch( - &tool_host, - &tool_cell, - HostCall::Tool { - tool: name.to_string(), - arguments: Value::Null, - }, - )?; - Ok(json_to_dynamic(&value)) - }, - ); - let tool_args_host = host.clone(); - let tool_args_cell = cell.clone(); - engine.register_fn( - "tool", - move |name: &str, args: Map| -> std::result::Result> { - let arguments = dynamic_to_json(&Dynamic::from_map(args)); - let value = dispatch( - &tool_args_host, - &tool_args_cell, - HostCall::Tool { - tool: name.to_string(), - arguments, - }, - )?; - Ok(json_to_dynamic(&value)) - }, - ); - - // ── agent(name, input) ── - let agent_host = host.clone(); - let agent_cell = cell.clone(); - engine.register_fn( - "agent", - move |name: &str, input: &str| -> std::result::Result> { - let value = dispatch( - &agent_host, - &agent_cell, - HostCall::Agent { - agent: name.to_string(), - input: input.to_string(), - data: None, - }, - )?; - Ok(value.as_str().unwrap_or_default().to_string()) - }, - ); - - // ── final_answer(text) ── - let answer_host = host.clone(); - let answer_cell = cell.clone(); - engine.register_fn( - "final_answer", - move |text: &str| -> std::result::Result<(), Box> { - dispatch( - &answer_host, - &answer_cell, - HostCall::FinalAnswer { - answer: text.to_string(), - }, - )?; - Ok(()) - }, - ); - - engine -} - -#[async_trait] -impl RlmInterpreter for RhaiInterpreter { - fn language(&self) -> &str { - "rhai" - } - - fn usage_guide(&self) -> String { - r#"Write Rhai. Variables persist across cells. Host functions: -- llm(prompt) -> string // ask the default sub-LLM -- llm(#{ model: "name", prompt: "...", system: "..." }) -> string -- tool("name", #{ arg: value, ... }) -> result // call a registered tool -- agent("name", "input") -> string // delegate to a sub-agent -- final_answer("...") // end the task with this answer -- print(x) // observe a value in the next turn -Errors raised by capabilities can be caught with try/catch. The last -expression of a cell is echoed back to you as its value. - -Rhai syntax notes (Rhai is NOT JavaScript or Rust): -- There are NO tuples: return an array `[a, b]` or an object map `#{ k: v }`. -- Object maps are written `#{ name: value }` and indexed with `m.name` or `m["name"]`. -- Sub-arrays: `arr.extract(0..5)`; also `arr.len()`, `arr.push(x)`, `arr.filter(|x| ...)`, - `arr.map(|x| ...)`; loops: `for x in arr { ... }` and `for i in 0..n { ... }`. -- Strings concatenate with `+`; convert with `x.to_string()`; interpolate with `` `${x}` ``. -- `let` declares a mutable variable; statements end with `;`."# - .to_string() - } - - async fn set_variable(&mut self, name: &str, value: Value) -> Result<()> { - self.scope - .lock() - .map_err(|_| { - TinyAgentsError::Model( - "rlm rhai interpreter scope poisoned by a previous panic".to_string(), - ) - })? - .set_value(name.to_string(), json_to_dynamic(&value)); - Ok(()) - } - - async fn eval_cell(&mut self, code: &str, host: Arc) -> Result { - let cell: SharedCellState = Arc::new(Mutex::new(CellState::default())); - let engine = build_engine(host, cell.clone(), self.max_operations); - let scope = self.scope.clone(); - let code = code.to_string(); - - // Rhai is synchronous and the capability closures block through the - // bridge, so evaluate on the blocking pool to keep the async runtime - // (which drives the actual provider I/O) responsive. - // - // The scope is locked *inside* the blocking closure (not moved out of - // `self` via `mem::take`) so a caller that drops this `eval_cell` - // future — a `timeout`/`select!`/abort around `RlmSession::eval` or - // `RlmRunner::run` — never leaves `self.scope` empty: the orphaned - // blocking task still holds the only route back to the scope and - // still writes its updates into it before the lock releases. - let eval = tokio::task::spawn_blocking(move || { - // A poisoned lock means an earlier cell's blocking task panicked - // while holding the scope: surface that as a distinct outcome - // rather than silently continuing on a scope whose consistency - // is no longer guaranteed. - match scope.lock() { - Ok(mut guard) => Ok(engine.eval_with_scope::(&mut guard, &code)), - Err(_poisoned) => Err(()), - } - }) - .await - .map_err(|err| TinyAgentsError::Model(format!("rlm rhai eval task failed: {err}")))? - .map_err(|()| { - TinyAgentsError::Model( - "rlm rhai interpreter state lost: a previous cell panicked while holding the \ - notebook scope" - .to_string(), - ) - })?; - - let mut state = cell.lock().expect("cell state poisoned"); - if let Some(fatal) = state.fatal.take() { - return Err(fatal); - } - let stdout = std::mem::take(&mut state.stdout); - drop(state); - - match eval { - Ok(value) => { - let json = dynamic_to_json(&value); - Ok(CellEval { - stdout, - value: (!json.is_null()).then_some(json), - error: None, - }) - } - Err(err) => { - let message = err.to_string(); - if message.contains(DEADLINE_TOKEN) { - return Err(TinyAgentsError::Timeout(DEADLINE_TOKEN.to_string())); - } - if message.contains(CANCELLED_TOKEN) { - return Err(TinyAgentsError::Cancelled); - } - if matches!(*err, EvalAltResult::ErrorTooManyOperations(_)) { - return Err(TinyAgentsError::LimitExceeded( - "rlm cell exceeded the operation limit".to_string(), - )); - } - Ok(CellEval { - stdout, - value: None, - error: Some(message), - }) - } - } - } - - async fn shutdown(&mut self) -> Result<()> { - Ok(()) - } -} diff --git a/src/rlm/mod.rs b/src/rlm/mod.rs deleted file mode 100644 index 2024b48a..00000000 --- a/src/rlm/mod.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Recursive-language-model (RLM) runtime: a driver model writes code cells -//! executed in a sandboxed interpreter whose only host surface is capability -//! calls back into the [`CapabilityRegistry`](crate::registry::CapabilityRegistry) -//! — sub-LLM queries, tools, and sub-agent delegation. Scripts can therefore -//! *recursively* call language models, which is what turns a code sandbox -//! into an RLM. -//! -//! ## The three layers -//! -//! 1. **[`RlmInterpreter`]** — the pluggable execution API ("the interpreter -//! exposed as an API"). Built-ins: the embedded Rhai engine (hermetic -//! sandbox, the default) and external Python / JavaScript processes -//! (binary + args are configuration; they speak a line-delimited JSON -//! wire protocol, see [`interpreter::external`]). -//! 2. **[`RlmSession`]** — one interpreter bound to one [`RlmHost`], with -//! every [`RlmPolicy`] limit enforced fail-closed per cell. Drive it -//! directly when embedding your own loop. -//! 3. **[`RlmRunner`]** — the model-driven loop: render a template into a -//! system prompt, let the driver model emit fenced code cells, execute, -//! feed observations back, stop on `final_answer(...)`. -//! -//! Everything a run needs is describable as one serde document -//! ([`RlmConfig`]), so external harnesses can define RLM behaviors as -//! configuration rather than code. -//! -//! ```no_run -//! use std::sync::Arc; -//! use tinyagents::registry::CapabilityRegistry; -//! use tinyagents::rlm::{RlmConfig, RlmRunner}; -//! -//! # async fn demo() -> tinyagents::Result<()> { -//! let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); -//! // registry.register_model("openai", Arc::new(model))?; … -//! let config = RlmConfig::from_json( -//! r#"{ "interpreter": {"kind": "rhai"}, "template": "general" }"#, -//! )?; -//! let mut runner = RlmRunner::from_config(config, Arc::new(registry), Arc::new(()))?; -//! let outcome = runner.run("How many primes are there below 1000?").await?; -//! println!("{:?}", outcome.answer); -//! # Ok(()) } -//! ``` - -mod host; -pub mod interpreter; -mod runner; -mod session; -pub mod templates; -mod types; - -#[cfg(test)] -mod test; - -pub use host::{CapabilityListing, RlmHost, RlmHostApi, is_fatal}; -pub use interpreter::{CellEval, RlmInterpreter, build_interpreter}; -pub use runner::{RlmRunner, extract_code_cell}; -pub use session::RlmSession; -pub use types::{ - CellOutcome, HostCall, InterpreterSpec, RlmCallKind, RlmCallRecord, RlmCancelFlag, RlmConfig, - RlmOutcome, RlmPolicy, RlmStep, RlmStopReason, RlmTemplate, TemplateSpec, -}; diff --git a/src/rlm/runner.rs b/src/rlm/runner.rs deleted file mode 100644 index a8616cde..00000000 --- a/src/rlm/runner.rs +++ /dev/null @@ -1,251 +0,0 @@ -//! The model-driven RLM loop: a driver model writes code cells, the session -//! executes them, and the observations flow back until the script (or the -//! model) produces a final answer. -//! -//! The loop deliberately drives [`ChatModel::invoke`] directly instead of -//! going through `AgentHarness`: the "tool" here is the whole sandboxed -//! interpreter, whose feedback protocol (code fence in, observation out) is -//! the RLM contract rather than a JSON tool call. - -use std::sync::Arc; - -use serde_json::Value; - -use super::host::{RlmHost, RlmHostApi}; -use super::session::RlmSession; -use super::templates; -use super::types::{RlmConfig, RlmOutcome, RlmStep, RlmStopReason}; -use crate::error::{Result, TinyAgentsError}; -use crate::harness::message::Message; -use crate::harness::model::ModelRequest; -use crate::registry::CapabilityRegistry; - -/// Extracts the first fenced code block from a model reply. -/// -/// Accepts ```` ``` ````, a bare ```` ``` ````, or any other fence -/// info string (models occasionally mislabel the language); returns `None` -/// when the reply contains no complete fence. -pub fn extract_code_cell(reply: &str) -> Option { - let fence_start = reply.find("```")?; - let after_fence = &reply[fence_start + 3..]; - let newline = after_fence.find('\n')?; - let body = &after_fence[newline + 1..]; - let fence_end = body.find("```")?; - let code = body[..fence_end].trim_end(); - (!code.trim().is_empty()).then(|| code.to_string()) -} - -/// Renders a cell outcome as the observation message fed back to the driver. -fn render_observation(outcome: &super::types::CellOutcome) -> String { - let mut out = String::new(); - if !outcome.stdout.is_empty() { - out.push_str("stdout:\n"); - out.push_str(&outcome.stdout); - if !outcome.stdout.ends_with('\n') { - out.push('\n'); - } - } - if let Some(value) = &outcome.value { - out.push_str(&format!("value: {value}\n")); - } - if let Some(error) = &outcome.error { - out.push_str(&format!("error: {error}\n")); - } - if out.is_empty() { - out.push_str("(cell produced no output)\n"); - } - out.push_str("Continue. Reply with the next code cell, or call final_answer(...) when done."); - out -} - -/// The model-driven RLM runner. Construct with [`RlmRunner::from_config`], -/// optionally inject a context, then [`run`](RlmRunner::run) a task. -pub struct RlmRunner { - registry: Arc>, - config: RlmConfig, - session: RlmSession, - driver_model: String, - system_prompt: String, -} - -impl RlmRunner { - /// Builds a runner from a config document, a capability registry, and the - /// application state capability calls run against. - pub fn from_config( - config: RlmConfig, - registry: Arc>, - state: Arc, - ) -> Result { - let driver_model = config - .driver_model - .clone() - .or_else(|| { - registry - .names(crate::registry::ComponentKind::Model) - .into_iter() - .next() - }) - .ok_or_else(|| { - TinyAgentsError::Validation( - "rlm: no driver model configured and no model registered".to_string(), - ) - })?; - let sub_model = config - .sub_model - .clone() - .unwrap_or_else(|| driver_model.clone()); - let host = Arc::new( - RlmHost::new(registry.clone(), state) - .with_policy(config.policy.clone()) - .with_default_model(sub_model), - ); - let session = RlmSession::new(&config.interpreter, host)?; - - let template = templates::resolve(&config.template)?; - let system_prompt = templates::render_system_prompt( - &template, - &session.language(), - &session.usage_guide(), - &session.host().capabilities(), - &config.policy, - ); - Ok(Self { - registry, - config, - session, - driver_model, - system_prompt, - }) - } - - /// The session, for injecting variables or inspecting call counts. - pub fn session_mut(&mut self) -> &mut RlmSession { - &mut self.session - } - - /// The rendered driver system prompt (for inspection/telemetry). - pub fn system_prompt(&self) -> &str { - &self.system_prompt - } - - /// Injects the task context as the `context` variable in the sandbox. - pub async fn set_context(&mut self, context: Value) -> Result<()> { - self.session.set_variable("context", context).await - } - - /// Runs the loop for one task until a final answer or the cell budget. - pub async fn run(&mut self, task: impl Into) -> Result { - let driver = self - .registry - .model(&self.driver_model) - .ok_or_else(|| TinyAgentsError::ModelNotFound(self.driver_model.clone()))?; - let state = self.session.host().app_state(); - - let mut messages = vec![ - Message::system(self.system_prompt.clone()), - Message::user(task.into()), - ]; - let mut steps: Vec = Vec::new(); - let mut driver_calls = 0usize; - // Set after a reply with no code fence: the driver gets one nudge to - // produce a cell before its prose is accepted as the answer. Models - // occasionally emit raw, unfenced code; without the nudge that code - // would be mistaken for a final answer. - let mut nudged = false; - - let outcome = loop { - // Gate on the *session-cumulative* cell count (matching - // `RlmSession::eval`'s own enforcement, and `docs/modules/rlm`'s - // documented "counters are session-cumulative" contract) rather - // than `steps.len()`, which resets to zero on every `run()` call. - // With `steps.len()` the two checks only agreed on the very - // first run: a second `run()` on the same (long-lived, - // `&mut self`) runner would see an empty `steps`, pay for a full - // driver-model call, and only then have `self.session.eval` - // return a hard `LimitExceeded` error instead of the graceful - // `CellBudgetExhausted` outcome the same condition produces on - // the first run. - if self.session.cells_run() >= self.config.policy.max_cells { - break RlmOutcome { - answer: None, - stop_reason: RlmStopReason::CellBudgetExhausted, - steps, - driver_calls, - sub_llm_calls: 0, - tool_calls: 0, - agent_calls: 0, - }; - } - - // `driver_model` is a *registry* name; the resolved ChatModel - // already knows its provider model id, so the request leaves - // `model` unset rather than leaking the registry name upstream. - let request = ModelRequest { - messages: messages.clone(), - ..Default::default() - }; - driver_calls += 1; - let response = driver.invoke(&state, request).await?; - let reply_text = Message::Assistant(response.message.clone()).text(); - messages.push(Message::Assistant(response.message)); - - let Some(code) = extract_code_cell(&reply_text) else { - if !nudged { - nudged = true; - messages.push(Message::user( - "Your reply contained no fenced code block, so nothing was executed. \ - Reply with exactly one fenced code block, or — if you are done — call \ - final_answer(...) from code. If you truly have nothing to run, repeat \ - your final answer in plain prose.", - )); - continue; - } - // Two fence-less replies in a row: accept the prose answer. - break RlmOutcome { - answer: Some(reply_text.trim().to_string()), - stop_reason: RlmStopReason::ModelAnswered, - steps, - driver_calls, - sub_llm_calls: 0, - tool_calls: 0, - agent_calls: 0, - }; - }; - nudged = false; - - let cell = self.session.eval(&code).await?; - let answered = cell.final_answer.clone(); - let observation = render_observation(&cell); - steps.push(RlmStep { - code, - outcome: cell, - }); - - if let Some(answer) = answered { - break RlmOutcome { - answer: Some(answer), - stop_reason: RlmStopReason::Answered, - steps, - driver_calls, - sub_llm_calls: 0, - tool_calls: 0, - agent_calls: 0, - }; - } - messages.push(Message::user(observation)); - }; - - let (llm, tool, agent) = self.session.host().call_counts(); - Ok(RlmOutcome { - sub_llm_calls: llm, - tool_calls: tool, - agent_calls: agent, - ..outcome - }) - } - - /// Releases interpreter resources. - pub async fn shutdown(&mut self) -> Result<()> { - self.session.shutdown().await - } -} diff --git a/src/rlm/session.rs b/src/rlm/session.rs deleted file mode 100644 index 36608b26..00000000 --- a/src/rlm/session.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! An RLM session: one interpreter instance bound to one capability host. -//! -//! The session is the programmatic surface — "the interpreter exposed as an -//! API". Embedders that want direct cell execution (their own loop, a -//! notebook UI, a test) drive [`RlmSession::eval`] themselves; the -//! model-driven loop in [`super::runner`] is built on exactly this surface. - -use std::sync::Arc; -use std::time::Instant; - -use serde_json::Value; - -use super::host::{RlmHost, RlmHostApi}; -use super::interpreter::{RlmInterpreter, build_interpreter}; -use super::types::{CellOutcome, InterpreterSpec}; -use crate::error::{Result, TinyAgentsError}; - -/// Marker appended when captured output exceeds -/// [`RlmPolicy::max_output_bytes`] and is truncated. -const TRUNCATION_MARKER: &str = "\n… [output truncated by rlm policy]"; - -/// Truncates `s` to at most `max` bytes, walking back to the nearest UTF-8 -/// char boundary at or below `max` first. `String::truncate` panics on a -/// non-boundary byte index, and captured cell output is arbitrary text (a -/// multi-byte character can straddle the raw budget), so the cut point must -/// be found before truncating. -fn truncate_at_char_boundary(s: &mut String, max: usize) { - let mut end = max; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - s.truncate(end); -} - -/// One sandboxed script workspace: a persistent interpreter plus the -/// capability host its cells call back into. -pub struct RlmSession { - interpreter: Box, - host: Arc>, - cells_run: usize, -} - -impl RlmSession { - /// Builds a session from an interpreter spec and a configured host. - pub fn new(spec: &InterpreterSpec, host: Arc>) -> Result { - let interpreter = build_interpreter(spec, host.policy().max_operations)?; - Ok(Self { - interpreter, - host, - cells_run: 0, - }) - } - - /// Builds a session over an already-constructed interpreter backend - /// (for custom [`RlmInterpreter`] implementations). - pub fn from_interpreter( - interpreter: Box, - host: Arc>, - ) -> Self { - Self { - interpreter, - host, - cells_run: 0, - } - } - - /// The capability host this session's cells call back into. - pub fn host(&self) -> &Arc> { - &self.host - } - - /// The language cells are written in. - pub fn language(&self) -> String { - self.interpreter.language().to_string() - } - - /// The interpreter-specific capability usage guide (prompt fragment). - pub fn usage_guide(&self) -> String { - self.interpreter.usage_guide() - } - - /// Number of cells evaluated so far. - pub fn cells_run(&self) -> usize { - self.cells_run - } - - /// Injects a global variable visible to subsequent cells — the safe way - /// to hand a task context to scripts without splicing it into source. - pub async fn set_variable(&mut self, name: &str, value: Value) -> Result<()> { - self.interpreter.set_variable(name, value).await - } - - /// Evaluates one code cell, enforcing the session policy fail-closed. - pub async fn eval(&mut self, code: &str) -> Result { - let policy = self.host.policy().clone(); - if self.host.cancel_flag().is_cancelled() { - return Err(TinyAgentsError::Cancelled); - } - if self.cells_run >= policy.max_cells { - return Err(TinyAgentsError::LimitExceeded(format!( - "cell limit ({}) exceeded", - policy.max_cells - ))); - } - if code.len() > policy.max_script_bytes { - return Err(TinyAgentsError::LimitExceeded(format!( - "cell source is {} bytes, over the {}-byte limit", - code.len(), - policy.max_script_bytes - ))); - } - self.cells_run += 1; - - self.host.begin_cell(); - let start = Instant::now(); - let eval = self - .interpreter - .eval_cell(code, self.host.clone() as Arc) - .await; - let (calls, final_answer) = self.host.end_cell(); - let mut eval = eval?; - - // Bound what flows back into the driver conversation. Truncation is - // explicit (marked) so the model knows it saw a prefix. `truncate` - // cuts at a raw byte offset, so the budget is first walked back to - // the nearest UTF-8 char boundary — output is arbitrary - // model/script-authored text (CJK, emoji, accents included), and a - // multi-byte character straddling `max_output_bytes` would otherwise - // panic `String::truncate`. - if eval.stdout.len() > policy.max_output_bytes { - truncate_at_char_boundary(&mut eval.stdout, policy.max_output_bytes); - eval.stdout.push_str(TRUNCATION_MARKER); - } - if let Some(value) = &eval.value { - let rendered = value.to_string(); - if rendered.len() > policy.max_output_bytes { - let mut clipped = rendered; - truncate_at_char_boundary(&mut clipped, policy.max_output_bytes); - clipped.push_str(TRUNCATION_MARKER); - eval.value = Some(Value::String(clipped)); - } - } - - Ok(CellOutcome { - stdout: eval.stdout, - value: eval.value, - error: eval.error, - calls, - final_answer, - elapsed: start.elapsed(), - }) - } - - /// Releases interpreter resources (kills an external child process). - pub async fn shutdown(&mut self) -> Result<()> { - self.interpreter.shutdown().await - } -} diff --git a/src/rlm/templates.rs b/src/rlm/templates.rs deleted file mode 100644 index 95f1f518..00000000 --- a/src/rlm/templates.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Built-in prompt templates for the RLM driver model, and the placeholder -//! renderer. -//! -//! A template is a plain [`RlmTemplate`] document, so external harnesses can -//! ship their own as JSON and select them through -//! [`TemplateSpec::Inline`](super::types::TemplateSpec). The built-ins cover -//! the three recurring RLM shapes: -//! -//! - **`general`** — solve a task with code, calling capabilities as needed. -//! - **`context-explorer`** — the recursive-LM pattern from the RLM -//! literature: a context too large to read at once is injected as the -//! `context` variable, and the model probes it programmatically (slice, -//! search, summarize slices via sub-LLM calls) instead of reading it all. -//! - **`orchestrator`** — decompose the task and delegate the pieces to -//! registered sub-agents, then synthesize. - -use super::host::CapabilityListing; -use super::types::{RlmPolicy, RlmTemplate, TemplateSpec}; -use crate::error::{Result, TinyAgentsError}; - -/// Shared preamble describing the cell loop contract. -const LOOP_CONTRACT: &str = r#"You are operating a sandboxed code notebook. Every reply MUST contain exactly -one fenced code block, opened with ```{{language}} and closed with ``` — code -outside a fence is never executed. The host executes the block and shows you -the captured output, the cell's value, and any error; variables persist -between cells. Keep cells small and observe intermediate results -instead of writing one giant script. When you have the answer, call -final_answer("...") from code. Never fabricate outputs you have not observed. - -{{usage}} - -Available capabilities: -{{capabilities}} - -Resource limits (exceeding them aborts the run): -{{limits}}"#; - -/// The built-in `general` template. -pub fn general() -> RlmTemplate { - RlmTemplate { - name: "general".to_string(), - system_prompt: format!( - "{LOOP_CONTRACT}\n\nSolve the user's task with code. Use sub-LLM calls for fuzzy \ - subproblems (summarization, extraction, judgment) and plain code for exact ones \ - (counting, filtering, arithmetic)." - ), - } -} - -/// The built-in `context-explorer` template (recursive-LM context probing). -pub fn context_explorer() -> RlmTemplate { - RlmTemplate { - name: "context-explorer".to_string(), - system_prompt: format!( - "{LOOP_CONTRACT}\n\nA variable named `context` holds material that is too large to \ - read in one glance. NEVER print all of it. Probe it programmatically: inspect its \ - length and structure first, then slice/search it, and delegate fuzzy analysis of \ - individual chunks to sub-LLM calls (`llm`). Combine the per-chunk findings with \ - code, then answer." - ), - } -} - -/// The built-in `orchestrator` template (sub-agent delegation). -pub fn orchestrator() -> RlmTemplate { - RlmTemplate { - name: "orchestrator".to_string(), - system_prompt: format!( - "{LOOP_CONTRACT}\n\nYou are an orchestrator. Decompose the task into independent \ - pieces, delegate each to the most suitable registered agent with `agent(name, \ - input)`, inspect their replies, iterate if a piece came back weak, and synthesize \ - the final answer yourself." - ), - } -} - -/// Resolves a [`TemplateSpec`] to a concrete template. -pub fn resolve(spec: &TemplateSpec) -> Result { - match spec { - TemplateSpec::Inline(template) => Ok(template.clone()), - TemplateSpec::Named(name) => match name.as_str() { - "general" => Ok(general()), - "context-explorer" => Ok(context_explorer()), - "orchestrator" => Ok(orchestrator()), - other => Err(TinyAgentsError::Validation(format!( - "unknown rlm template `{other}` (built-ins: general, context-explorer, \ - orchestrator)" - ))), - }, - } -} - -/// Renders a template's system prompt, substituting the documented -/// placeholders. -pub fn render_system_prompt( - template: &RlmTemplate, - language: &str, - usage: &str, - capabilities: &CapabilityListing, - policy: &RlmPolicy, -) -> String { - template - .system_prompt - .replace("{{language}}", language) - .replace("{{usage}}", usage) - .replace("{{capabilities}}", &render_capabilities(capabilities)) - .replace("{{limits}}", &render_limits(policy)) -} - -fn render_capabilities(listing: &CapabilityListing) -> String { - let mut out = String::new(); - if listing.models.is_empty() { - out.push_str("- models: (none registered)\n"); - } else { - out.push_str(&format!("- models: {}\n", listing.models.join(", "))); - } - if listing.tools.is_empty() { - out.push_str("- tools: (none registered)\n"); - } else { - out.push_str("- tools:\n"); - for (name, description) in &listing.tools { - if description.is_empty() { - out.push_str(&format!(" - {name}\n")); - } else { - out.push_str(&format!(" - {name}: {description}\n")); - } - } - } - if listing.agents.is_empty() { - out.push_str("- agents: (none registered)"); - } else { - out.push_str(&format!("- agents: {}", listing.agents.join(", "))); - } - out -} - -fn render_limits(policy: &RlmPolicy) -> String { - let timeout = policy - .cell_timeout - .map(|t| format!("{}s", t.as_secs())) - .unwrap_or_else(|| "none".to_string()); - format!( - "- max cells: {}\n- max sub-LLM calls: {}\n- max tool calls: {}\n- max agent calls: \ - {}\n- per-cell timeout: {timeout}", - policy.max_cells, policy.max_llm_calls, policy.max_tool_calls, policy.max_agent_calls - ) -} diff --git a/src/rlm/test.rs b/src/rlm/test.rs deleted file mode 100644 index 27f24945..00000000 --- a/src/rlm/test.rs +++ /dev/null @@ -1,508 +0,0 @@ -//! Module-local unit tests for the RLM runtime: config serialization, -//! template rendering, code-fence extraction, and the embedded-Rhai session -//! against deterministic capability doubles. - -use std::sync::Arc; -use std::time::Duration; - -use serde_json::json; - -use super::*; -use crate::harness::testkit::{FakeTool, ScriptedModel, SlowModel}; -use crate::registry::CapabilityRegistry; - -fn registry_with_mock(replies: Vec<&str>) -> Arc> { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(replies))) - .expect("register model"); - registry - .register_tool(Arc::new(FakeTool::returning("echo", "echoed"))) - .expect("register tool"); - Arc::new(registry) -} - -fn rhai_session(registry: Arc>, policy: RlmPolicy) -> RlmSession<()> { - let host = Arc::new( - RlmHost::new(registry, Arc::new(())) - .with_policy(policy) - .with_default_model("mock"), - ); - RlmSession::new(&InterpreterSpec::Rhai, host).expect("build session") -} - -// ── Config round-trips ────────────────────────────────────────────────────── - -#[test] -fn config_round_trips_through_json() { - let config = RlmConfig { - interpreter: InterpreterSpec::Python { - binary: Some("python3".to_string()), - args: vec![], - }, - driver_model: Some("openai".to_string()), - sub_model: None, - policy: RlmPolicy::default(), - template: TemplateSpec::Named("context-explorer".to_string()), - }; - let json = config.to_json().expect("serialize"); - let back = RlmConfig::from_json(&json).expect("parse"); - assert_eq!(config, back); -} - -#[test] -fn minimal_config_document_parses_with_defaults() { - let config = RlmConfig::from_json(r#"{ "interpreter": {"kind": "rhai"} }"#).expect("parse"); - assert_eq!(config.interpreter, InterpreterSpec::Rhai); - assert_eq!(config.template, TemplateSpec::Named("general".to_string())); - assert_eq!(config.policy, RlmPolicy::default()); -} - -#[test] -fn host_call_wire_shape_is_stable() { - let call: HostCall = serde_json::from_value(json!({ - "capability": "llm", - "prompt": "hi", - "model": null, - "system": null, - })) - .expect("parse llm call"); - assert_eq!( - call, - HostCall::Llm { - model: None, - prompt: "hi".to_string(), - system: None - } - ); - let call: HostCall = serde_json::from_value(json!({ - "capability": "tool", - "tool": "echo", - })) - .expect("parse tool call without arguments"); - assert!(matches!(call, HostCall::Tool { arguments, .. } if arguments.is_null())); -} - -// ── Code-fence extraction ─────────────────────────────────────────────────── - -#[test] -fn extracts_fenced_code_and_rejects_prose() { - assert_eq!( - extract_code_cell("Let me try:\n```rhai\nlet x = 1;\nx\n```\nDone."), - Some("let x = 1;\nx".to_string()) - ); - assert_eq!( - extract_code_cell("```\nprint(1)\n```"), - Some("print(1)".to_string()) - ); - assert_eq!(extract_code_cell("no code here"), None); - assert_eq!(extract_code_cell("unterminated ```python\nprint(1)"), None); - assert_eq!(extract_code_cell("```rhai\n\n```"), None); -} - -// ── Template rendering ────────────────────────────────────────────────────── - -#[test] -fn renders_placeholders_into_the_system_prompt() { - let listing = CapabilityListing { - models: vec!["mock".to_string()], - tools: vec![("echo".to_string(), "Echoes.".to_string())], - agents: vec!["helper".to_string()], - }; - let prompt = templates::render_system_prompt( - &templates::general(), - "rhai", - "USAGE GUIDE", - &listing, - &RlmPolicy::default(), - ); - assert!(prompt.contains("```rhai")); - assert!(prompt.contains("USAGE GUIDE")); - assert!(prompt.contains("echo: Echoes.")); - assert!(prompt.contains("helper")); - assert!(!prompt.contains("{{")); -} - -#[test] -fn unknown_named_template_fails_closed() { - let err = templates::resolve(&TemplateSpec::Named("nope".to_string())); - assert!(err.is_err()); -} - -// ── Embedded Rhai session ─────────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rhai_cell_evaluates_and_persists_variables() { - let mut session = rhai_session(registry_with_mock(vec!["unused"]), RlmPolicy::default()); - let outcome = session.eval("let x = 21; x").await.expect("cell 1"); - assert_eq!(outcome.value, Some(json!(21))); - let outcome = session.eval("x * 2").await.expect("cell 2"); - assert_eq!(outcome.value, Some(json!(42))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rhai_cell_calls_llm_tool_and_final_answer() { - let mut session = rhai_session( - registry_with_mock(vec!["sub-model says hi"]), - RlmPolicy::default(), - ); - let outcome = session - .eval( - r#" - let reply = llm("hello?"); - print(reply); - let echoed = tool("echo", #{ q: 7 }); - final_answer(reply); - "#, - ) - .await - .expect("cell"); - assert!(outcome.stdout.contains("sub-model says hi")); - assert_eq!(outcome.final_answer.as_deref(), Some("sub-model says hi")); - assert_eq!(outcome.calls.len(), 3); - assert_eq!(outcome.calls[0].kind, RlmCallKind::Llm); - assert_eq!(outcome.calls[1].kind, RlmCallKind::Tool); - assert_eq!(outcome.calls[2].kind, RlmCallKind::FinalAnswer); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn script_error_is_recoverable_not_fatal() { - let mut session = rhai_session(registry_with_mock(vec![]), RlmPolicy::default()); - let outcome = session.eval("this is not rhai ][").await.expect("cell"); - assert!(outcome.error.is_some()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unknown_tool_error_is_catchable_in_script() { - let mut session = rhai_session(registry_with_mock(vec![]), RlmPolicy::default()); - let outcome = session - .eval(r#"try { tool("missing") } catch (e) { print("caught: " + e); } "ok""#) - .await - .expect("cell"); - assert!(outcome.stdout.contains("caught")); - assert_eq!(outcome.value, Some(json!("ok"))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn llm_call_limit_is_fatal_and_aborts_the_cell() { - let policy = RlmPolicy { - max_llm_calls: 1, - ..RlmPolicy::default() - }; - let mut session = rhai_session(registry_with_mock(vec!["one", "two"]), policy); - let err = session - .eval(r#"llm("first"); llm("second")"#) - .await - .expect_err("limit must abort"); - assert!( - matches!(err, crate::error::TinyAgentsError::LimitExceeded(_)), - "got {err:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cell_budget_and_script_size_fail_closed() { - let policy = RlmPolicy { - max_cells: 1, - max_script_bytes: 16, - ..RlmPolicy::default() - }; - let mut session = rhai_session(registry_with_mock(vec![]), policy); - let err = session - .eval("1 + 1 + 1 + 1 + 1 + 1 + 1") - .await - .expect_err("script too large"); - assert!(matches!( - err, - crate::error::TinyAgentsError::LimitExceeded(_) - )); - session.eval("1").await.expect("first small cell"); - let err = session.eval("2").await.expect_err("cell budget"); - assert!(matches!( - err, - crate::error::TinyAgentsError::LimitExceeded(_) - )); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oversized_stdout_is_truncated_with_a_marker() { - let policy = RlmPolicy { - max_output_bytes: 64, - ..RlmPolicy::default() - }; - let mut session = rhai_session(registry_with_mock(vec![]), policy); - let outcome = session - .eval(r#"for i in 0..100 { print("aaaaaaaaaaaaaaaaaaaaaaaa"); }"#) - .await - .expect("cell"); - assert!(outcome.stdout.len() < 200); - assert!(outcome.stdout.contains("truncated")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oversized_multibyte_stdout_is_truncated_without_panicking() { - // Regression test: `String::truncate` panics unless the cut index is a - // UTF-8 char boundary. `max_output_bytes: 64` is not a multiple of the - // 3-byte-wide "日" character printed below, so the naive raw-byte cut - // used to panic partway through evaluating the cell. - let policy = RlmPolicy { - max_output_bytes: 64, - ..RlmPolicy::default() - }; - let mut session = rhai_session(registry_with_mock(vec![]), policy); - let outcome = session - .eval(r#"for i in 0..100 { print("日日日日日日日日日日"); }"#) - .await - .expect("cell must not panic on a multi-byte truncation boundary"); - assert!(outcome.stdout.contains("truncated")); - // The truncated prefix must itself still be valid UTF-8 (no half-cut - // multi-byte character), which `String::truncate` guarantees once the - // cut lands on a char boundary. - assert!(std::str::from_utf8(outcome.stdout.as_bytes()).is_ok()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn oversized_multibyte_value_is_truncated_without_panicking() { - // Same boundary hazard as stdout, but for the rendered cell value. - let policy = RlmPolicy { - max_output_bytes: 64, - ..RlmPolicy::default() - }; - let mut session = rhai_session(registry_with_mock(vec![]), policy); - let outcome = session - .eval(r#"let s = ""; for i in 0..100 { s += "日"; } s"#) - .await - .expect("cell must not panic on a multi-byte truncation boundary"); - let value = outcome.value.expect("truncated value"); - let text = value.as_str().expect("string value"); - assert!(text.contains("truncated")); - assert!(std::str::from_utf8(text.as_bytes()).is_ok()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn context_variable_is_visible_to_scripts() { - let mut session = rhai_session(registry_with_mock(vec![]), RlmPolicy::default()); - session - .set_variable("context", json!({"words": ["alpha", "beta"]})) - .await - .expect("set context"); - let outcome = session.eval("context.words[1]").await.expect("cell"); - assert_eq!(outcome.value, Some(json!("beta"))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn dropping_a_cell_future_does_not_lose_the_persistent_scope() { - // Regression test: `eval_cell` used to `mem::take` the scope out of - // `self` and only restore it after its `spawn_blocking` task joined. A - // caller that drops the `eval_cell` future mid-flight — the documented - // `tokio::time::timeout`/`select!`/task-abort cancellation shape — left - // `self.scope` permanently empty, since the detached blocking task's - // `(scope, result)` was discarded along with the dropped future. Keeping - // the scope behind `Arc>` means the orphaned task still writes - // its updates into the *shared* scope, so a later cell still sees them. - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model( - "mock", - Arc::new(SlowModel::new(Duration::from_millis(150), "done")), - ) - .expect("register model"); - let mut session = rhai_session(Arc::new(registry), RlmPolicy::default()); - - // `x` is assigned before the script blocks on the slow `llm(...)` call, - // so the assignment has already happened by the time this future is - // dropped. - let cancelled = tokio::time::timeout( - Duration::from_millis(20), - session.eval(r#"let x = 42; llm("wait"); x"#), - ) - .await; - assert!( - cancelled.is_err(), - "the timeout must fire before the slow llm() call resolves" - ); - - // Give the orphaned blocking task time to actually finish the call and - // write the scope back. - tokio::time::sleep(Duration::from_millis(300)).await; - - let outcome = session.eval("x").await.expect("second cell"); - assert_eq!(outcome.value, Some(json!(42))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_panicked_cell_poisons_the_scope_instead_of_silently_emptying_it() { - // The other half of the scope-loss defect: a blocking closure that - // panics mid-eval (holding the scope lock) must not leave the - // interpreter usable-but-amnesiac. The mutex poisons, and every - // subsequent cell must fail loudly with a clear diagnostic rather than - // silently running against an empty namespace. - struct PanicModel; - - #[async_trait::async_trait] - impl crate::harness::model::ChatModel<()> for PanicModel { - async fn invoke( - &self, - _state: &(), - _request: crate::harness::model::ModelRequest, - ) -> crate::error::Result { - panic!("simulated provider panic while holding the rlm scope"); - } - } - - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(PanicModel)) - .expect("register model"); - let mut session = rhai_session(Arc::new(registry), RlmPolicy::default()); - - let first = session.eval(r#"let x = 1; llm("boom")"#).await; - assert!( - first.is_err(), - "the panicking cell must fail, not silently succeed" - ); - - let second = session - .eval("x") - .await - .expect_err("the scope is poisoned, so the next cell must fail loudly"); - assert!( - matches!(&second, crate::error::TinyAgentsError::Model(msg) if msg.contains("interpreter state lost")), - "got {second:?}" - ); -} - -// ── The model-driven runner ───────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runner_loops_until_final_answer() { - // Cell 1 computes and prints; cell 2 answers with the observed value. - let registry = registry_with_mock(vec![ - "Let me compute.\n```rhai\nlet x = 6 * 7;\nprint(x);\nx\n```", - "Now I know.\n```rhai\nfinal_answer(\"the answer is 42\")\n```", - ]); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - ..RlmConfig::default() - }; - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("build runner"); - let outcome = runner.run("multiply 6 by 7").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("the answer is 42")); - assert_eq!(outcome.stop_reason, RlmStopReason::Answered); - assert_eq!(outcome.steps.len(), 2); - assert_eq!(outcome.driver_calls, 2); - assert!(outcome.steps[0].outcome.stdout.contains("42")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runner_nudges_once_then_accepts_prose_as_the_answer() { - // A fence-less reply first earns a nudge (it may be unfenced code, not an - // answer); only a second fence-less reply is accepted as prose. - let registry = registry_with_mock(vec!["The answer is 4.", "The answer is 4."]); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - ..RlmConfig::default() - }; - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("build runner"); - let outcome = runner.run("what is 2+2?").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("The answer is 4.")); - assert_eq!(outcome.stop_reason, RlmStopReason::ModelAnswered); - assert_eq!(outcome.driver_calls, 2); - assert!(outcome.steps.is_empty()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runner_recovers_a_cell_after_a_nudge() { - // Unfenced code first (would previously have been mistaken for an - // answer), fenced after the nudge, then a final answer. - let registry = registry_with_mock(vec![ - "let x = 6 * 7; x", - "```rhai\nlet x = 6 * 7;\nx\n```", - "```rhai\nfinal_answer(\"42\")\n```", - ]); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - ..RlmConfig::default() - }; - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("build runner"); - let outcome = runner.run("multiply 6 by 7").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("42")); - assert_eq!(outcome.stop_reason, RlmStopReason::Answered); - assert_eq!(outcome.steps.len(), 2); - assert_eq!(outcome.driver_calls, 3); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runner_stops_at_the_cell_budget() { - // The driver keeps emitting cells and never answers. - let cells: Vec<&str> = vec!["```rhai\n1\n```"; 4]; - let registry = registry_with_mock(cells); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - policy: RlmPolicy { - max_cells: 2, - ..RlmPolicy::default() - }, - ..RlmConfig::default() - }; - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("build runner"); - let outcome = runner.run("loop forever").await.expect("run"); - assert_eq!(outcome.answer, None); - assert_eq!(outcome.stop_reason, RlmStopReason::CellBudgetExhausted); - assert_eq!(outcome.steps.len(), 2); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_second_run_after_the_cell_budget_stops_gracefully_instead_of_erroring() { - // Regression test: `RlmRunner::run` used to gate its loop on - // `steps.len()`, a per-call counter that resets to zero on every `run()` - // call, while `RlmSession::eval` enforces `max_cells` against its own - // session-cumulative `cells_run` counter that nothing ever reset. The two - // checks agreed only on the first call: a second `run()` on the same - // (long-lived, `&mut self`) runner — legal, and a natural thing to do — - // saw an empty `steps`, paid for a driver-model call it didn't need, and - // then hit `self.session.eval`'s hard `LimitExceeded` error instead of - // the graceful `CellBudgetExhausted` outcome the identical condition - // produces on the first run. - let cells: Vec<&str> = vec!["```rhai\n1\n```"; 4]; - let registry = registry_with_mock(cells); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - policy: RlmPolicy { - max_cells: 2, - ..RlmPolicy::default() - }, - ..RlmConfig::default() - }; - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("build runner"); - - let first = runner.run("loop forever").await.expect("first run"); - assert_eq!(first.stop_reason, RlmStopReason::CellBudgetExhausted); - assert_eq!(first.steps.len(), 2); - - let second = runner - .run("try again") - .await - .expect("a second run must stop gracefully, not return a hard error"); - assert_eq!(second.stop_reason, RlmStopReason::CellBudgetExhausted); - // No cells executed (the budget was already spent) and no driver call - // wasted producing one that would only be rejected. - assert_eq!(second.steps.len(), 0); - assert_eq!(second.driver_calls, 0); -} - -// ── Cancellation ──────────────────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn pre_cancelled_session_refuses_cells() { - let cancel = RlmCancelFlag::new(); - cancel.cancel(); - let host = Arc::new( - RlmHost::new(registry_with_mock(vec![]), Arc::new(())) - .with_default_model("mock") - .with_cancel_flag(cancel), - ); - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host).expect("session"); - let err = session.eval("1").await.expect_err("must refuse"); - assert!(matches!(err, crate::error::TinyAgentsError::Cancelled)); -} diff --git a/src/rlm/types.rs b/src/rlm/types.rs deleted file mode 100644 index 7fcada71..00000000 --- a/src/rlm/types.rs +++ /dev/null @@ -1,420 +0,0 @@ -//! Public data types for the recursive-language-model (RLM) runtime. -//! -//! Everything here is `serde`-serializable on purpose: an RLM run is meant to -//! be **config-driven**, so an external harness (a CLI, a service, another -//! agent runtime) can describe an entire run — interpreter choice, resource -//! policy, prompt template — as a JSON document and hand it to -//! [`RlmConfig::from_json`]. -//! -//! Logic lives in the sibling modules: the host capability boundary in -//! [`super::host`], the interpreter backends in [`super::interpreter`], the -//! session in [`super::session`], and the model-driven loop in -//! [`super::runner`]. - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::error::{Result, TinyAgentsError}; - -// ── Interpreter selection ─────────────────────────────────────────────────── - -/// Which interpreter executes the code cells of an RLM session. -/// -/// The embedded [`Rhai`](InterpreterSpec::Rhai) engine is the default and the -/// only *hermetically* sandboxed choice: it has no filesystem, network, or -/// process access — the capability functions registered by the host are its -/// entire world. External interpreters run as a child **process** provided by -/// the embedding application (the binary and args are configuration, exactly -/// so a harness can point at a virtualenv Python, a Deno binary, a container -/// entrypoint, …); they speak the line-delimited JSON wire protocol described -/// in [`super::interpreter::external`]. The host still enforces every -/// [`RlmPolicy`] limit fail-closed (killing the child on violation), but the -/// child process itself has whatever OS access the embedder's environment -/// grants it — isolate it externally (container, seccomp, jail) when running -/// untrusted models. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum InterpreterSpec { - /// The embedded Rhai engine (feature-default, hermetic sandbox). - #[default] - Rhai, - /// An external CPython-compatible interpreter. - Python { - /// The interpreter binary (defaults to `python3` on `PATH`). - #[serde(default, skip_serializing_if = "Option::is_none")] - binary: Option, - /// Extra arguments placed before the bootstrap `-c` program. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - args: Vec, - }, - /// An external Node.js-compatible JavaScript interpreter. - Javascript { - /// The interpreter binary (defaults to `node` on `PATH`). - #[serde(default, skip_serializing_if = "Option::is_none")] - binary: Option, - /// Extra arguments placed before the bootstrap `-e` program. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - args: Vec, - }, - /// An arbitrary command that already speaks the RLM wire protocol on its - /// stdin/stdout (for embedders that ship their own runner, e.g. a - /// container image or a jailed interpreter). - Command { - /// The command binary. - binary: String, - /// Arguments passed verbatim. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - args: Vec, - }, -} - -impl InterpreterSpec { - /// The language name scripts are written in, used in prompts and code - /// fence extraction (```rhai / ```python / ```javascript). - pub fn language(&self) -> &'static str { - match self { - InterpreterSpec::Rhai => "rhai", - InterpreterSpec::Python { .. } => "python", - InterpreterSpec::Javascript { .. } => "javascript", - InterpreterSpec::Command { .. } => "python", - } - } -} - -// ── Policy ────────────────────────────────────────────────────────────────── - -/// Resource limits bounding an RLM session and its model-driven loop. -/// -/// Every limit is enforced **fail closed**: exceeding a bound aborts the cell -/// (and, for an external interpreter, kills the child process) instead of -/// silently truncating or running unbounded work. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default)] -pub struct RlmPolicy { - /// Maximum code cells a session may execute, cumulative across every - /// [`super::RlmRunner::run`] call on the same runner/session (matching - /// [`super::RlmSession::eval`]'s own enforcement) — not reset per call. - pub max_cells: usize, - /// Maximum source size, in bytes, of a single cell. - pub max_script_bytes: usize, - /// Maximum captured stdout + value size, in bytes, per cell. - pub max_output_bytes: usize, - /// Maximum sub-LLM (`llm`) calls per session. - pub max_llm_calls: usize, - /// Maximum `tool` calls per session. - pub max_tool_calls: usize, - /// Maximum sub-agent (`agent`) calls per session. - pub max_agent_calls: usize, - /// Maximum recursion depth for sub-agent calls, enforced through the - /// shared harness guard - /// ([`RunConfig::checked_child_depth`](crate::harness::context::RunConfig::checked_child_depth)). - pub max_depth: usize, - /// Wall-clock timeout per cell (script + in-flight capability calls). - #[serde(with = "humantime_millis")] - pub cell_timeout: Option, - /// Maximum Rhai operations per cell (embedded interpreter only; `0` - /// means unlimited). - pub max_operations: u64, -} - -impl Default for RlmPolicy { - fn default() -> Self { - Self { - max_cells: 16, - max_script_bytes: 64 * 1024, - max_output_bytes: 256 * 1024, - max_llm_calls: 64, - max_tool_calls: 128, - max_agent_calls: 32, - max_depth: 8, - cell_timeout: Some(Duration::from_secs(120)), - max_operations: 5_000_000, - } - } -} - -/// Serializes the optional cell timeout as integer milliseconds so an RLM -/// config is a plain JSON document (`"cell_timeout": 120000`). -mod humantime_millis { - use std::time::Duration; - - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize( - value: &Option, - serializer: S, - ) -> Result { - match value { - Some(duration) => serializer.serialize_some(&(duration.as_millis() as u64)), - None => serializer.serialize_none(), - } - } - - pub fn deserialize<'de, D: Deserializer<'de>>( - deserializer: D, - ) -> Result, D::Error> { - Ok(Option::::deserialize(deserializer)?.map(Duration::from_millis)) - } -} - -// ── Cancellation ──────────────────────────────────────────────────────────── - -/// A shared, sticky cancellation flag for an RLM session (the same contract as -/// the REPL's flag: once cancelled, the session refuses further work until a -/// fresh flag is installed). -#[derive(Clone, Debug, Default)] -pub struct RlmCancelFlag(Arc); - -impl RlmCancelFlag { - /// Creates a fresh, un-cancelled flag. - pub fn new() -> Self { - Self(Arc::new(AtomicBool::new(false))) - } - - /// Requests cancellation; idempotent, observed by every clone. - pub fn cancel(&self) { - self.0.store(true, Ordering::SeqCst); - } - - /// Returns whether cancellation has been requested. - pub fn is_cancelled(&self) -> bool { - self.0.load(Ordering::SeqCst) - } -} - -// ── The host-call boundary ────────────────────────────────────────────────── - -/// One capability call a script makes back into the host. -/// -/// This is the **entire** host surface a sandboxed script sees, across every -/// interpreter backend: the embedded Rhai closures build these values -/// directly, and the external wire protocol carries them as the `call` field -/// of a `{"op":"call"}` frame. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "capability", rename_all = "snake_case")] -pub enum HostCall { - /// A sub-LLM query (`llm(...)` in scripts). - Llm { - /// Registry model name; `None` selects the session's default model. - #[serde(default, skip_serializing_if = "Option::is_none")] - model: Option, - /// The user prompt. - prompt: String, - /// Optional system prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - system: Option, - }, - /// A tool invocation (`tool(name, args)` in scripts). - Tool { - /// Registry tool name. - tool: String, - /// JSON arguments matching the tool's schema. - #[serde(default)] - arguments: Value, - }, - /// A sub-agent delegation (`agent(name, input)` in scripts). - Agent { - /// Registry agent name. - agent: String, - /// The prompt the child run is seeded with. - input: String, - /// Optional structured side-channel payload. - #[serde(default, skip_serializing_if = "Option::is_none")] - data: Option, - }, - /// The script's final answer (`final_answer(text)`); ends the run loop. - FinalAnswer { - /// The answer text handed back to the caller. - answer: String, - }, -} - -impl HostCall { - /// The capability name used in call records and telemetry. - pub fn name(&self) -> String { - match self { - HostCall::Llm { model, .. } => model.clone().unwrap_or_else(|| "default".to_string()), - HostCall::Tool { tool, .. } => tool.clone(), - HostCall::Agent { agent, .. } => agent.clone(), - HostCall::FinalAnswer { .. } => "final_answer".to_string(), - } - } - - /// The record kind for this call. - pub fn kind(&self) -> RlmCallKind { - match self { - HostCall::Llm { .. } => RlmCallKind::Llm, - HostCall::Tool { .. } => RlmCallKind::Tool, - HostCall::Agent { .. } => RlmCallKind::Agent, - HostCall::FinalAnswer { .. } => RlmCallKind::FinalAnswer, - } - } -} - -/// The kind of capability an [`RlmCallRecord`] describes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RlmCallKind { - /// A sub-LLM query. - Llm, - /// A tool invocation. - Tool, - /// A sub-agent delegation. - Agent, - /// The final answer. - FinalAnswer, -} - -/// A record of one capability call a cell performed. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RlmCallRecord { - /// Which capability kind was invoked. - pub kind: RlmCallKind, - /// The capability name (model, tool, or agent registry name). - pub name: String, - /// Structured detail about the call (argument summary, sizes). - pub detail: Value, - /// Wall-clock time the call took. - pub elapsed: Duration, -} - -// ── Cell + run outcomes ───────────────────────────────────────────────────── - -/// The structured result of evaluating one code cell. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CellOutcome { - /// Captured print/console output, bounded by - /// [`RlmPolicy::max_output_bytes`]. - pub stdout: String, - /// The cell's final expression value, if it produced one. - pub value: Option, - /// A script-level error (exception / runtime error), when the cell - /// failed *recoverably* — the driving model sees this and may adapt. - pub error: Option, - /// Capability calls recorded during the cell, in order. - pub calls: Vec, - /// The final answer, if the cell called `final_answer(...)`. - pub final_answer: Option, - /// Wall-clock time the cell took to evaluate. - pub elapsed: Duration, -} - -/// Why an [`RlmOutcome`] run loop stopped. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RlmStopReason { - /// A cell called `final_answer(...)`. - Answered, - /// The driver model replied with prose and no code cell; the prose is - /// taken as the answer. - ModelAnswered, - /// The [`RlmPolicy::max_cells`] budget was exhausted without an answer. - CellBudgetExhausted, -} - -/// One executed step of the model-driven loop: the code the driver model -/// wrote and what evaluating it produced. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RlmStep { - /// The code cell the driver model emitted. - pub code: String, - /// The evaluation outcome fed back to the model. - pub outcome: CellOutcome, -} - -/// The result of one complete [`super::RlmRunner::run`] loop. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RlmOutcome { - /// The final answer, when the run produced one. - pub answer: Option, - /// Why the loop stopped. - pub stop_reason: RlmStopReason, - /// Every executed step, in order (the full trajectory). - pub steps: Vec, - /// Driver-model calls made by the loop itself (excludes sub-LLM calls - /// made *by scripts*, which are counted in [`RlmOutcome::sub_llm_calls`]). - pub driver_calls: usize, - /// Sub-LLM calls scripts made through the `llm` capability. - pub sub_llm_calls: usize, - /// Tool calls scripts made through the `tool` capability. - pub tool_calls: usize, - /// Sub-agent calls scripts made through the `agent` capability. - pub agent_calls: usize, -} - -// ── Templates ─────────────────────────────────────────────────────────────── - -/// A named prompt scaffold for the driver model. -/// -/// The `system_prompt` may reference these placeholders, substituted at run -/// time by [`super::templates::render_system_prompt`]: -/// -/// - `{{language}}` — the interpreter language (`rhai`, `python`, …) -/// - `{{usage}}` — the interpreter-specific capability usage guide -/// - `{{capabilities}}` — the live model/tool/agent registry listing -/// - `{{limits}}` — a human-readable summary of the [`RlmPolicy`] -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RlmTemplate { - /// The template name (used by [`TemplateSpec::Named`]). - pub name: String, - /// The system prompt scaffold with `{{placeholder}}` slots. - pub system_prompt: String, -} - -/// How a config selects its prompt template: one of the built-in named -/// templates, or a fully inline scaffold. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(untagged)] -pub enum TemplateSpec { - /// A built-in template by name (`"general"`, `"context-explorer"`, - /// `"orchestrator"`). - Named(String), - /// An inline template document. - Inline(RlmTemplate), -} - -impl Default for TemplateSpec { - fn default() -> Self { - TemplateSpec::Named("general".to_string()) - } -} - -// ── Config ────────────────────────────────────────────────────────────────── - -/// A complete, serializable description of an RLM run — the document an -/// external harness hands to [`super::RlmRunner::from_config`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct RlmConfig { - /// Which interpreter executes code cells. - pub interpreter: InterpreterSpec, - /// The registry name of the driver model that writes cells; `None` - /// selects the registry default. - #[serde(skip_serializing_if = "Option::is_none")] - pub driver_model: Option, - /// The registry name of the default sub-LLM scripts reach with - /// `llm(...)` when they don't name a model; `None` falls back to the - /// driver model. - #[serde(skip_serializing_if = "Option::is_none")] - pub sub_model: Option, - /// Resource limits for the session. - pub policy: RlmPolicy, - /// The driver prompt template. - pub template: TemplateSpec, -} - -impl RlmConfig { - /// Parses a config from a JSON document. - pub fn from_json(json: &str) -> Result { - serde_json::from_str(json).map_err(TinyAgentsError::Serialization) - } - - /// Serializes this config to pretty JSON. - pub fn to_json(&self) -> Result { - serde_json::to_string_pretty(self).map_err(TinyAgentsError::Serialization) - } -} diff --git a/tests/e2e_complex_graph.rs b/tests/e2e_complex_graph.rs index d27d2553..3f1574c7 100644 --- a/tests/e2e_complex_graph.rs +++ b/tests/e2e_complex_graph.rs @@ -349,18 +349,3 @@ async fn unbounded_loop_back_hits_recursion_limit_deterministically() { "expected RecursionLimit(4), got {err:?}" ); } - -// --------------------------------------------------------------------------- -// DEFERRED: graph node that REPLs another graph (graph -> .ragsh REPL -> graph). -// --------------------------------------------------------------------------- - -/// Placeholder tracking the graph->REPL->graph composition. The `.ragsh` live -/// execution engine that would let a node drive another graph through a REPL -/// session is not built yet (it lands in a later cluster). This test is -/// intentionally ignored — not faked — so the intent is tracked and this file -/// fails loudly to be wired up once the REPL engine exists. -#[tokio::test] -#[ignore = "pending the .ragsh REPL execution engine (later cluster); lands with that work"] -async fn graph_repls_another_graph_pending_repl_engine() { - panic!("not implemented: requires the .ragsh REPL execution engine"); -} diff --git a/tests/e2e_misc_public_helpers.rs b/tests/e2e_misc_public_helpers.rs index f0dccdac..2a09d471 100644 --- a/tests/e2e_misc_public_helpers.rs +++ b/tests/e2e_misc_public_helpers.rs @@ -21,7 +21,6 @@ use tinyagents::harness::ids::{ use tinyagents::harness::limits::{LimitTracker, RunLimits}; use tinyagents::harness::store::{AppendStore, InMemoryAppendStore}; use tinyagents::harness::tool::{ToolCall, ToolFormat, ToolResult, ToolSchema}; -use tinyagents::repl::{CapabilityPolicy, ReplCommand, ReplOutcome, ReplSession, parse_command}; #[tokio::test] async fn graph_reducers_streams_observability_and_status_helpers_work() { @@ -252,7 +251,7 @@ async fn graph_reducers_streams_observability_and_status_helpers_work() { } #[test] -fn tool_schema_limits_ids_and_repl_contracts_cover_public_helpers() { +fn tool_schema_limits_and_ids_cover_public_helpers() { let schema = ToolSchema::new( "make", "make a value", @@ -352,139 +351,4 @@ fn tool_schema_limits_ids_and_repl_contracts_cover_public_helpers() { assert!(new_session_id().as_str().starts_with("session-")); assert!(new_cell_id().as_str().starts_with("cell-")); assert!(new_call_id().as_str().starts_with("call-")); - - assert_eq!(parse_command("help").unwrap().name(), "help"); - assert_eq!(parse_command("?").unwrap(), ReplCommand::Help); - assert_eq!(parse_command("q").unwrap(), ReplCommand::Quit); - assert_eq!( - parse_command(r#"set name "Ada Lovelace""#).unwrap(), - ReplCommand::Set { - key: "name".into(), - value: "Ada Lovelace".into() - } - ); - assert_eq!( - parse_command(r#"call tool {"x":1}"#).unwrap(), - ReplCommand::Call { - capability: "tool".into(), - args: json!({ "x": 1 }) - } - ); - assert!(parse_command("").is_err()); - assert!(parse_command("unknown").is_err()); - assert!(parse_command(r#"set x "unterminated"#).is_err()); - assert!(parse_command("call tool not-json").is_err()); - - let mut policy = CapabilityPolicy::new(); - assert!(policy.is_empty()); - policy - .allow("load") - .allow("compile") - .allow("run") - .allow("tool"); - assert_eq!(policy.len(), 4); - assert!(policy.is_allowed("tool")); - let list_policy = CapabilityPolicy::from_list(["tool"]); - assert!(list_policy.is_allowed("tool")); - - let mut session = ReplSession::new().with_policy(policy); - session.set("direct", json!(42)); - assert_eq!(session.get("direct"), Some(&json!(42))); - assert!(session.vars().contains_key("direct")); - assert!(matches!( - session.execute(ReplCommand::Help).unwrap(), - ReplOutcome::Message(_) - )); - assert_eq!( - session - .execute(ReplCommand::Set { - key: "name".into(), - value: "Ada".into() - }) - .unwrap(), - ReplOutcome::Message("ok".into()) - ); - assert_eq!( - session - .execute(ReplCommand::Get { key: "name".into() }) - .unwrap(), - ReplOutcome::Value(json!("Ada")) - ); - assert!(matches!( - session - .execute(ReplCommand::Show { - what: "vars".into() - }) - .unwrap(), - ReplOutcome::Value(_) - )); - assert!(matches!( - session - .execute(ReplCommand::Show { - what: "graphs".into() - }) - .unwrap(), - ReplOutcome::Message(_) - )); - assert!(matches!( - session - .execute(ReplCommand::Show { - what: "status".into() - }) - .unwrap(), - ReplOutcome::Value(_) - )); - assert!(matches!( - session - .execute(ReplCommand::Show { what: "bad".into() }) - .unwrap(), - ReplOutcome::Message(_) - )); - assert!(matches!( - session - .execute(ReplCommand::Load { - path: "x.rag".into() - }) - .unwrap(), - ReplOutcome::Planned { .. } - )); - assert!(matches!( - session - .execute(ReplCommand::Compile { name: "x".into() }) - .unwrap(), - ReplOutcome::Planned { .. } - )); - assert!(matches!( - session - .execute(ReplCommand::Run { - graph: "g".into(), - input: "{}".into() - }) - .unwrap(), - ReplOutcome::Planned { .. } - )); - assert!(matches!( - session - .execute(ReplCommand::Call { - capability: "tool".into(), - args: json!({}) - }) - .unwrap(), - ReplOutcome::Planned { .. } - )); - assert!( - session - .execute(ReplCommand::Call { - capability: "blocked".into(), - args: json!({}) - }) - .unwrap_err() - .to_string() - .contains("allowlist") - ); - assert_eq!( - session.execute(ReplCommand::Quit).unwrap(), - ReplOutcome::Quit - ); - assert!(!session.history.is_empty()); } diff --git a/tests/e2e_repl_blueprint.rs b/tests/e2e_repl_blueprint.rs deleted file mode 100644 index f31699f9..00000000 --- a/tests/e2e_repl_blueprint.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! TRUE end-to-end: a [`ReplSession`] driven through parsed command lines that -//! load + compile + run a `.rag` graph by name, interleaved with session -//! variables, and gated by a [`CapabilityPolicy`]. -//! -//! This composes the **REPL** (command parser + session + capability policy) -//! with the **language** subsystem: the same `.rag` source the REPL plans to -//! load/compile is independently parsed and compiled in the test, proving the -//! REPL's plan corresponds to a real, compilable blueprint rather than an -//! arbitrary string. - -use tinyagents::TinyAgentsError; -use tinyagents::language::compiler::compile; -use tinyagents::language::parser::parse_str; -use tinyagents::repl::{CapabilityPolicy, ReplCommand, ReplOutcome, ReplSession, parse_command}; - -const SUPPORT_AGENT: &str = r#" -graph support_agent { - start agent - node agent { - kind agent - model "default" - tools ["lookup_user"] - routes { - tool_call -> tools - final -> END - } - } - node tools { - kind tool_executor - next agent - } -} -"#; - -#[test] -fn repl_plans_match_a_real_compilable_blueprint() { - // The capability commands (load/compile/run) require their verbs on the - // allowlist; the deny-by-default policy still blocks everything else. - let policy = CapabilityPolicy::from_list(["load", "compile", "run"]); - let mut session = ReplSession::new().with_policy(policy); - - // --- load --- - let load = parse_command("load support_agent.rag").expect("parses"); - assert_eq!( - load, - ReplCommand::Load { - path: "support_agent.rag".to_string() - } - ); - match session.execute(load).expect("load allowed") { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "load"); - assert_eq!(detail["path"], "support_agent.rag"); - } - other => panic!("expected Planned load, got {other:?}"), - } - - // --- set / get session variables interleaved with capability commands --- - let set = parse_command("set graph_name support_agent").expect("parses"); - assert!(matches!( - session.execute(set).expect("set ok"), - ReplOutcome::Message(_) - )); - assert_eq!( - session - .execute(parse_command("get graph_name").unwrap()) - .unwrap(), - ReplOutcome::Value(serde_json::json!("support_agent")) - ); - - // --- compile --- - match session - .execute(parse_command("compile support_agent").expect("parses")) - .expect("compile allowed") - { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "compile"); - assert_eq!(detail["name"], "support_agent"); - } - other => panic!("expected Planned compile, got {other:?}"), - } - - // --- run --- - let run_cmd = parse_command(r#"run support_agent "{}""#).expect("parses"); - assert_eq!( - run_cmd, - ReplCommand::Run { - graph: "support_agent".to_string(), - input: "{}".to_string(), - } - ); - let planned_graph = match session.execute(run_cmd).expect("run allowed") { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "graph_run"); - detail["graph"].as_str().unwrap().to_string() - } - other => panic!("expected Planned graph_run, got {other:?}"), - }; - - // The REPL planned to run `support_agent`; prove that name corresponds to a - // real graph the language pipeline can actually parse + compile. - let program = parse_str(SUPPORT_AGENT).expect("source parses"); - let blueprint = compile(&program).expect("program compiles").remove(0); - assert_eq!(blueprint.graph_id, planned_graph); - assert_eq!(blueprint.start, "agent"); - assert_eq!(blueprint.nodes.len(), 2); - - // Every command was recorded in history (load, set, get, compile, run). - assert_eq!(session.history.len(), 5); -} - -#[test] -fn disallowed_capability_is_rejected_by_the_session() { - // Only `load` is allowed; `run` is not on the allowlist. - let policy = CapabilityPolicy::from_list(["load"]); - let mut session = ReplSession::new().with_policy(policy); - - let err = session - .execute(ReplCommand::Run { - graph: "support_agent".to_string(), - input: "{}".to_string(), - }) - .expect_err("run is not permitted"); - - match err { - TinyAgentsError::Capability(msg) => { - assert!( - msg.contains("run"), - "error should name the capability: {msg}" - ); - } - other => panic!("expected Capability error, got {other:?}"), - } -} diff --git a/tests/e2e_rlm.rs b/tests/e2e_rlm.rs deleted file mode 100644 index 8fcc2a4f..00000000 --- a/tests/e2e_rlm.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! End-to-end tests for the `rlm` surface (feature = "rlm"). -//! -//! Deterministic tests drive the embedded Rhai backend and the model-driven -//! runner against testkit doubles. The external-interpreter tests run against -//! a real `python3` / `node` from `PATH` and **skip gracefully** (early -//! return) when the binary is missing, mirroring the `live_*.rs` gating -//! convention — no network or API key is needed either way. - -#![cfg(feature = "rlm")] - -use std::sync::Arc; - -use serde_json::json; -use tinyagents::harness::message::Message; -use tinyagents::harness::runtime::AgentHarness; -use tinyagents::harness::testkit::{FakeTool, ScriptedModel}; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{ - InterpreterSpec, RlmConfig, RlmHost, RlmPolicy, RlmRunner, RlmSession, RlmStopReason, -}; -use tinyagents::{HarnessSubAgent, SubAgent}; - -fn registry_with_doubles(replies: Vec<&str>) -> Arc> { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(replies))) - .expect("register model"); - registry - .register_tool(Arc::new(FakeTool::returning("lookup", "tool-result-42"))) - .expect("register tool"); - Arc::new(registry) -} - -fn session_for(spec: &InterpreterSpec, registry: Arc>) -> RlmSession<()> { - let host = Arc::new( - RlmHost::new(registry, Arc::new(())) - .with_policy(RlmPolicy::default()) - .with_default_model("mock"), - ); - RlmSession::new(spec, host).expect("build session") -} - -fn binary_available(binary: &str) -> bool { - std::process::Command::new(binary) - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|status| status.success()) - .unwrap_or(false) -} - -// ── Sub-agent delegation from inside a script ─────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rhai_script_delegates_to_a_registered_subagent() { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(vec!["unused"]))) - .expect("register model"); - - // A real harness-backed sub-agent with its own scripted model. - let mut child_harness: AgentHarness<()> = AgentHarness::new(); - child_harness - .register_model( - "child-model", - Arc::new(ScriptedModel::replies(vec!["report from the child agent"])), - ) - .set_default_model("child-model"); - let subagent = Arc::new(SubAgent::new( - "researcher", - "Investigates a question and reports back.", - Arc::new(child_harness), - )); - registry - .register_agent(Arc::new(HarnessSubAgent::new(subagent))) - .expect("register agent"); - - let mut session = session_for(&InterpreterSpec::Rhai, Arc::new(registry)); - let outcome = session - .eval(r#"let report = agent("researcher", "investigate X"); final_answer(report)"#) - .await - .expect("cell"); - assert_eq!( - outcome.final_answer.as_deref(), - Some("report from the child agent") - ); -} - -// ── External Python interpreter ───────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn python_interpreter_runs_cells_and_calls_capabilities() { - if !binary_available("python3") { - eprintln!("skipping: python3 is not on PATH"); - return; - } - let spec = InterpreterSpec::Python { - binary: None, - args: vec![], - }; - let mut session = session_for(&spec, registry_with_doubles(vec!["sub-llm reply"])); - - // Globals persist across cells; last expression is the value. - let outcome = session.eval("x = 40\nx + 2").await.expect("cell 1"); - assert_eq!(outcome.value, Some(json!(42))); - - // Context injection without source splicing. - session - .set_variable("context", json!({"items": [1, 2, 3]})) - .await - .expect("set context"); - let outcome = session - .eval("len(context['items']) + x") - .await - .expect("cell 2"); - assert_eq!(outcome.value, Some(json!(43))); - - // Capability calls: llm + tool + final_answer through the wire protocol. - let outcome = session - .eval( - "reply = llm('hello?')\nprint(reply)\nr = tool('lookup', {'q': 1})\nfinal_answer(reply)", - ) - .await - .expect("cell 3"); - assert!(outcome.stdout.contains("sub-llm reply")); - assert_eq!(outcome.final_answer.as_deref(), Some("sub-llm reply")); - - // Script exceptions are recoverable and RlmError is catchable. - let outcome = session - .eval("try:\n tool('missing')\nexcept RlmError as e:\n print('caught', e)\n'ok'") - .await - .expect("cell 4"); - assert!(outcome.stdout.contains("caught")); - assert_eq!(outcome.value, Some(json!("ok"))); - - session.shutdown().await.expect("shutdown"); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn python_cell_timeout_kills_the_child_fail_closed() { - if !binary_available("python3") { - eprintln!("skipping: python3 is not on PATH"); - return; - } - let registry = registry_with_doubles(vec![]); - let host = Arc::new( - RlmHost::new(registry, Arc::new(())) - .with_policy(RlmPolicy { - cell_timeout: Some(std::time::Duration::from_millis(400)), - ..RlmPolicy::default() - }) - .with_default_model("mock"), - ); - let mut session = RlmSession::new( - &InterpreterSpec::Python { - binary: None, - args: vec![], - }, - host, - ) - .expect("session"); - let started = std::time::Instant::now(); - let err = session - .eval("import time\ntime.sleep(60)") - .await - .expect_err("must time out"); - assert!( - matches!(err, tinyagents::TinyAgentsError::Timeout(_)), - "got {err:?}" - ); - assert!(started.elapsed() < std::time::Duration::from_secs(10)); -} - -// ── External JavaScript interpreter ───────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn javascript_interpreter_runs_cells_and_calls_capabilities() { - if !binary_available("node") { - eprintln!("skipping: node is not on PATH"); - return; - } - let spec = InterpreterSpec::Javascript { - binary: None, - args: vec![], - }; - let mut session = session_for(&spec, registry_with_doubles(vec!["js sub-llm reply"])); - - let outcome = session.eval("let x = 40; x + 2").await.expect("cell 1"); - assert_eq!(outcome.value, Some(json!(42))); - - session - .set_variable("context", json!("needle in a haystack")) - .await - .expect("set context"); - let outcome = session - .eval("context.includes('needle')") - .await - .expect("cell 2"); - assert_eq!(outcome.value, Some(json!(true))); - - let outcome = session - .eval( - "const reply = llm('hello?'); console.log(reply); tool('lookup', {q: 1}); final_answer(reply)", - ) - .await - .expect("cell 3"); - assert!(outcome.stdout.contains("js sub-llm reply")); - assert_eq!(outcome.final_answer.as_deref(), Some("js sub-llm reply")); - - session.shutdown().await.expect("shutdown"); -} - -// ── Config-driven runner over an external interpreter ─────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runner_drives_a_python_session_from_a_json_config() { - if !binary_available("python3") { - eprintln!("skipping: python3 is not on PATH"); - return; - } - let registry = registry_with_doubles(vec![ - // The scripted "driver model" writes a python cell, then answers. - "```python\ntotal = sum(range(10))\nprint(total)\ntotal\n```", - "```python\nfinal_answer(f'the sum is {total}')\n```", - ]); - let config = RlmConfig::from_json( - r#"{ - "interpreter": {"kind": "python"}, - "driver_model": "mock", - "template": "general", - "policy": {"max_cells": 4, "cell_timeout": 30000} - }"#, - ) - .expect("parse config"); - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("runner"); - let outcome = runner.run("sum the numbers below 10").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("the sum is 45")); - assert_eq!(outcome.stop_reason, RlmStopReason::Answered); - assert_eq!(outcome.steps.len(), 2); - assert!(outcome.steps[0].outcome.stdout.contains("45")); - runner.shutdown().await.expect("shutdown"); -} - -// ── Prompt surface sanity ─────────────────────────────────────────────────── - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn system_prompt_lists_live_capabilities_and_language() { - let registry = registry_with_doubles(vec![]); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - ..RlmConfig::default() - }; - let runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("runner"); - let prompt = runner.system_prompt(); - assert!(prompt.contains("```rhai")); - assert!(prompt.contains("lookup")); - assert!(prompt.contains("final_answer")); -} - -// ── Messages are ordinary harness messages ────────────────────────────────── - -#[test] -fn observation_shapes_are_plain_messages() { - // Guard against accidental coupling: the runner speaks in ordinary - // Message values, so any ChatModel implementation can drive it. - let m = Message::user("observation"); - assert_eq!(m.text(), "observation"); -} diff --git a/tests/feature_registry_diagnostics.rs b/tests/feature_registry_diagnostics.rs index c7a32de1..374ef5e4 100644 --- a/tests/feature_registry_diagnostics.rs +++ b/tests/feature_registry_diagnostics.rs @@ -28,7 +28,7 @@ fn name_only_descriptor_kinds_register_alias_and_resolve() { // Every name-only kind that routes through `register_descriptor`. for (kind, name) in [ (ComponentKind::Store, "kv"), - (ComponentKind::Script, "triage.ragsh"), + (ComponentKind::Script, "triage.script"), (ComponentKind::Middleware, "redact"), (ComponentKind::Checkpointer, "sqlite"), (ComponentKind::TaskStore, "jobs"), diff --git a/tests/feature_repl_graph_authoring.rs b/tests/feature_repl_graph_authoring.rs deleted file mode 100644 index 9b5a513d..00000000 --- a/tests/feature_repl_graph_authoring.rs +++ /dev/null @@ -1,167 +0,0 @@ -//! Feature tests for the `.ragsh` session graph-authoring lifecycle -//! (`src/repl/session/builtins/authoring.rs`, gated behind the `repl` feature). -//! -//! Run with `cargo test --features repl --test feature_repl_graph_authoring`. -//! The whole file compiles to nothing without the feature. -//! -//! A session that drafts a `.rag` graph must route it through the compiler, the -//! capability resolver, and the policy review gate before it can be registered -//! — generated topology is never installed directly. These tests cover that -//! `graph_define` → `graph_validate` → `graph_compile` → `graph_register` flow -//! and the review-token gate, which the in-crate unit tests only touch for the -//! `graph_define` limit-accounting edge case. -//! -//! Everything is deterministic and offline. - -#![cfg(feature = "repl")] - -use std::sync::Arc; - -use tinyagents::harness::providers::MockModel; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::{ReplCapabilities, ReplPolicy, ReplSession, TinyAgentsError}; - -/// A `.rag` graph whose single model node binds to a model named `assistant`. -const GRAPH_SOURCE: &str = r#"graph triage { - start classify - node classify { - kind model - model "assistant" - next END - } -}"#; - -/// A session over a registry that carries the `assistant` model the graph binds -/// to, so `graph_compile`'s resolver gate passes. -fn session_with_assistant(policy: ReplPolicy) -> ReplSession { - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("assistant", Arc::new(MockModel::constant("ok"))) - .expect("register model"); - ReplSession::<()>::new() - .with_policy(policy) - .with_capabilities(ReplCapabilities::new(Arc::new(registry))) -} - -/// A `graph_define(...)` cell for `GRAPH_SOURCE` under graph name `triage`. -fn define_cell() -> String { - format!(r#"graph_define(#{{ name: "triage", source: `{GRAPH_SOURCE}` }})"#) -} - -#[test] -fn graph_define_drafts_an_uncompiled_review_gated_blueprint() { - let mut s = session_with_assistant(ReplPolicy::default()); - - let result = s.eval_cell(&define_cell()).expect("graph_define drafts"); - let descriptor = result.value.expect("descriptor map").to_json(); - - assert_eq!(descriptor["name"], serde_json::json!("triage")); - // One model node. - assert_eq!(descriptor["nodes"], serde_json::json!(1)); - // A fresh draft is not yet compiled and, under the default policy, requires - // review before registration. - assert_eq!(descriptor["compiled"], serde_json::json!(false)); - assert_eq!(descriptor["requires_review"], serde_json::json!(true)); -} - -#[test] -fn graph_validate_reports_unresolved_capability_references() { - // The graph binds to `assistant`, which is NOT registered here, so the - // resolver-backed `graph_validate` surfaces a diagnostic message. - let mut s = ReplSession::<()>::new(); - - let script = format!( - r#"let g = graph_define(#{{ name: "triage", source: `{GRAPH_SOURCE}` }}); - graph_validate(g)"# - ); - let result = s.eval_cell(&script).expect("define + validate"); - let messages = result.value.expect("array of messages").to_json(); - let messages = messages.as_array().expect("array"); - - assert!( - messages - .iter() - .any(|m| m.as_str().is_some_and(|s| s.contains("assistant"))), - "validation should flag the unresolved `assistant` reference, got {messages:?}" - ); -} - -#[test] -fn full_lifecycle_compiles_then_registers_with_a_review_token() { - let mut s = session_with_assistant(ReplPolicy::default()); - - // Define, then compile — compilation binds through the resolver and marks - // the draft compiled. - let compiled = s - .eval_cell(&format!( - r#"let g = graph_define(#{{ name: "triage", source: `{GRAPH_SOURCE}` }}); - graph_compile(g)"# - )) - .expect("define + compile"); - let descriptor = compiled.value.expect("compiled descriptor").to_json(); - assert_eq!(descriptor["compiled"], serde_json::json!(true)); - assert_eq!(descriptor["requires_review"], serde_json::json!(true)); - - // Registering a review-gated graph without a review_id is rejected. - let err = s - .eval_cell( - r#"let g = graph_compile(#{ name: "triage" }); - graph_register(#{ graph: g })"#, - ) - .expect_err("registration without review must fail"); - assert!( - matches!(err, TinyAgentsError::Validation(ref m) if m.contains("review")), - "got {err:?}" - ); - - // Supplying a review token lets registration succeed; it returns the name. - let registered = s - .eval_cell( - r#"let g = graph_compile(#{ name: "triage" }); - graph_register(#{ graph: g, review_id: "reviewed-by-human" })"#, - ) - .expect("registration with review token succeeds"); - assert_eq!( - registered.value.expect("name").to_json(), - serde_json::json!("triage") - ); -} - -#[test] -fn register_before_compile_is_rejected() { - let mut s = session_with_assistant(ReplPolicy::default()); - - // Draft exists but was never compiled: registration must fail closed. - let err = s - .eval_cell(&format!( - r#"let g = graph_define(#{{ name: "triage", source: `{GRAPH_SOURCE}` }}); - graph_register(#{{ graph: g }})"# - )) - .expect_err("registering an uncompiled draft must fail"); - assert!( - matches!(err, TinyAgentsError::Validation(ref m) if m.contains("compiled")), - "got {err:?}" - ); -} - -#[test] -fn review_gate_can_be_disabled_by_policy() { - // With the review gate off, a compiled graph registers without a token. - let policy = ReplPolicy { - generated_graphs_require_review: false, - ..ReplPolicy::default() - }; - let mut s = session_with_assistant(policy); - - let registered = s - .eval_cell(&format!( - r#"let g = graph_define(#{{ name: "triage", source: `{GRAPH_SOURCE}` }}); - let c = graph_compile(g); - graph_register(#{{ graph: c }})"# - )) - .expect("no-review policy registers without a token"); - assert_eq!( - registered.value.expect("name").to_json(), - serde_json::json!("triage") - ); -} diff --git a/tests/feature_repl_session.rs b/tests/feature_repl_session.rs deleted file mode 100644 index 81069831..00000000 --- a/tests/feature_repl_session.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! Feature tests for the Rhai-backed `.ragsh` session runtime -//! (`src/repl/session/`, gated behind the `repl` cargo feature). -//! -//! Run with `cargo test --features repl --test feature_repl_session`. The whole -//! file compiles to nothing without the feature (mirroring `e2e_rlm.rs`). -//! -//! These drive the crate-root [`ReplSession`] (the scripting session, exported -//! as `crate::ReplSession` when `repl` is enabled) from *outside* the crate to -//! cover user-facing capability features that the in-crate unit tests do not -//! exercise at the integration boundary: the `model_query` / -//! `model_query_batched` capability functions wired to a registered model, the -//! `graph_run` blueprint-resolution reference, the `show_vars()` built-in, the -//! persistent namespace across cells, per-session call budgets, and -//! reserved-name protection. -//! -//! Everything is deterministic and offline: models are testkit doubles -//! (`ScriptedModel`, `MockModel`) that never touch the network. - -#![cfg(feature = "repl")] - -use std::sync::Arc; - -use tinyagents::harness::providers::MockModel; -use tinyagents::harness::testkit::ScriptedModel; -use tinyagents::language::Blueprint; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::{ - ReplCallKind, ReplCapabilities, ReplPolicy, ReplSession, ReplValue, TinyAgentsError, -}; - -/// Builds a session over a registry that carries a single scripted model named -/// `assistant` returning the given replies in order. -fn session_with_model(replies: Vec<&str>) -> ReplSession { - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("assistant", Arc::new(ScriptedModel::replies(replies))) - .expect("register model"); - ReplSession::<()>::new().with_capabilities(ReplCapabilities::new(Arc::new(registry))) -} - -#[test] -fn model_query_calls_a_registered_model_and_records_the_call() { - let mut s = session_with_model(vec!["the-answer"]); - - let result = s - .eval_cell(r#"model_query(#{ model: "assistant", prompt: "hi" })"#) - .expect("model_query should succeed against the registered model"); - - // The cell value is the model's reply text. - assert_eq!( - result.value, - Some(ReplValue::String("the-answer".to_string())) - ); - - // Exactly one Model capability call was recorded, naming the model. - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].kind, ReplCallKind::Model); - assert_eq!(result.calls[0].name, "assistant"); -} - -#[test] -fn model_query_does_not_leak_the_registry_alias_as_the_provider_model_id() { - // Regression test: `build_model_request` used to set `ModelRequest.model` - // to the *registry* name the script passed, not a provider model id. - // `CapabilityRegistry::register_model` allows those to differ (a host - // may register a `"fast"` alias for `gpt-4o-mini`), and a real provider - // transport sends `request.model` verbatim on the wire when set — so the - // request must leave `model` unset and let the resolved model supply its - // own provider id, exactly like `RlmHost::handle_llm` / `RlmRunner::run`. - let scripted = Arc::new(ScriptedModel::replies(vec!["hi"])); - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("fast", scripted.clone()) - .expect("register model"); - let mut s = - ReplSession::<()>::new().with_capabilities(ReplCapabilities::new(Arc::new(registry))); - - s.eval_cell(r#"model_query(#{ model: "fast", prompt: "hi" })"#) - .expect("model_query"); - - let requests = scripted.requests(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].model, None, - "the registry alias `fast` must not be sent as the provider model id" - ); -} - -#[test] -fn model_query_structured_returns_content_and_finish_reason() { - let mut s = session_with_model(vec!["structured-reply"]); - - let result = s - .eval_cell( - r#"let r = model_query(#{ model: "assistant", prompt: "hi", structured: true }); r.content"#, - ) - .expect("structured model_query"); - - assert_eq!( - result.value, - Some(ReplValue::String("structured-reply".to_string())) - ); -} - -#[test] -fn model_query_on_an_unregistered_model_reports_model_not_found() { - let mut s = ReplSession::<()>::new(); - - let err = s - .eval_cell(r#"model_query(#{ model: "ghost", prompt: "hi" })"#) - .expect_err("querying an unregistered model must fail"); - assert!( - matches!(err, TinyAgentsError::ModelNotFound(ref m) if m == "ghost"), - "expected ModelNotFound(ghost), got {err:?}" - ); -} - -#[test] -fn model_query_batched_fans_out_and_preserves_order() { - // `MockModel::constant` makes each leg deterministic regardless of the - // concurrency scheduling in the batch. - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("m", Arc::new(MockModel::constant("reply"))) - .expect("register model"); - let mut s = - ReplSession::<()>::new().with_capabilities(ReplCapabilities::new(Arc::new(registry))); - - let result = s - .eval_cell( - r#"model_query_batched([ - #{ model: "m", prompt: "a" }, - #{ model: "m", prompt: "b" }, - #{ model: "m", prompt: "c" }, - ])"#, - ) - .expect("batched model query"); - - let value = result.value.expect("array value").to_json(); - let items = value.as_array().expect("array"); - assert_eq!(items.len(), 3); - for item in items { - assert_eq!(item, &serde_json::json!("reply")); - } - // One recorded Model call per leg. - assert_eq!(result.calls.len(), 3); - assert!(result.calls.iter().all(|c| c.kind == ReplCallKind::Model)); -} - -#[test] -fn model_call_budget_fails_closed() { - let policy = ReplPolicy { - max_model_calls: 2, - ..ReplPolicy::default() - }; - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("m", Arc::new(MockModel::constant("ok"))) - .unwrap(); - let mut s = ReplSession::<()>::new() - .with_policy(policy) - .with_capabilities(ReplCapabilities::new(Arc::new(registry))); - - let call = r#"model_query(#{ model: "m", prompt: "x" })"#; - s.eval_cell(call).expect("call 1 within budget"); - s.eval_cell(call).expect("call 2 within budget"); - - let err = s - .eval_cell(call) - .expect_err("call 3 exceeds max_model_calls"); - assert!( - matches!(err, TinyAgentsError::LimitExceeded(_)), - "got {err:?}" - ); -} - -#[test] -fn graph_run_resolves_a_registered_blueprint_to_a_reference() { - // Register a compiled blueprint by name; graph_run resolves it and hands - // back a script-visible reference (graph id, start node, node count). - let mut registry = CapabilityRegistry::<()>::new(); - let blueprint = Blueprint { - graph_id: "triage".to_string(), - start: "classify".to_string(), - ..Blueprint::default() - }; - registry - .register_graph_blueprint("triage", blueprint) - .expect("register blueprint"); - let mut s = - ReplSession::<()>::new().with_capabilities(ReplCapabilities::new(Arc::new(registry))); - - let result = s - .eval_cell(r#"graph_run(#{ graph: "triage" })"#) - .expect("graph_run resolves the registered blueprint"); - - let value = result.value.expect("reference map").to_json(); - assert_eq!(value["graph"], serde_json::json!("triage")); - assert_eq!(value["start"], serde_json::json!("classify")); - assert_eq!(value["resolved"], serde_json::json!(true)); - - assert_eq!(result.calls.len(), 1); - assert_eq!(result.calls[0].kind, ReplCallKind::Graph); -} - -#[test] -fn graph_run_on_an_unregistered_graph_is_rejected() { - let mut s = ReplSession::<()>::new(); - - let err = s - .eval_cell(r#"graph_run(#{ graph: "missing" })"#) - .expect_err("running an unregistered graph must fail"); - assert!( - matches!(err, TinyAgentsError::Capability(ref m) if m.contains("missing")), - "got {err:?}" - ); -} - -#[test] -fn persistent_namespace_survives_across_cells_and_reads_via_show_vars() { - let mut s = ReplSession::<()>::new(); - - // A binding from cell 1 is visible in later cells and to `show_vars()`. - s.eval_cell(r#"let ticket = "T-42";"#) - .expect("seed binding"); - let follow = s.eval_cell("ticket").expect("read binding"); - assert_eq!(follow.value, Some(ReplValue::String("T-42".to_string()))); - - // `show_vars()` prints the persistent namespace captured at the start of - // the cell (so it reflects `ticket` seeded earlier). - let shown = s.eval_cell("show_vars(); ()").expect("show_vars runs"); - assert!( - shown.stdout.contains("ticket"), - "show_vars stdout should mention the persistent binding, got: {:?}", - shown.stdout - ); -} - -#[test] -fn reserved_capability_name_cannot_be_replaced_via_variables_api() { - let mut s = ReplSession::<()>::new(); - - // The public ReplVariables surface refuses to bind a reserved capability - // name, so a caller cannot smuggle a replacement for `model_query`. - let err = s - .variables - .set("model_query", ReplValue::Int(1)) - .expect_err("reserved capability names are protected"); - assert!(matches!(err, TinyAgentsError::Capability(_)), "got {err:?}"); -} - -#[test] -fn capabilities_expose_registered_names_by_kind() { - let mut registry = CapabilityRegistry::<()>::new(); - registry - .register_model("assistant", Arc::new(MockModel::constant("hi"))) - .unwrap(); - let caps = ReplCapabilities::new(Arc::new(registry)); - - assert_eq!(caps.models(), vec!["assistant"]); - assert!(caps.tools().is_empty()); - assert!(caps.graphs().is_empty()); - assert!(caps.agents().is_empty()); -} diff --git a/tests/feature_rlm_config.rs b/tests/feature_rlm_config.rs deleted file mode 100644 index 089dfa44..00000000 --- a/tests/feature_rlm_config.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Feature tests for the RLM **config surface**: the serde documents an -//! external harness hands to the runtime (`RlmConfig`, `InterpreterSpec`, -//! `RlmPolicy`, `TemplateSpec`) and the host-call wire enum (`HostCall`). -//! -//! These focus on the config-as-data contract — round trips, documented -//! defaults, millisecond timeout encoding, untagged template selection, and -//! the `HostCall` name/kind accessors — which the module's own unit tests only -//! touch in part. Everything here is offline and deterministic. - -#![cfg(feature = "rlm")] - -use std::time::Duration; - -use serde_json::json; -use tinyagents::rlm::{ - HostCall, InterpreterSpec, RlmCallKind, RlmConfig, RlmPolicy, RlmTemplate, TemplateSpec, -}; - -#[test] -fn interpreter_spec_language_name_per_variant() { - assert_eq!(InterpreterSpec::Rhai.language(), "rhai"); - assert_eq!( - InterpreterSpec::Python { - binary: None, - args: vec![] - } - .language(), - "python" - ); - assert_eq!( - InterpreterSpec::Javascript { - binary: None, - args: vec![] - } - .language(), - "javascript" - ); - // An embedder-provided command speaks the Python-flavoured wire protocol. - assert_eq!( - InterpreterSpec::Command { - binary: "runner".to_string(), - args: vec![] - } - .language(), - "python" - ); -} - -#[test] -fn interpreter_spec_default_is_the_hermetic_rhai_engine() { - assert_eq!(InterpreterSpec::default(), InterpreterSpec::Rhai); -} - -#[test] -fn config_defaults_are_the_documented_shape() { - let config = RlmConfig::default(); - assert_eq!(config.interpreter, InterpreterSpec::Rhai); - assert_eq!(config.driver_model, None); - assert_eq!(config.sub_model, None); - assert_eq!(config.template, TemplateSpec::Named("general".to_string())); - assert_eq!(config.policy, RlmPolicy::default()); -} - -#[test] -fn policy_defaults_match_the_documented_bounds() { - let policy = RlmPolicy::default(); - assert_eq!(policy.max_cells, 16); - assert_eq!(policy.max_script_bytes, 64 * 1024); - assert_eq!(policy.max_output_bytes, 256 * 1024); - assert_eq!(policy.max_llm_calls, 64); - assert_eq!(policy.max_tool_calls, 128); - assert_eq!(policy.max_agent_calls, 32); - assert_eq!(policy.max_depth, 8); - assert_eq!(policy.cell_timeout, Some(Duration::from_secs(120))); - assert_eq!(policy.max_operations, 5_000_000); -} - -#[test] -fn cell_timeout_is_encoded_as_integer_milliseconds() { - let config = RlmConfig::from_json( - r#"{ "interpreter": {"kind": "rhai"}, "policy": { "cell_timeout": 4500 } }"#, - ) - .expect("parse"); - assert_eq!( - config.policy.cell_timeout, - Some(Duration::from_millis(4500)) - ); - - let json = config.to_json().expect("serialize"); - assert!( - json.contains("\"cell_timeout\": 4500"), - "timeout should serialize as plain millis, got: {json}" - ); -} - -#[test] -fn a_null_cell_timeout_round_trips_as_no_deadline() { - let policy = RlmPolicy { - cell_timeout: None, - ..RlmPolicy::default() - }; - let config = RlmConfig { - policy, - ..RlmConfig::default() - }; - let back = RlmConfig::from_json(&config.to_json().expect("serialize")).expect("parse"); - assert_eq!(back.policy.cell_timeout, None); -} - -#[test] -fn absent_optional_model_names_are_omitted_from_json() { - let json = RlmConfig::default().to_json().expect("serialize"); - assert!( - !json.contains("driver_model"), - "None driver_model must be skipped, got: {json}" - ); - assert!( - !json.contains("sub_model"), - "None sub_model must be skipped, got: {json}" - ); -} - -#[test] -fn interpreter_spec_python_round_trips_binary_and_args() { - let config = RlmConfig { - interpreter: InterpreterSpec::Python { - binary: Some("/opt/venv/bin/python".to_string()), - args: vec!["-B".to_string()], - }, - ..RlmConfig::default() - }; - let back = RlmConfig::from_json(&config.to_json().expect("serialize")).expect("parse"); - assert_eq!(config, back); -} - -#[test] -fn template_spec_is_untagged_named_or_inline() { - let named: TemplateSpec = - serde_json::from_value(json!("orchestrator")).expect("parse named template"); - assert_eq!(named, TemplateSpec::Named("orchestrator".to_string())); - - let inline: TemplateSpec = serde_json::from_value(json!({ - "name": "custom", - "system_prompt": "do the thing" - })) - .expect("parse inline template"); - assert_eq!( - inline, - TemplateSpec::Inline(RlmTemplate { - name: "custom".to_string(), - system_prompt: "do the thing".to_string(), - }) - ); -} - -#[test] -fn host_call_reports_a_name_and_kind_per_variant() { - let llm_default = HostCall::Llm { - model: None, - prompt: "hi".to_string(), - system: None, - }; - assert_eq!(llm_default.name(), "default"); - assert_eq!(llm_default.kind(), RlmCallKind::Llm); - - let llm_named = HostCall::Llm { - model: Some("gpt".to_string()), - prompt: "hi".to_string(), - system: None, - }; - assert_eq!(llm_named.name(), "gpt"); - - let tool = HostCall::Tool { - tool: "search".to_string(), - arguments: json!({}), - }; - assert_eq!(tool.name(), "search"); - assert_eq!(tool.kind(), RlmCallKind::Tool); - - let agent = HostCall::Agent { - agent: "researcher".to_string(), - input: "go".to_string(), - data: None, - }; - assert_eq!(agent.name(), "researcher"); - assert_eq!(agent.kind(), RlmCallKind::Agent); - - let answer = HostCall::FinalAnswer { - answer: "done".to_string(), - }; - assert_eq!(answer.name(), "final_answer"); - assert_eq!(answer.kind(), RlmCallKind::FinalAnswer); -} - -#[test] -fn host_call_variants_round_trip_through_their_tagged_wire_shape() { - for call in [ - HostCall::Llm { - model: Some("m".to_string()), - prompt: "p".to_string(), - system: Some("s".to_string()), - }, - HostCall::Tool { - tool: "t".to_string(), - arguments: json!({ "q": 1 }), - }, - HostCall::Agent { - agent: "a".to_string(), - input: "i".to_string(), - data: Some(json!({ "k": "v" })), - }, - HostCall::FinalAnswer { - answer: "the end".to_string(), - }, - ] { - let json = serde_json::to_string(&call).expect("serialize call"); - let back: HostCall = serde_json::from_str(&json).expect("parse call"); - assert_eq!(call, back); - } -} diff --git a/tests/feature_rlm_host.rs b/tests/feature_rlm_host.rs deleted file mode 100644 index 7b228d09..00000000 --- a/tests/feature_rlm_host.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Feature tests for the **RLM host capability boundary**: the fatal-vs- -//! script-visible error split (`is_fatal`), the live capability listing shown -//! to the driver model, and the fail-closed enforcement of the model/agent -//! recursion contracts. -//! -//! These exercise host behaviours the unit tests do not: `is_fatal` -//! classification directly, `capabilities()` over populated and empty -//! registries, an `llm` call with no default model, an unknown model surfaced -//! inside a script, and the shared sub-agent depth guard tripping fatally. - -#![cfg(feature = "rlm")] - -use std::sync::Arc; - -use tinyagents::TinyAgentsError; -use tinyagents::harness::runtime::AgentHarness; -use tinyagents::harness::testkit::{FakeTool, ScriptedModel}; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{InterpreterSpec, RlmHost, RlmPolicy, RlmSession, is_fatal}; -use tinyagents::{HarnessSubAgent, SubAgent}; - -#[test] -fn is_fatal_flags_only_policy_bounds() { - assert!(is_fatal(&TinyAgentsError::LimitExceeded("x".into()))); - assert!(is_fatal(&TinyAgentsError::Timeout("x".into()))); - assert!(is_fatal(&TinyAgentsError::Cancelled)); - assert!(is_fatal(&TinyAgentsError::SubAgentDepth(4))); - - // Recoverable, script-visible failures are NOT fatal — the model adapts. - assert!(!is_fatal(&TinyAgentsError::Tool("boom".into()))); - assert!(!is_fatal(&TinyAgentsError::ToolNotFound("missing".into()))); - assert!(!is_fatal(&TinyAgentsError::ModelNotFound("missing".into()))); - assert!(!is_fatal(&TinyAgentsError::Validation("bad".into()))); - assert!(!is_fatal(&TinyAgentsError::Model( - "provider blew up".into() - ))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn capabilities_listing_reflects_the_registry() { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(vec!["unused"]))) - .expect("register model"); - registry - .register_tool(Arc::new(FakeTool::returning("echo", "ok"))) - .expect("register tool"); - - let mut child: AgentHarness<()> = AgentHarness::new(); - child - .register_model("cm", Arc::new(ScriptedModel::replies(vec!["done"]))) - .set_default_model("cm"); - let subagent = Arc::new(SubAgent::new( - "helper", - "A helpful sub-agent.", - Arc::new(child), - )); - registry - .register_agent(Arc::new(HarnessSubAgent::new(subagent))) - .expect("register agent"); - - let host = RlmHost::new(Arc::new(registry), Arc::new(())); - let listing = tinyagents::rlm::RlmHostApi::capabilities(&host); - assert_eq!(listing.models, vec!["mock".to_string()]); - assert_eq!(listing.agents, vec!["helper".to_string()]); - assert_eq!(listing.tools.len(), 1); - assert_eq!(listing.tools[0].0, "echo"); - assert!(listing.tools[0].1.contains("Fake tool")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn capabilities_listing_is_empty_for_a_bare_registry() { - let host: RlmHost<()> = RlmHost::new(Arc::new(CapabilityRegistry::new()), Arc::new(())); - let listing = tinyagents::rlm::RlmHostApi::capabilities(&host); - assert!(listing.models.is_empty()); - assert!(listing.tools.is_empty()); - assert!(listing.agents.is_empty()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn llm_without_a_default_model_surfaces_a_catchable_error() { - // No `with_default_model`, and the script names no model: the host raises a - // (recoverable) validation error that the script can catch. - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(vec!["unused"]))) - .expect("register model"); - let host = Arc::new(RlmHost::new(Arc::new(registry), Arc::new(()))); - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host).expect("session"); - - let outcome = session - .eval(r#"try { llm("hi") } catch (e) { print("caught: " + e); } "handled""#) - .await - .expect("cell should complete — the error is recoverable"); - assert!(outcome.stdout.contains("caught")); - assert!(outcome.stdout.contains("no model")); - assert_eq!(outcome.error, None); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn an_unknown_named_model_is_catchable_in_a_script() { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(vec!["unused"]))) - .expect("register model"); - let host = Arc::new(RlmHost::new(Arc::new(registry), Arc::new(())).with_default_model("mock")); - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host).expect("session"); - - let outcome = session - .eval( - r#"try { llm(#{ model: "nope", prompt: "hi" }) } catch (e) { print("err: " + e); } "ok""#, - ) - .await - .expect("cell"); - assert!(outcome.stdout.contains("err:")); - assert!(outcome.stdout.contains("nope")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn sub_agent_depth_guard_trips_fatally() { - // Seed the session at the depth cap so any `agent(...)` call would recurse - // one level too deep — the shared harness guard aborts the cell. - let policy = RlmPolicy { - max_depth: 2, - ..RlmPolicy::default() - }; - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(vec!["unused"]))) - .expect("register model"); - let host = Arc::new( - RlmHost::new(Arc::new(registry), Arc::new(())) - .with_policy(policy) - .with_default_model("mock") - .with_run_depth(2), - ); - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host).expect("session"); - - let err = session - .eval(r#"agent("anything", "go")"#) - .await - .expect_err("depth guard must abort the cell"); - assert!( - matches!(err, TinyAgentsError::SubAgentDepth(2)), - "got {err:?}" - ); -} diff --git a/tests/feature_rlm_runner.rs b/tests/feature_rlm_runner.rs deleted file mode 100644 index 1d03d69d..00000000 --- a/tests/feature_rlm_runner.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! Feature tests for **RLM runner orchestration** beyond the happy-path loop -//! the unit tests already cover: construction validation, the `sub_model` -//! fallback to the driver model, context injection into the sandbox, the -//! capability-call tallies rolled into the outcome, and the rendered system -//! prompt reflecting the configured template. -//! -//! Offline throughout — the "driver model" is a `ScriptedModel` whose replies -//! double as both driver turns and sub-LLM answers (they share one registry -//! model), which is exactly how the `sub_model` fallback is observed. - -#![cfg(feature = "rlm")] - -use std::sync::Arc; - -use serde_json::json; -use tinyagents::harness::testkit::{FakeTool, ScriptedModel}; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{RlmConfig, RlmRunner, RlmStopReason, TemplateSpec}; - -fn registry(replies: Vec<&str>) -> Arc> { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(replies))) - .expect("register model"); - registry - .register_tool(Arc::new(FakeTool::returning("echo", "echoed"))) - .expect("register tool"); - Arc::new(registry) -} - -fn config_with_driver() -> RlmConfig { - RlmConfig { - driver_model: Some("mock".to_string()), - ..RlmConfig::default() - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn building_a_runner_without_any_model_fails_closed() { - let empty: Arc> = Arc::new(CapabilityRegistry::new()); - let err = RlmRunner::from_config(RlmConfig::default(), empty, Arc::new(())) - .err() - .expect("no model means no runner"); - assert!( - matches!(err, tinyagents::TinyAgentsError::Validation(_)), - "got {err:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_driver_only_config_uses_the_driver_as_the_sub_llm() { - // reply 0: the driver turn (a cell that calls the *unnamed* sub-LLM). - // reply 1: served to that `llm(...)` call — proving the sub-LLM defaulted - // to the driver model since `sub_model` was left unset. - let registry = registry(vec![ - "```rhai\nlet r = llm(\"inner question\");\nfinal_answer(r)\n```", - "sub-llm answered", - ]); - let mut runner = - RlmRunner::from_config(config_with_driver(), registry, Arc::new(())).expect("runner"); - let outcome = runner.run("solve it").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("sub-llm answered")); - assert_eq!(outcome.stop_reason, RlmStopReason::Answered); - assert_eq!(outcome.sub_llm_calls, 1); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn injected_context_is_visible_to_driver_written_cells() { - let registry = registry(vec!["```rhai\nfinal_answer(context.label)\n```"]); - let mut runner = - RlmRunner::from_config(config_with_driver(), registry, Arc::new(())).expect("runner"); - runner - .set_context(json!({ "label": "from-context" })) - .await - .expect("set context"); - let outcome = runner.run("use the context").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("from-context")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn the_outcome_tallies_script_capability_calls() { - let registry = registry(vec![ - "```rhai\nlet t = tool(\"echo\", #{ q: 1 });\nlet l = llm(\"hi\");\nfinal_answer(\"done\")\n```", - "sub reply", - ]); - let mut runner = - RlmRunner::from_config(config_with_driver(), registry, Arc::new(())).expect("runner"); - let outcome = runner.run("do work").await.expect("run"); - assert_eq!(outcome.answer.as_deref(), Some("done")); - assert_eq!(outcome.tool_calls, 1); - assert_eq!(outcome.sub_llm_calls, 1); - assert_eq!(outcome.agent_calls, 0); - assert_eq!(outcome.driver_calls, 1); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn the_system_prompt_reflects_the_configured_template_and_capabilities() { - let registry = registry(vec![]); - let config = RlmConfig { - driver_model: Some("mock".to_string()), - template: TemplateSpec::Named("orchestrator".to_string()), - ..RlmConfig::default() - }; - let runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("runner"); - let prompt = runner.system_prompt(); - // Orchestrator scaffold language plus the live capability listing. - assert!(prompt.to_lowercase().contains("orchestrator")); - assert!(prompt.contains("```rhai")); - assert!(prompt.contains("echo")); - assert!(!prompt.contains("{{")); -} diff --git a/tests/feature_rlm_session.rs b/tests/feature_rlm_session.rs deleted file mode 100644 index 23ef56d9..00000000 --- a/tests/feature_rlm_session.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! Feature tests for the **RLM session lifecycle** and embedded-Rhai execution -//! semantics that the module's own unit tests leave under-covered: the -//! programmatic accessors (`language`, `usage_guide`, `cells_run`), building a -//! session over a pre-constructed interpreter backend, injecting structured -//! context, the fail-closed operation limit, cumulative call counts, idempotent -//! shutdown, and the sticky cancellation flag. -//! -//! All tests are offline: model/tool doubles come from `harness::testkit` and -//! the embedded Rhai engine has no OS access. - -#![cfg(feature = "rlm")] - -use std::sync::Arc; - -use serde_json::json; -use tinyagents::harness::testkit::{FakeTool, ScriptedModel}; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{ - InterpreterSpec, RlmCancelFlag, RlmHost, RlmPolicy, RlmSession, build_interpreter, -}; - -fn registry(replies: Vec<&str>) -> Arc> { - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("mock", Arc::new(ScriptedModel::replies(replies))) - .expect("register model"); - registry - .register_tool(Arc::new(FakeTool::returning("echo", "echoed"))) - .expect("register tool"); - Arc::new(registry) -} - -fn host(replies: Vec<&str>, policy: RlmPolicy) -> Arc> { - Arc::new( - RlmHost::new(registry(replies), Arc::new(())) - .with_policy(policy) - .with_default_model("mock"), - ) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_exposes_language_and_usage_guide() { - let session = RlmSession::new(&InterpreterSpec::Rhai, host(vec![], RlmPolicy::default())) - .expect("build session"); - assert_eq!(session.language(), "rhai"); - let guide = session.usage_guide(); - assert!(guide.contains("llm(")); - assert!(guide.contains("final_answer(")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_tracks_the_number_of_cells_run() { - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host(vec![], RlmPolicy::default())) - .expect("build session"); - assert_eq!(session.cells_run(), 0); - session.eval("1").await.expect("cell 1"); - session.eval("2").await.expect("cell 2"); - assert_eq!(session.cells_run(), 2); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_can_be_built_over_a_prebuilt_interpreter_backend() { - let interpreter = - build_interpreter(&InterpreterSpec::Rhai, RlmPolicy::default().max_operations) - .expect("build interpreter"); - let mut session = RlmSession::from_interpreter(interpreter, host(vec![], RlmPolicy::default())); - let outcome = session.eval("40 + 2").await.expect("cell"); - assert_eq!(outcome.value, Some(json!(42))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn injected_context_supports_nested_structures() { - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host(vec![], RlmPolicy::default())) - .expect("build session"); - session - .set_variable( - "context", - json!({ "user": { "roles": ["admin", "editor"] } }), - ) - .await - .expect("set context"); - let outcome = session.eval("context.user.roles[0]").await.expect("cell"); - assert_eq!(outcome.value, Some(json!("admin"))); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn runaway_operation_count_fails_closed() { - let policy = RlmPolicy { - max_operations: 10_000, - ..RlmPolicy::default() - }; - let mut session = - RlmSession::new(&InterpreterSpec::Rhai, host(vec![], policy)).expect("build session"); - let err = session - .eval("let n = 0; while n < 100000000 { n += 1; } n") - .await - .expect_err("operation limit must abort the cell"); - assert!( - matches!(err, tinyagents::TinyAgentsError::LimitExceeded(_)), - "got {err:?}" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn session_host_accumulates_capability_call_counts() { - let mut session = RlmSession::new( - &InterpreterSpec::Rhai, - host(vec!["a", "b"], RlmPolicy::default()), - ) - .expect("build session"); - assert_eq!(session.host().call_counts(), (0, 0, 0)); - - session - .eval(r#"llm("one"); tool("echo")"#) - .await - .expect("cell 1"); - session.eval(r#"llm("two")"#).await.expect("cell 2"); - - let (llm, tool, agent) = session.host().call_counts(); - assert_eq!((llm, tool, agent), (2, 1, 0)); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shutdown_is_idempotent_for_the_embedded_backend() { - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host(vec![], RlmPolicy::default())) - .expect("build session"); - session.shutdown().await.expect("first shutdown"); - session.shutdown().await.expect("second shutdown"); -} - -#[test] -fn cancel_flag_is_sticky_and_shared_across_clones() { - let flag = RlmCancelFlag::new(); - let clone = flag.clone(); - assert!(!flag.is_cancelled()); - assert!(!clone.is_cancelled()); - - clone.cancel(); - assert!( - flag.is_cancelled(), - "cancellation is observed by every clone" - ); - // Idempotent: cancelling again keeps it cancelled. - clone.cancel(); - assert!(flag.is_cancelled()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn cancelling_mid_session_refuses_further_cells() { - let flag = RlmCancelFlag::new(); - let host = Arc::new( - RlmHost::new(registry(vec![]), Arc::new(())) - .with_default_model("mock") - .with_cancel_flag(flag.clone()), - ); - let mut session = RlmSession::new(&InterpreterSpec::Rhai, host).expect("build session"); - - session - .eval("1 + 1") - .await - .expect("first cell before cancel"); - flag.cancel(); - let err = session - .eval("2 + 2") - .await - .expect_err("cancelled session must refuse work"); - assert!(matches!(err, tinyagents::TinyAgentsError::Cancelled)); -} diff --git a/tests/feature_rlm_templates.rs b/tests/feature_rlm_templates.rs deleted file mode 100644 index 86dd8332..00000000 --- a/tests/feature_rlm_templates.rs +++ /dev/null @@ -1,124 +0,0 @@ -//! Feature tests for the **RLM prompt templates**: the built-in template -//! catalogue, fail-closed resolution of unknown names, inline template -//! pass-through, and the placeholder renderer's substitution of language, -//! usage guide, live capabilities, and policy limits. -//! -//! The renderer is pure and offline, so these build a `CapabilityListing` -//! directly and assert on the rendered prompt text. - -#![cfg(feature = "rlm")] - -use tinyagents::rlm::templates; -use tinyagents::rlm::{CapabilityListing, RlmPolicy, RlmTemplate, TemplateSpec}; - -#[test] -fn resolves_each_built_in_template_by_name() { - for name in ["general", "context-explorer", "orchestrator"] { - let template = - templates::resolve(&TemplateSpec::Named(name.to_string())).expect("resolve built-in"); - assert_eq!(template.name, name); - assert!(template.system_prompt.contains("{{language}}")); - assert!(template.system_prompt.contains("final_answer(")); - } -} - -#[test] -fn the_orchestrator_template_describes_delegation() { - let template = templates::orchestrator(); - assert!(template.system_prompt.contains("agent(")); - assert!(template.system_prompt.to_lowercase().contains("delegate")); -} - -#[test] -fn the_context_explorer_template_describes_the_context_variable() { - let template = templates::context_explorer(); - assert!(template.system_prompt.contains("`context`")); -} - -#[test] -fn an_unknown_named_template_fails_closed_with_the_catalogue() { - let err = templates::resolve(&TemplateSpec::Named("does-not-exist".to_string())) - .expect_err("unknown template must fail"); - match err { - tinyagents::TinyAgentsError::Validation(message) => { - assert!(message.contains("does-not-exist")); - assert!(message.contains("general")); - assert!(message.contains("orchestrator")); - } - other => panic!("expected Validation, got {other:?}"), - } -} - -#[test] -fn an_inline_template_is_returned_verbatim() { - let inline = RlmTemplate { - name: "bespoke".to_string(), - system_prompt: "custom {{language}} scaffold".to_string(), - }; - let resolved = - templates::resolve(&TemplateSpec::Inline(inline.clone())).expect("resolve inline"); - assert_eq!(resolved, inline); -} - -#[test] -fn rendering_substitutes_every_placeholder() { - let listing = CapabilityListing { - models: vec!["gpt".to_string()], - tools: vec![("search".to_string(), "Searches the web.".to_string())], - agents: vec!["planner".to_string()], - }; - let prompt = templates::render_system_prompt( - &templates::general(), - "python", - "USAGE-GUIDE-MARKER", - &listing, - &RlmPolicy::default(), - ); - assert!( - !prompt.contains("{{"), - "no placeholder must remain: {prompt}" - ); - assert!(prompt.contains("```python")); - assert!(prompt.contains("USAGE-GUIDE-MARKER")); - assert!(prompt.contains("search: Searches the web.")); - assert!(prompt.contains("planner")); - assert!(prompt.contains("max cells: 16")); -} - -#[test] -fn rendering_marks_an_empty_registry_as_none_registered() { - let prompt = templates::render_system_prompt( - &templates::general(), - "rhai", - "guide", - &CapabilityListing::default(), - &RlmPolicy::default(), - ); - assert!(prompt.contains("models: (none registered)")); - assert!(prompt.contains("tools: (none registered)")); - assert!(prompt.contains("agents: (none registered)")); -} - -#[test] -fn rendering_reflects_a_customised_policy_and_absent_timeout() { - let policy = RlmPolicy { - max_cells: 3, - max_llm_calls: 5, - max_tool_calls: 7, - max_agent_calls: 9, - cell_timeout: None, - ..RlmPolicy::default() - }; - let prompt = templates::render_system_prompt( - &templates::general(), - "rhai", - "guide", - &CapabilityListing::default(), - &policy, - ); - assert!(prompt.contains("max cells: 3")); - assert!(prompt.contains("max sub-LLM calls: 5")); - assert!(prompt.contains("max tool calls: 7")); - assert!(prompt.contains("max agent calls: 9")); - assert!(prompt.contains("per-cell timeout: none")); -} diff --git a/tests/live_rlm.rs b/tests/live_rlm.rs deleted file mode 100644 index 21215be4..00000000 --- a/tests/live_rlm.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! Live, network-gated RLM tests (feature = "rlm"). -//! -//! Skips gracefully (early return, not a panic) when `OPENAI_API_KEY` is not -//! set, following the `live_*.rs` convention. Assertions are structural only -//! (an answer was produced, cells actually executed) — never on exact model -//! prose. - -#![cfg(feature = "rlm")] - -use std::sync::Arc; - -use tinyagents::harness::providers::openai::OpenAiModel; -use tinyagents::registry::CapabilityRegistry; -use tinyagents::rlm::{RlmConfig, RlmRunner, RlmStopReason}; - -fn live_registry() -> Option>> { - let _ = dotenvy::dotenv(); - if std::env::var("OPENAI_API_KEY").is_err() { - eprintln!("skipping: OPENAI_API_KEY is not set"); - return None; - } - let mut registry: CapabilityRegistry<()> = CapabilityRegistry::new(); - registry - .register_model("openai", Arc::new(OpenAiModel::from_env().expect("model"))) - .expect("register model"); - Some(Arc::new(registry)) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn live_rhai_rlm_computes_with_code() { - let Some(registry) = live_registry() else { - return; - }; - let config = RlmConfig::from_json( - r#"{ - "interpreter": {"kind": "rhai"}, - "driver_model": "openai", - "template": "general", - "policy": {"max_cells": 6, "cell_timeout": 60000} - }"#, - ) - .expect("config"); - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("runner"); - let outcome = runner - .run( - "Compute the exact sum of the cubes of the integers from 1 to 25 with code, then \ - return just that number as the final answer.", - ) - .await - .expect("run"); - // 1³+…+25³ = (25·26/2)² = 105625. - let answer = outcome.answer.expect("an answer"); - assert!(answer.contains("105625"), "unexpected answer: {answer}"); - assert!( - !outcome.steps.is_empty(), - "the model must have executed at least one cell" - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn live_python_rlm_probes_an_injected_context() { - if std::process::Command::new("python3") - .arg("--version") - .output() - .is_err() - { - eprintln!("skipping: python3 is not on PATH"); - return; - } - let Some(registry) = live_registry() else { - return; - }; - let config = RlmConfig::from_json( - r#"{ - "interpreter": {"kind": "python"}, - "driver_model": "openai", - "template": "context-explorer", - "policy": {"max_cells": 6, "cell_timeout": 60000} - }"#, - ) - .expect("config"); - let mut runner = RlmRunner::from_config(config, registry, Arc::new(())).expect("runner"); - - // A context with a needle the model must find programmatically. - let mut lines: Vec = (0..500) - .map(|i| format!("log line {i}: heartbeat ok")) - .collect(); - lines[317] = "log line 317: FATAL disk failure on node srv-42".to_string(); - runner - .set_context(serde_json::json!(lines.join("\n"))) - .await - .expect("set context"); - - let outcome = runner - .run("Exactly one log line in `context` is not a heartbeat. Which node failed?") - .await - .expect("run"); - let answer = outcome.answer.expect("an answer"); - assert!(answer.contains("srv-42"), "unexpected answer: {answer}"); - assert_ne!(outcome.stop_reason, RlmStopReason::CellBudgetExhausted); - runner.shutdown().await.expect("shutdown"); -} diff --git a/tests/repl_session.rs b/tests/repl_session.rs deleted file mode 100644 index b372f1c0..00000000 --- a/tests/repl_session.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! End-to-end coverage for the REPL command parser and session runtime. -//! -//! These tests exercise the public surface re-exported from -//! `tinyagents::repl`: parsing representative command lines, running a -//! [`ReplSession`] through `set`/`get`/`show`, and enforcing the -//! [`CapabilityPolicy`] allowlist on `call`. - -use tinyagents::TinyAgentsError; -use tinyagents::repl::{CapabilityPolicy, ReplCommand, ReplOutcome, ReplSession, parse_command}; - -#[test] -fn parses_representative_commands() { - assert_eq!( - parse_command("set region us-east").unwrap(), - ReplCommand::Set { - key: "region".to_string(), - value: "us-east".to_string(), - } - ); - - assert_eq!( - parse_command(r#"set greeting "hello world""#).unwrap(), - ReplCommand::Set { - key: "greeting".to_string(), - value: "hello world".to_string(), - } - ); - - assert_eq!( - parse_command("get region").unwrap(), - ReplCommand::Get { - key: "region".to_string(), - } - ); - - assert_eq!( - parse_command("show vars").unwrap(), - ReplCommand::Show { - what: "vars".to_string(), - } - ); - - assert_eq!( - parse_command(r#"call lookup_user {"user_id": "usr_123"}"#).unwrap(), - ReplCommand::Call { - capability: "lookup_user".to_string(), - args: serde_json::json!({ "user_id": "usr_123" }), - } - ); - - assert_eq!(parse_command("quit").unwrap(), ReplCommand::Quit); -} - -#[test] -fn session_runs_set_get_and_show() { - let mut session = ReplSession::new(); - - // `set` is a side-effect-free acknowledgement message. - let set_outcome = session - .execute(ReplCommand::Set { - key: "env".to_string(), - value: "production".to_string(), - }) - .unwrap(); - assert!(matches!(set_outcome, ReplOutcome::Message(_))); - - // `get` round-trips the stored value as JSON. - let get_outcome = session - .execute(ReplCommand::Get { - key: "env".to_string(), - }) - .unwrap(); - assert_eq!( - get_outcome, - ReplOutcome::Value(serde_json::json!("production")) - ); - - // `show vars` reflects everything in the namespace. - let show_vars = session - .execute(ReplCommand::Show { - what: "vars".to_string(), - }) - .unwrap(); - match show_vars { - ReplOutcome::Value(v) => assert_eq!(v["env"], serde_json::json!("production")), - other => panic!("expected Value outcome for `show vars`, got {other:?}"), - } - - // `show status` reports the variable count. - let show_status = session - .execute(ReplCommand::Show { - what: "status".to_string(), - }) - .unwrap(); - match show_status { - ReplOutcome::Value(v) => assert_eq!(v["variables"], serde_json::json!(1)), - other => panic!("expected Value outcome for `show status`, got {other:?}"), - } - - // Every executed command is recorded in history. - assert_eq!(session.history.len(), 4); -} - -#[test] -fn disallowed_capability_call_is_rejected() { - // A fresh session has a deny-all policy. - let mut session = ReplSession::new(); - - let err = session - .execute(ReplCommand::Call { - capability: "secret_tool".to_string(), - args: serde_json::Value::Null, - }) - .unwrap_err(); - - match err { - TinyAgentsError::Capability(msg) => { - assert!( - msg.contains("secret_tool"), - "error should name the capability: {msg}" - ); - } - other => panic!("expected Capability error, got {other:?}"), - } -} - -#[test] -fn allowed_capability_call_returns_planned() { - let policy = CapabilityPolicy::from_list(["lookup_user"]); - let mut session = ReplSession::new().with_policy(policy); - - let outcome = session - .execute(ReplCommand::Call { - capability: "lookup_user".to_string(), - args: serde_json::json!({ "user_id": "usr_42" }), - }) - .unwrap(); - - match outcome { - ReplOutcome::Planned { action, detail } => { - assert_eq!(action, "capability_call"); - assert_eq!(detail["capability"], "lookup_user"); - assert_eq!(detail["args"]["user_id"], "usr_42"); - } - other => panic!("expected Planned outcome, got {other:?}"), - } -}