diff --git a/Cargo.lock b/Cargo.lock index 95ee2f60..8d70c83c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1324,6 +1324,7 @@ version = "0.6.1" dependencies = [ "serde", "serde_json", + "thiserror", ] [[package]] diff --git a/crates/tinyjuice-bus/Cargo.toml b/crates/tinyjuice-bus/Cargo.toml index 159d3745..3d8759e3 100644 --- a/crates/tinyjuice-bus/Cargo.toml +++ b/crates/tinyjuice-bus/Cargo.toml @@ -16,6 +16,8 @@ publish = false # The wire payloads are encoded into TinyBus JSON frames. serde = { version = "1", features = ["derive"] } serde_json = "1" +# Shared REPL error vocabulary; no execution dependencies. +thiserror = "2" [dev-dependencies] # Contract tests pin the JSON representation hosts exchange with the module. diff --git a/crates/tinyjuice-bus/README.md b/crates/tinyjuice-bus/README.md index 11df5076..27c130f1 100644 --- a/crates/tinyjuice-bus/README.md +++ b/crates/tinyjuice-bus/README.md @@ -5,9 +5,27 @@ the interface names, the request and response types, and the contract version. A host loads `tinyjuice-module` as a dynamic library and cannot import Rust items from it. This crate is what supplies the call vocabulary instead. It is -`serde` and nothing else — no TinyBus, no async runtime, no compression code — -so linking it costs a host almost nothing. +`serde`, JSON Schema declarations, and error derives — no TinyBus, no async runtime, no compression code — +so hosts can declare tools and exchange typed requests without linking an engine. The values here are **moved out of** the `tinyjuice` library rather than copied from it: `tinyjuice::types` re-exports them, so there is one definition of each and a host is looking at the same bytes the module validates against. + + +Contract 1.2 adds `Query(QueryRequest)` and `ExtractHtml(content)`. `Query` +accepts either a CCR handle or supplied content that the host has authorized, +plus a typed REPL operation, limits, and an optional summary callback ticket. +The response distinguishes expired handles and operation errors from transport +failures. `Repl(handle, op_json)` retains its existing two-argument form. + +`repl` owns the serialized operation/result vocabulary; `tools` owns the stock +REPL and retrieval declarations and summary-focus schema. The implementation +crate re-exports these definitions for library compatibility. The summary +callback prompt and unavailable notices are shared in `summary`. + + +See [the module specification](../../docs/specs/tinybus-module.md) for request +examples, operation tags/defaults, reply shapes, and the fixed input/output +ceilings. HTML extraction returns `Result` so input-size +rejections remain distinct from transport failures. diff --git a/crates/tinyjuice-bus/src/lib.rs b/crates/tinyjuice-bus/src/lib.rs index ab4eed12..ff20152e 100644 --- a/crates/tinyjuice-bus/src/lib.rs +++ b/crates/tinyjuice-bus/src/lib.rs @@ -12,6 +12,9 @@ //! re-exports the shared values from here rather than defining a second copy. pub mod names; +pub mod repl; +pub mod summary; +pub mod tools; pub mod types; pub mod version; pub mod wire; @@ -29,4 +32,4 @@ pub use wire::{ #[cfg(test)] #[path = "lib_tests.rs"] -mod test; +mod tests; diff --git a/crates/tinyjuice-bus/src/lib_tests.rs b/crates/tinyjuice-bus/src/lib_tests.rs index 25f7ba7c..08cdbdd2 100644 --- a/crates/tinyjuice-bus/src/lib_tests.rs +++ b/crates/tinyjuice-bus/src/lib_tests.rs @@ -1,10 +1,6 @@ //! Tests that pin the shared `TinyJuice` vocabulary and compatibility rule. -use super::{ - AgentTokenjuiceCompression, CONTRACT_VERSION, CacheStats, CompactRequest, CompressOptions, - CompressorKind, ContentKind, GenerateRequest, LlmSummaryMode, RangeUnit, RetrieveRange, - is_compatible, -}; +use super::*; #[test] fn the_contract_accepts_its_own_version_and_newer_minors() { @@ -176,3 +172,78 @@ fn a_generate_request_keeps_its_camel_case_fields() { r#"{"contextToken":"t","purpose":"tool_output_summary","system":"s","prompt":"p","maxOutputTokens":7}"# ); } + +#[test] +fn typed_queries_preserve_the_legacy_operation_wire_shape() { + use crate::repl::{FindMode, ReplOp, ScopeUnit}; + use crate::wire::{QueryRequest, QueryTarget}; + let value = serde_json::json!({ + "target": {"kind": "handle", "token": "abc"}, + "op": {"op": "find", "query": "needle"} + }); + let request: QueryRequest = serde_json::from_value(value).unwrap(); + assert_eq!( + request.target, + QueryTarget::Handle { + token: "abc".into() + } + ); + assert_eq!(request.limits, crate::repl::ReplLimits::default()); + assert_eq!( + request.op, + ReplOp::Find { + query: "needle".into(), + mode: FindMode::Text, + ignore_case: false, + context: 0, + top_k: None, + scope: None, + unit: ScopeUnit::Lines, + } + ); + let round_trip: QueryRequest = + serde_json::from_value(serde_json::to_value(&request).unwrap()).unwrap(); + assert_eq!(round_trip, request); + assert!( + !is_compatible((1, 1)), + "typed query hosts need contract 1.2" + ); +} + +#[test] +fn query_replies_distinguish_operation_errors_from_success() { + use crate::wire::{QueryError, QueryResponse}; + let reply: QueryResponse = Err(QueryError::HandleNotFound); + assert_eq!( + serde_json::to_value(&reply).unwrap(), + serde_json::json!({"Err": {"kind": "handle_not_found"}}) + ); + let parsed: QueryResponse = + serde_json::from_value(serde_json::to_value(&reply).unwrap()).unwrap(); + assert_eq!(reply, parsed); + let reply: QueryResponse = Ok(crate::repl::ReplOutput::Text { + text: "overview".into(), + }); + assert_eq!( + serde_json::to_value(reply).unwrap(), + serde_json::json!({"Ok": {"kind": "text", "text": "overview"}}) + ); +} + +#[test] +fn declarations_round_trip_and_keep_the_existing_tool_names() { + let declarations = crate::tools::repl_tool_declarations(); + assert_eq!( + declarations + .iter() + .map(|tool| tool.name.as_str()) + .collect::>(), + ["juice_find", "juice_extract", "juice_summarize"] + ); + for declaration in declarations { + let decoded: crate::tools::ReplToolDeclaration = + serde_json::from_value(serde_json::to_value(&declaration).unwrap()).unwrap(); + assert_eq!(decoded, declaration); + assert_eq!(declaration.parameters["required"][0], "handle"); + } +} diff --git a/crates/tinyjuice-bus/src/names.rs b/crates/tinyjuice-bus/src/names.rs index d34f9a25..aae81895 100644 --- a/crates/tinyjuice-bus/src/names.rs +++ b/crates/tinyjuice-bus/src/names.rs @@ -33,6 +33,12 @@ pub mod methods { pub const COMPACT_WITH: &str = "CompactWith"; /// Reads back an original the module offloaded. pub const RETRIEVE: &str = "Retrieve"; + /// Legacy JSON REPL query; retains its two positional arguments. + pub const REPL: &str = "Repl"; + /// Typed REPL query over module storage or supplied content. + pub const QUERY: &str = "Query"; + /// Convert HTML to Markdown inside the module. + pub const EXTRACT_HTML: &str = "ExtractHtml"; /// Reports what the cache is holding. pub const CACHE_STATS: &str = "CacheStats"; } @@ -55,5 +61,8 @@ pub const METHODS: &[&str] = &[ methods::COMPACT, methods::COMPACT_WITH, methods::RETRIEVE, + methods::REPL, + methods::QUERY, + methods::EXTRACT_HTML, methods::CACHE_STATS, ]; diff --git a/crates/tinyjuice-bus/src/repl.rs b/crates/tinyjuice-bus/src/repl.rs new file mode 100644 index 00000000..139ab8fe --- /dev/null +++ b/crates/tinyjuice-bus/src/repl.rs @@ -0,0 +1,187 @@ +//! Shared data for REPL-style output inspection. + +use serde::{Deserialize, Serialize}; + +/// Unit of a Python-style scope slice. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ScopeUnit { + /// Count lines (the default). + #[default] + Lines, + /// Count Unicode characters. + Chars, +} + +/// Caps applied to every op so a query can never return the whole payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct ReplLimits { + pub max_hits: usize, + /// Cap for range-producing modes (`sed`, `awk`) and expanded grep context. + pub max_lines: usize, + pub max_output_chars: usize, + pub max_line_chars: usize, + pub regex_size_limit: usize, +} + +impl Default for ReplLimits { + fn default() -> Self { + Self { + max_hits: 50, + max_lines: 400, + max_output_chars: 8_000, + max_line_chars: 240, + regex_size_limit: 1 << 20, + } + } +} + +/// A located line. `line` is 1-based. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Hit { + pub line: usize, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RegexMatch { + pub line: usize, + pub text: String, + pub captures: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SearchHit { + pub line_start: usize, + pub line_end: usize, + pub score: f32, + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Heading { + pub level: u8, + pub text: String, + pub line: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Link { + pub text: String, + pub href: String, +} + +/// How `find` reads its query. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FindMode { + /// Case-insensitive substring. + #[default] + Text, + /// Regex per line (optionally case-insensitive, with context lines). + Grep, + /// Regex per line, returning capture groups. + Regex, + /// BM25-ranked windows of lines. + Rank, + /// sed subset: `-n 10,20p`, `/re/d`, `s/a/b/g`. + Sed, + /// awk subset: `-F, '$3 > 5 { print $1, $NF }'`. + Awk, + /// jq filter over JSON output: `.items[] | select(.size > 10) | .name`. + Jq, +} + +/// What `extract` pulls out of HTML or Markdown. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtractKind { + Links, + Headings, +} + +/// An op over a stored original. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum ReplOp { + Find { + query: String, + #[serde(default)] + mode: FindMode, + #[serde(default)] + ignore_case: bool, + #[serde(default)] + context: usize, + #[serde(default)] + top_k: Option, + /// Python-style slice to search within, e.g. `[:-100]`. + #[serde(default)] + scope: Option, + #[serde(default)] + unit: ScopeUnit, + }, + Extract { + what: ExtractKind, + #[serde(default)] + scope: Option, + #[serde(default)] + unit: ScopeUnit, + }, + Summarize { + #[serde(default)] + max_chars: Option, + /// What the caller cares about; ranks which parts of the scope to keep. + #[serde(default)] + hint: Option, + #[serde(default)] + scope: Option, + #[serde(default)] + unit: ScopeUnit, + }, +} + +/// Result of an op. `truncated` counts hits dropped by a cap. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ReplOutput { + Lines { + hits: Vec, + truncated: usize, + }, + Matches { + matches: Vec, + truncated: usize, + }, + Search { + hits: Vec, + truncated: usize, + }, + Links { + links: Vec, + truncated: usize, + }, + Headings { + headings: Vec, + truncated: usize, + }, + Values { + values: Vec, + truncated: usize, + }, + Text { + text: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ReplError { + #[error("handle not found or expired; re-run the original tool")] + HandleNotFound, + #[error("invalid pattern: {0}")] + InvalidPattern(String), + #[error("empty query")] + EmptyQuery, + #[error("{0} support is not compiled in")] + Unsupported(&'static str), +} diff --git a/crates/tinyjuice-bus/src/summary.rs b/crates/tinyjuice-bus/src/summary.rs new file mode 100644 index 00000000..5ef7643a --- /dev/null +++ b/crates/tinyjuice-bus/src/summary.rs @@ -0,0 +1,54 @@ +//! Summary callback prompt and error vocabulary shared with hosts. + +/// Prompt used by the tool-output summary callback. +pub const SYSTEM_PROMPT: &str = include_str!("summary_prompt.md"); + +/// Why a payload that qualified for a summary did not get one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnavailableReason { + /// Larger than `llm_summary_max_input_tokens`. + PayloadTooLarge, + /// Model summaries are disabled or the breaker is open for this scope. + Disabled, + /// The model call failed or its reply was empty or not smaller. + Failed, + /// The model call outlasted `llm_summary_timeout_ms`. + TimedOut, +} + +impl UnavailableReason { + /// The model-facing notice for this reason. + /// + /// A host prefixes it to the tool result *after* its own caps have run, so + /// a cap cannot cut it. Every variant ends with the same bare instruction — + /// do not re-run the tool for a summary — because the reasonable response + /// to an unsummarized dump is otherwise to call the tool again, which reads + /// to a user as a hang. It carries no justifying clause: each one tried + /// ("it will return the same result", "the full output is already here") + /// was a claim this code cannot make for every tool. + #[must_use] + pub fn notice(self) -> &'static str { + match self { + Self::PayloadTooLarge => concat!( + "[summarization unavailable — this output exceeds the summarizer's ", + "size cap, so the tool output follows and may be truncated. ", + "Do not re-run the tool for a summary.]" + ), + Self::Disabled => concat!( + "[summarization unavailable — model summaries are disabled for this scope, ", + "so the tool output follows. ", + "Do not re-run the tool for a summary.]" + ), + Self::TimedOut => concat!( + "[summarization timed out — the tool output follows. If a recovery handle ", + "appears in its footer, inspect the stored output with the juice_* tools ", + "using that handle. Do not re-run the tool for a summary.]" + ), + Self::Failed => concat!( + "[summarization unavailable — the summarizer did not return a usable ", + "summary for this result, so the tool output follows. ", + "Do not re-run the tool for a summary.]" + ), + } + } +} diff --git a/src/summarize/prompt.md b/crates/tinyjuice-bus/src/summary_prompt.md similarity index 100% rename from src/summarize/prompt.md rename to crates/tinyjuice-bus/src/summary_prompt.md diff --git a/crates/tinyjuice-bus/src/tools.rs b/crates/tinyjuice-bus/src/tools.rs new file mode 100644 index 00000000..55694906 --- /dev/null +++ b/crates/tinyjuice-bus/src/tools.rs @@ -0,0 +1,110 @@ +//! Tool declarations shared by library adapters and module-only hosts. + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// A tool's frozen public declaration and its corresponding REPL operation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReplToolDeclaration { + /// Existing product tool name. + pub name: String, + /// Operation tag inserted into the REPL request. + pub op: String, + /// Description presented to the model. + pub description: String, + /// JSON Schema for the tool's arguments. + pub parameters: Value, +} + +/// The stock REPL declarations, preserving their existing tool schemas. +#[must_use] +pub fn repl_tool_declarations() -> Vec { + let t = |name: &str, op: &str, description: &str, extra: Value, required: &[&str]| { + let mut props = json!({ "handle": { "type": "string", "description": "the handle named in the output's footer" } }); + if let (Some(p), Some(e)) = (props.as_object_mut(), extra.as_object()) { + p.extend(e.clone()); + } + let mut all_required = vec!["handle"]; + all_required.extend_from_slice(required); + ReplToolDeclaration { + name: name.into(), + op: op.into(), + description: description.into(), + parameters: json!({ "type": "object", "properties": props, "required": all_required }), + } + }; + vec![ + t( + "juice_find", + "find", + "Read or search part of a stored tool output by its handle, without loading all of it. mode `sed` reads exact lines (query `-n 120,200p`); `grep`/`regex` search (with `context` lines; regex returns capture groups); `text` substring; `rank` BM25; `awk` (`-F, '$2>5{print $1}'`); `jq` for JSON. Prefer this to juice_retrieve when you need only part of an output.", + json!({ + "query": { "type": "string" }, + "mode": { "type": "string", "enum": ["text", "grep", "regex", "rank", "sed", "awk", "jq"] }, + "ignore_case": { "type": "boolean" }, + "context": { "type": "integer" }, + "top_k": { "type": "integer" }, + "scope": { "type": "string", "description": "python slice, e.g. [:-100]" }, + "unit": { "type": "string", "enum": ["lines", "chars"] } + }), + &["query"], + ), + t( + "juice_extract", + "extract", + "HTML or Markdown outputs only: list their links or headings.", + json!({ + "what": { "type": "string", "enum": ["links", "headings"] }, + "scope": { "type": "string" }, + "unit": { "type": "string", "enum": ["lines", "chars"] } + }), + &["what"], + ), + t( + "juice_summarize", + "summarize", + "Model-free overview of a stored output: size, outline or JSON shape, then head and tail; with `hint`, the parts most relevant to it. Use it to find where to read with juice_find.", + json!({ + "hint": { "type": "string", "description": "what you need from it" }, + "max_chars": { "type": "integer" }, + "scope": { "type": "string" }, + "unit": { "type": "string", "enum": ["lines", "chars"] } + }), + &[], + ), + ] +} + +/// The argument's name on the wire. +pub const SUMMARY_FOCUS_ARG: &str = "summary_focus"; + +/// The schema for the optional `summary_focus` argument. +pub fn summary_focus_property() -> Value { + json!({ + "type": "string", + "description": "What you need from this result. A large result is summarized around \ + this; the full output stays retrievable." + }) +} + +/// Description of the existing retrieval tool. +pub const RETRIEVE_TOOL_DESCRIPTION: &str = "Retrieve the full, original text of a tool result that was compacted to \ + save context. When a tool output shows a marker like \ + `retrieve_tool_output(\"a1b2c3d4e5f6\")`, call this with that hash to get \ + the complete original back. Use it only when you actually need the dropped \ + detail — the compacted view is usually enough."; + +/// Existing retrieval tool argument schema. +#[must_use] +pub fn retrieve_tool_parameters() -> Value { + json!({ + "type": "object", + "properties": { + "hash": { + "type": "string", + "description": "The hash from a retrieve_tool_output(\"…\") marker." + } + }, + "required": ["hash"] + }) +} diff --git a/crates/tinyjuice-bus/src/version.rs b/crates/tinyjuice-bus/src/version.rs index 3aa6e96a..86aa328b 100644 --- a/crates/tinyjuice-bus/src/version.rs +++ b/crates/tinyjuice-bus/src/version.rs @@ -1,7 +1,7 @@ //! The `TinyJuice` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 1); +pub const CONTRACT_VERSION: (u32, u32) = (1, 2); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinyjuice-bus/src/wire.rs b/crates/tinyjuice-bus/src/wire.rs index 721bc75b..80b48a02 100644 --- a/crates/tinyjuice-bus/src/wire.rs +++ b/crates/tinyjuice-bus/src/wire.rs @@ -151,3 +151,68 @@ pub struct CacheStats { /// Bytes those originals occupy. pub bytes: usize, } + +/// Content to inspect without transferring a cached original back to the host. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum QueryTarget { + /// Resolve the original from the module's CCR store. + Handle { token: String }, + /// Inspect artifact content the host has already authorized and read. + Content { content: String }, +} + +/// A bounded REPL query executed inside the module. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct QueryRequest { + /// Stored handle or supplied artifact content; never a filesystem path. + pub target: QueryTarget, + /// Typed operation, including its optional scope. + pub op: crate::repl::ReplOp, + /// Output and pattern compilation caps. + #[serde(default)] + pub limits: crate::repl::ReplLimits, + /// Turn-bound model callback ticket for an on-demand summary. + #[serde(default)] + pub context_token: Option, + /// Scope for summary reuse and failure suppression. + #[serde(default)] + pub scope: Option, +} + +/// Structured failures of a REPL operation, distinct from transport failures. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", content = "detail", rename_all = "snake_case")] +pub enum QueryError { + /// The module no longer has this original. + HandleNotFound, + /// Supplied content exceeds the module's fixed input ceiling. + InputTooLarge, + /// The operation contains an invalid pattern or slice. + InvalidPattern(String), + /// A search query was empty. + EmptyQuery, + /// The compiled module lacks this optional operation. + Unsupported(String), +} + +/// A REPL result or an operation error, encoded as a single bus reply. +pub type QueryResponse = Result; + +/// Largest supplied artifact content accepted by `Query` (the host file-read cap). +pub const MAX_QUERY_CONTENT_BYTES: usize = 10 * 1024 * 1024; + +/// Largest HTML input accepted by `ExtractHtml` (the web extractor input cap). +pub const MAX_HTML_INPUT_BYTES: usize = 8 * 1024 * 1024; + +/// Structured HTML extraction failures, separate from transport errors. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum HtmlError { + /// The supplied input exceeds the fixed parser ceiling. + InputTooLarge, +} + +/// Markdown or a structured extraction error. +pub type HtmlResponse = Result; diff --git a/crates/tinyjuice-module/src/service.rs b/crates/tinyjuice-module/src/service.rs index ed03b720..9979fb95 100644 --- a/crates/tinyjuice-module/src/service.rs +++ b/crates/tinyjuice-module/src/service.rs @@ -110,12 +110,82 @@ impl Compression { Ok(reply.to_string()) } + /// A typed query always executes against module-owned storage or supplied text. + async fn query( + &self, + request: tinyjuice_bus::wire::QueryRequest, + ) -> BusResult { + use tinyjuice_bus::wire::{QueryError, QueryTarget}; + let limits = query_limits(request.limits); + let model = request + .context_token + .map(|context_token| tinyjuice::repl::ModelSummary { + options: tinyjuice::tool_integration::current_options(), + context_token, + scope: request.scope, + }); + let result = match request.target { + QueryTarget::Handle { token } => { + tinyjuice::repl::run_op_with_model( + &tinyjuice::cache::GlobalCcrStore, + &token, + &request.op, + &limits, + model.as_ref(), + ) + .await + } + QueryTarget::Content { content } => { + if content.len() > tinyjuice_bus::wire::MAX_QUERY_CONTENT_BYTES { + return Ok(Err(QueryError::InputTooLarge)); + } + tinyjuice::repl::run_on_text_with_model( + &content, + &request.op, + &limits, + model.as_ref(), + ) + .await + } + }; + Ok(result.map_err(|error| match error { + tinyjuice::repl::ReplError::HandleNotFound => QueryError::HandleNotFound, + tinyjuice::repl::ReplError::InvalidPattern(detail) => { + QueryError::InvalidPattern(detail) + } + tinyjuice::repl::ReplError::EmptyQuery => QueryError::EmptyQuery, + tinyjuice::repl::ReplError::Unsupported(detail) => { + QueryError::Unsupported(detail.into()) + } + })) + } + + async fn extract_html(&self, content: String) -> BusResult { + if content.len() > tinyjuice_bus::wire::MAX_HTML_INPUT_BYTES { + return Ok(Err(tinyjuice_bus::wire::HtmlError::InputTooLarge)); + } + Ok(Ok(tinyjuice::compressors::html::html_to_markdown(&content))) + } + async fn cache_stats(&self) -> BusResult { let (entries, bytes) = tinyjuice::cache::stats(); Ok(CacheStats { entries, bytes }) } } +/// Requests may narrow a budget but never expand the module's stock ceilings. +fn query_limits(requested: tinyjuice_bus::repl::ReplLimits) -> tinyjuice_bus::repl::ReplLimits { + use tinyjuice_bus::repl::ReplLimits; + let ceiling = ReplLimits::default(); + ReplLimits { + max_hits: requested.max_hits.min(ceiling.max_hits), + max_lines: requested.max_lines.min(ceiling.max_lines), + max_output_chars: requested.max_output_chars.min(ceiling.max_output_chars), + max_line_chars: requested.max_line_chars.min(ceiling.max_line_chars), + regex_size_limit: requested.regex_size_limit.min(ceiling.regex_size_limit), + } +} + /// `Compact` and `CompactWith` share one body; the positional form is /// `CompactWith` with no arguments, focus or context. `enabled = false` turns /// the content router off but not the summary stage, which is gated by its @@ -223,6 +293,8 @@ mod exports { "CompactWith", "Retrieve", "Repl", + "Query", + "ExtractHtml", "CacheStats" ], signals = [], diff --git a/crates/tinyjuice-module/src/service_tests.rs b/crates/tinyjuice-module/src/service_tests.rs index 902e5506..60fd190b 100644 --- a/crates/tinyjuice-module/src/service_tests.rs +++ b/crates/tinyjuice-module/src/service_tests.rs @@ -76,3 +76,49 @@ async fn service_repl_greps_a_stored_output_and_reports_bad_input() { .unwrap(); assert!(gone.contains("expired")); } + +#[test] +fn caller_limits_cannot_expand_any_module_ceiling() { + use tinyjuice_bus::repl::ReplLimits; + let unlimited = ReplLimits { + max_hits: usize::MAX, + max_lines: usize::MAX, + max_output_chars: usize::MAX, + max_line_chars: usize::MAX, + regex_size_limit: usize::MAX, + }; + assert_eq!(query_limits(unlimited), ReplLimits::default()); + let narrowed = ReplLimits { + max_hits: 1, + max_lines: 2, + max_output_chars: 3, + max_line_chars: 4, + regex_size_limit: 5, + }; + assert_eq!(query_limits(narrowed), narrowed); +} + +#[tokio::test] +async fn oversized_supplied_inputs_are_rejected_before_execution() { + use tinyjuice_bus::wire::*; + let query = QueryRequest { + target: QueryTarget::Content { + content: "x".repeat(MAX_QUERY_CONTENT_BYTES + 1), + }, + op: serde_json::from_value(serde_json::json!({"op":"find","query":""})).unwrap(), + limits: Default::default(), + context_token: None, + scope: None, + }; + assert_eq!( + Compression.query(query).await.unwrap(), + Err(QueryError::InputTooLarge) + ); + assert_eq!( + Compression + .extract_html("x".repeat(MAX_HTML_INPUT_BYTES + 1)) + .await + .unwrap(), + Err(HtmlError::InputTooLarge) + ); +} diff --git a/crates/tinyjuice-module/tests/module_e2e.rs b/crates/tinyjuice-module/tests/module_e2e.rs index 8eaa7787..46ce9261 100644 --- a/crates/tinyjuice-module/tests/module_e2e.rs +++ b/crates/tinyjuice-module/tests/module_e2e.rs @@ -10,15 +10,7 @@ use tinybus::transport::memory::MemoryBus; use tinyjuice::types::CompressedOutput; use tinyjuice_module::{BUS_NAME, OBJECT_PATH}; -const EXPECTED_METHODS: &[&str] = &[ - "Install", - "Detect", - "Compress", - "Compact", - "CompactWith", - "Retrieve", - "CacheStats", -]; +const EXPECTED_METHODS: &[&str] = tinyjuice_bus::METHODS; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires TINYJUICE_TEST_MODULE to point at the built cdylib"] @@ -98,10 +90,15 @@ async fn the_built_module_compresses_and_recovers_over_a_real_broker() { assert!(output.applied); let token = output.ccr_token.expect("compression should be recoverable"); let recovered: Option = proxy - .call("Retrieve", (token, Option::::None)) + .call( + "Retrieve", + (token.clone(), Option::::None), + ) .await .expect("retrieve should succeed"); assert_eq!(recovered, Some(content)); + typed_queries_execute_in_the_loaded_artifact(&proxy, &token).await; + assert!(matches!(modules.list()[0].state, ModuleState::Ready)); compact_with_calls_back_to_the_host_for_a_focused_summary(&client, &proxy).await; @@ -193,6 +190,25 @@ async fn compact_with_calls_back_to_the_host_for_a_focused_summary( let prompts = host.prompts.lock().unwrap().clone(); assert_eq!(prompts.len(), 1); assert!(prompts[0].contains("Caller focus: the rate limits")); + let query = tinyjuice_bus::wire::QueryRequest { + target: tinyjuice_bus::wire::QueryTarget::Content { + content: "Requests are rate limited per key. ".repeat(40), + }, + op: serde_json::from_value( + serde_json::json!({"op":"summarize","hint":"rate limits for this artifact"}), + ) + .unwrap(), + limits: Default::default(), + context_token: Some("query-turn".into()), + scope: Some("query-e2e".into()), + }; + let reply: tinyjuice_bus::wire::QueryResponse = proxy.call("Query", (query,)).await.unwrap(); + assert!( + matches!(reply, Ok(tinyjuice_bus::repl::ReplOutput::Text { text }) if text == "the limit is 60 requests a minute") + ); + let prompts = host.prompts.lock().unwrap().clone(); + assert_eq!(prompts.len(), 2); + assert!(prompts[1].contains("rate limits for this artifact")); } async fn wait_until_serving(client: &Connection) { @@ -213,3 +229,159 @@ async fn wait_until_serving(client: &Connection) { .await .expect("module should become ready"); } + +/// Every new member is called through the separately loaded artifact. +async fn typed_queries_execute_in_the_loaded_artifact(proxy: &tinybus::Proxy, token: &str) { + use tinyjuice_bus::repl::{ReplLimits, ReplOp, ReplOutput}; + use tinyjuice_bus::wire::{QueryError, QueryRequest, QueryResponse, QueryTarget}; + let op: ReplOp = + serde_json::from_value(serde_json::json!({"op": "find", "query": "ERROR"})).unwrap(); + let request = |target| QueryRequest { + target, + op: op.clone(), + limits: ReplLimits::default(), + context_token: None, + scope: None, + }; + let reply: QueryResponse = proxy + .call( + "Query", + (request(QueryTarget::Handle { + token: token.into(), + }),), + ) + .await + .unwrap(); + let Ok(ReplOutput::Lines { hits, .. }) = reply else { + panic!("expected stored log hits") + }; + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].line, 138); + let reply: QueryResponse = proxy + .call( + "Query", + (request(QueryTarget::Content { + content: "first\nERROR artifact\nlast".into(), + }),), + ) + .await + .unwrap(); + let Ok(ReplOutput::Lines { hits, .. }) = reply else { + panic!("expected artifact hits") + }; + assert_eq!(hits[0].line, 2); + assert_eq!(hits[0].text, "ERROR artifact"); + let gone: QueryResponse = proxy + .call( + "Query", + (request(QueryTarget::Handle { + token: "not-cached".into(), + }),), + ) + .await + .unwrap(); + assert_eq!(gone, Err(QueryError::HandleNotFound)); + let before: tinyjuice_bus::wire::CacheStats = proxy.call("CacheStats", ()).await.unwrap(); + let mut capped = request(QueryTarget::Content { + content: "ERROR one\nERROR two\nERROR three".into(), + }); + capped.limits.max_hits = 1; + let reply: QueryResponse = proxy.call("Query", (capped,)).await.unwrap(); + let Ok(ReplOutput::Lines { hits, truncated }) = reply else { + panic!("expected capped lines") + }; + assert_eq!(hits.len(), 1); + assert_eq!(truncated, 2); + let after: tinyjuice_bus::wire::CacheStats = proxy.call("CacheStats", ()).await.unwrap(); + assert_eq!( + before.entries, after.entries, + "supplied content must not create CCR entries" + ); + assert_eq!(before.bytes, after.bytes); + for (op, expected) in [( + serde_json::json!({"op":"find","query":""}), + QueryError::EmptyQuery, + )] { + let mut invalid = request(QueryTarget::Content { + content: "artifact".into(), + }); + invalid.op = serde_json::from_value(op).unwrap(); + let reply: QueryResponse = proxy.call("Query", (invalid,)).await.unwrap(); + assert_eq!(reply, Err(expected)); + } + let mut invalid = request(QueryTarget::Content { + content: "artifact".into(), + }); + invalid.op = + serde_json::from_value(serde_json::json!({"op":"find","mode":"regex","query":"["})) + .unwrap(); + let reply: QueryResponse = proxy.call("Query", (invalid,)).await.unwrap(); + assert!(matches!(reply, Err(QueryError::InvalidPattern(_)))); + let mut unlimited = request(QueryTarget::Content { + content: "ERROR bounded\n".repeat(100), + }); + unlimited.limits.max_hits = usize::MAX; + unlimited.limits.max_lines = usize::MAX; + unlimited.limits.max_line_chars = usize::MAX; + unlimited.limits.max_output_chars = usize::MAX; + unlimited.limits.regex_size_limit = usize::MAX; + let reply: QueryResponse = proxy.call("Query", (unlimited,)).await.unwrap(); + let Ok(ReplOutput::Lines { hits, truncated }) = reply else { + panic!("expected bounded hits") + }; + assert_eq!(hits.len(), 50); + assert_eq!(truncated, 50); + let mut context_query = request(QueryTarget::Content { + content: "before\n".repeat(10) + "ERROR needle\n" + &"after\n".repeat(10), + }); + context_query.op = serde_json::from_value(serde_json::json!({ + "op":"find", "mode":"grep", "query":"ERROR", "context":usize::MAX + })) + .unwrap(); + context_query.limits.max_lines = 3; + let context_reply: QueryResponse = proxy.call("Query", (context_query,)).await.unwrap(); + let Ok(ReplOutput::Lines { hits, truncated }) = context_reply else { + panic!("expected bounded contextual hits") + }; + assert_eq!(hits.len(), 3); + assert!( + hits.iter() + .any(|hit| hit.line == 11 && hit.text == "ERROR needle") + ); + assert!(truncated > 0); + let oversized_query = request(QueryTarget::Content { + content: "x".repeat(tinyjuice_bus::wire::MAX_QUERY_CONTENT_BYTES + 1), + }); + let oversized_reply: QueryResponse = proxy.call("Query", (oversized_query,)).await.unwrap(); + assert_eq!(oversized_reply, Err(QueryError::InputTooLarge)); + let oversized: tinyjuice_bus::wire::HtmlResponse = proxy + .call( + "ExtractHtml", + ("x".repeat(tinyjuice_bus::wire::MAX_HTML_INPUT_BYTES + 1),), + ) + .await + .unwrap(); + assert_eq!( + oversized, + Err(tinyjuice_bus::wire::HtmlError::InputTooLarge) + ); + let legacy: String = proxy + .call("Repl", (token, r#"{"op":"find","query":"ERROR"}"#)) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(&legacy).unwrap(), + ReplOutput::Lines { + hits: vec![tinyjuice_bus::repl::Hit { + line: 138, + text: "ERROR request failed id=137".into() + }], + truncated: 0 + } + ); + let markdown: tinyjuice_bus::wire::HtmlResponse = proxy.call("ExtractHtml", ("

Heading

Read details

",)).await.unwrap(); + let markdown = markdown.expect("bounded HTML extraction"); + assert!(markdown.contains("# Heading")); + assert!(markdown.contains("[details](/details)")); + assert!(!markdown.contains("secret()")); +} diff --git a/docs/repl-tools.md b/docs/repl-tools.md index 7dbddfa0..5de63201 100644 --- a/docs/repl-tools.md +++ b/docs/repl-tools.md @@ -71,7 +71,7 @@ are relative to the scope, as if the slice were piped in. ### Limits -Every op is capped (`ReplLimits`): hits, `sed`/`awk` lines, output characters, line length +Every op is capped (`ReplLimits`): hits, `sed`/`awk` lines and expanded grep context, output characters, line length and regex size. A capped result reports how many hits were dropped. Sensitive query parameters in HTML hrefs are redacted. An unknown or expired handle is an error. For HTML input, line numbers refer to its Markdown rendering. @@ -105,6 +105,17 @@ reply comes back empty and the summary fails. Turn reasoning off for the summari ## Surfaces +`run_on_text_with_model(text, op, limits, model)` queries content supplied by an +authorized caller without storing it in CCR. Only `Summarize` with `Some(model)` +uses the configured host callback; other operations remain deterministic. It +uses the same input/output limits, timeout, delayed-result reuse and scope-wide +failure breaker as `run_op_with_model`. A timeout, disabled model or model failure +returns the deterministic summary with an availability note. Callers retain +responsibility for authorizing the supplied content and choosing the summary +scope. An omitted `max_chars` preserves the existing model-summary output cap +(`ReplLimits::max_output_chars`, normally 8000); the deterministic overview keeps +its separate 2000-character default. Explicit `max_chars` bounds either result. + - Rust: `tinyjuice::repl::{run_op, run_on_text, ReplOp, ReplOutput, ReplLimits}`. - Rust, model-written summary: `tinyjuice::repl::{run_op_with_model, ModelSummary}`. - `tinytools` feature: `tinyjuice::repl::tools::repl_tools(store, limits)` returns diff --git a/docs/specs/tinybus-module.md b/docs/specs/tinybus-module.md index 698ae292..5f2a9648 100644 --- a/docs/specs/tinybus-module.md +++ b/docs/specs/tinybus-module.md @@ -5,12 +5,16 @@ compression engine without linking the engine or its dependencies into the host binary. It serves `ai.tinyhumans.tinyjuice.Compression` at -`/ai/tinyhumans/tinyjuice/Compression` with six methods: +`/ai/tinyhumans/tinyjuice/Compression` with the following methods: - `Install` applies router and CCR configuration. - `Detect` classifies content. - `Compress` routes one content blob through the configured engine. - `Compact` is the tool-output hot path with an agent compression profile. +- `CompactWith` carries arguments, focus, and a turn-bound summary callback ticket. +- `Repl` retains the legacy `(handle, op_json)` interface. +- `Query` runs a typed, bounded REPL operation against module CCR storage or supplied content. +- `ExtractHtml` converts HTML to Markdown without host-side parsing. - `Retrieve` fetches a CCR original, optionally by range. - `CacheStats` reports CCR occupancy. @@ -34,3 +38,74 @@ take their defaults, so a future knob does not break an older host. `Retrieve` accepts `(token, range)`, where `range` is optional. A range contains `start`, `end`, and `unit` (`bytes` or `lines`). The response is either the retrieved string or `null` when the token is not retained. + + +Contract 1.2 keeps every existing method's argument count. `Query` takes one +`QueryRequest` (camelCase fields). Its target is tagged by `kind`: `handle` +with `token`, or `content` with authorized artifact `content`. REPL operation +and output fields preserve their existing snake_case wire representation. +`limits` defaults to the stock REPL caps. `contextToken` and `scope` retain the +existing turn-bound model callback contract; only summarize uses it. Supplied +content is queried without inserting an entry in CCR. Modules own query, +HTML, pattern, slice, and model-summary algorithms; hosts own artifact path +validation, approvals, credentials, and callback lifetime. + +`Query` returns a serialized `Result`; an expired handle +is `{"Err":{"kind":"handle_not_found"}}`. A transport failure remains a bus +error. `ExtractHtml` takes one content string and returns `Result`: Markdown on success or a structured input-size error. Publish a +new module release before hosts require these members, then pin that release's +artifact digest. Do not link a library fallback into the host. + + +### REPL wire vocabulary + +`Query` takes a single request; the following request queries module storage +without returning the original to the host: + +```json +{ + "target": {"kind": "handle", "token": "abc123"}, + "op": {"op": "find", "query": "ERROR", "mode": "grep", "context": 2} +} +``` + +Replace `target` with `{"kind":"content","content":"authorized artifact text"}` +to query supplied content. `find` supports `text` (default), `grep`, `regex`, +`rank`, `sed`, `awk`, and `jq`. `extract` takes `what` = `links` or `headings`; +`summarize` takes optional `hint` and `max_chars`. Every operation accepts +optional `scope` (a Python-style slice) and `unit` (`lines`, the default, or +`chars`). Optional find fields default to `ignore_case=false`, `context=0`, +and no `top_k`. Missing `scope`, `hint`, or `max_chars` means no supplied value. + +Replies retain the legacy output tags `kind=lines|matches|search|links|headings| +values|text` inside `Ok`. Operation errors are in `Err`, tagged by `kind`: +`handle_not_found`, `input_too_large`, `invalid_pattern` (with `detail`), +`empty_query`, or `unsupported` (with `detail`). Hosts present cache misses +without re-running the original tool. Transport failures are separate bus errors. + +Incoming `limits` may narrow the stock budgets, never expand them: 50 hits, +400 lines, 8,000 output characters, 240 characters per line, and 1 MiB of regex +compilation state. Supplied query content is limited to 10 MiB, matching the +host artifact file-read ceiling. HTML extraction accepts at most 8 MiB, matching +the web extractor input ceiling; excess input returns +`{"Err":{"kind":"input_too_large"}}` without parsing. A model summary obeys +both an explicit `max_chars` and the module's output cap. Without `max_chars`, +model summaries retain the output cap and deterministic overviews retain their +existing 2,000-character default. +Contract 1.1 modules do not provide the new members; contract 1.2 hosts must +require a compatible released artifact instead of calling a linked fallback. + +`tinyjuice_bus::summary::SYSTEM_PROMPT` is the shared instruction text used by +the host's turn-bound summary callback. Import it through the contract when +building that callback; importing it does not execute a model. Its wording may +change between releases while the serialized callback request stays compatible. +`UnavailableReason` supplies content-free notices. Its disabled notice refers +to the configured summary scope, which may be a thread rather than a session. + +Each `ReplLimits` field is a ceiling: `max_hits=50` selected matches, +`max_lines=400` range output and expanded grep lines, `max_output_chars=8000` +response characters, `max_line_chars=240` characters per returned line, and +`regex_size_limit=1048576` bytes of regex compilation state. Module requests may +narrow these values but cannot enlarge the module's stock ceilings. Grep keeps +matching lines before allocating remaining context slots. An explicit summary +`max_chars` also bounds a model-failure notice combined with its overview. diff --git a/src/host/focus.rs b/src/host/focus.rs index 731bece8..9cf7b336 100644 --- a/src/host/focus.rs +++ b/src/host/focus.rs @@ -6,19 +6,9 @@ //! `before_tool` — before schema validation and before the tool runs, so the //! tool body never sees it — and hands it to TinyJuice with the result. -use serde_json::{Value, json}; +use serde_json::Value; -/// The argument's name on the wire. -pub const SUMMARY_FOCUS_ARG: &str = "summary_focus"; - -/// The schema for the optional `summary_focus` argument. -pub fn summary_focus_property() -> Value { - json!({ - "type": "string", - "description": "What you need from this result. A large result is summarized around \ - this; the full output stays retrievable." - }) -} +pub use tinyjuice_bus::tools::{SUMMARY_FOCUS_ARG, summary_focus_property}; /// Whether a tool's parameter schema declares TinyJuice's `summary_focus`, /// as opposed to a parameter of its own that happens to share the name. diff --git a/src/host/focus_tests.rs b/src/host/focus_tests.rs index 3a732f96..351fc7c2 100644 --- a/src/host/focus_tests.rs +++ b/src/host/focus_tests.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::json; #[test] fn the_focus_is_taken_out_of_the_arguments() { diff --git a/src/host/retrieve_tool.rs b/src/host/retrieve_tool.rs index e89cb3ef..a5c49b1d 100644 --- a/src/host/retrieve_tool.rs +++ b/src/host/retrieve_tool.rs @@ -12,7 +12,6 @@ use std::pin::Pin; use std::sync::Arc; use async_trait::async_trait; -use serde_json::json; use tinytools::{PermissionLevel, Tool, ToolResult}; /// Looks a stored original up by hash: `Ok(None)` is a cache miss. @@ -41,24 +40,11 @@ impl Tool for RetrieveToolOutputTool { } fn description(&self) -> &str { - "Retrieve the full, original text of a tool result that was compacted to \ - save context. When a tool output shows a marker like \ - `retrieve_tool_output(\"a1b2c3d4e5f6\")`, call this with that hash to get \ - the complete original back. Use it only when you actually need the dropped \ - detail — the compacted view is usually enough." + tinyjuice_bus::tools::RETRIEVE_TOOL_DESCRIPTION } fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "hash": { - "type": "string", - "description": "The hash from a retrieve_tool_output(\"…\") marker." - } - }, - "required": ["hash"] - }) + tinyjuice_bus::tools::retrieve_tool_parameters() } fn permission_level(&self) -> PermissionLevel { diff --git a/src/host/retrieve_tool_tests.rs b/src/host/retrieve_tool_tests.rs index 7c0b4474..27268c3e 100644 --- a/src/host/retrieve_tool_tests.rs +++ b/src/host/retrieve_tool_tests.rs @@ -1,4 +1,5 @@ use super::*; +use serde_json::json; fn tool_over( lookup: impl Fn(String) -> Result, String> + Send + Sync + 'static, diff --git a/src/repl/mod.rs b/src/repl/mod.rs index e83712ea..66a86067 100644 --- a/src/repl/mod.rs +++ b/src/repl/mod.rs @@ -76,20 +76,44 @@ pub async fn run_op_with_model( op: &ReplOp, limits: &ReplLimits, model: Option<&ModelSummary>, +) -> Result { + let text = store.get(handle).ok_or(ReplError::HandleNotFound)?; + run_on_text_with_model_inner(&text, op, limits, model, true).await +} + +/// Inspect supplied text with the same bounded model-summary behavior as a CCR query. +/// The caller owns authorization to read the supplied content; this does not store it. +pub async fn run_on_text_with_model( + text: &str, + op: &ReplOp, + limits: &ReplLimits, + model: Option<&ModelSummary>, +) -> Result { + run_on_text_with_model_inner(text, op, limits, model, false).await +} + +async fn run_on_text_with_model_inner( + text: &str, + op: &ReplOp, + limits: &ReplLimits, + model: Option<&ModelSummary>, + stored: bool, ) -> Result { let ( Some(model), ReplOp::Summarize { - hint, scope, unit, .. + hint, + scope, + unit, + max_chars, }, ) = (model, op) else { - return run_op(store, handle, op, limits); + return run_on_text(text, op, limits); }; - let text = store.get(handle).ok_or(ReplError::HandleNotFound)?; let scoped = match scope { - Some(spec) => scope::apply(&text, spec, *unit)?.0, - None => text.as_str(), + Some(spec) => scope::apply(text, spec, *unit)?.0, + None => text, }; let outcome = crate::summarize::summarize_on_demand( crate::summarize::SummaryInput { @@ -113,18 +137,29 @@ pub async fn run_op_with_model( } else { text }; - Ok(cap(ReplOutput::Text { text }, limits)) + let budget = max_chars + .unwrap_or(limits.max_output_chars) + .min(limits.max_output_chars); + let mut text = text; + if let Some((cut, _)) = text.char_indices().nth(budget) { + text.truncate(cut); + } + Ok(ReplOutput::Text { text }) } crate::summarize::OnDemandSummary::Fallback(reason) => { log::debug!("[tinyjuice::repl] summarize fell back to the overview reason={reason:?}"); - let overview = run_on_text(&text, op, limits)?; + let overview = run_on_text(text, op, limits)?; Ok(match (reason, overview) { - (Some(reason), ReplOutput::Text { text }) => cap( - ReplOutput::Text { - text: format!("{}\n{text}", fallback_note(reason)), - }, - limits, - ), + (Some(reason), ReplOutput::Text { text }) => { + let mut text = format!("{}\n{text}", fallback_note(reason, stored)); + let budget = max_chars + .unwrap_or(limits.max_output_chars) + .min(limits.max_output_chars); + if let Some((cut, _)) = text.char_indices().nth(budget) { + text.truncate(cut); + } + ReplOutput::Text { text } + } (_, overview) => overview, }) } @@ -132,12 +167,16 @@ pub async fn run_op_with_model( } /// The line ahead of an overview that stands in for a model summary. -fn fallback_note(reason: crate::summarize::UnavailableReason) -> &'static str { +fn fallback_note(reason: crate::summarize::UnavailableReason, stored: bool) -> &'static str { match reason { - crate::summarize::UnavailableReason::TimedOut => { + crate::summarize::UnavailableReason::TimedOut if stored => { "[model summary still running past its time limit; call juice_summarize again \ with the same handle and hint shortly to get it. Model-free overview follows.]" } + crate::summarize::UnavailableReason::TimedOut => { + "[model summary still running past its time limit; retry this query shortly \ + with the same content and hint. Model-free overview follows.]" + } _ => "[model summary unavailable; model-free overview follows.]", } } diff --git a/src/repl/mod_model_tests.rs b/src/repl/mod_model_tests.rs index 812e9d89..f0000635 100644 --- a/src/repl/mod_model_tests.rs +++ b/src/repl/mod_model_tests.rs @@ -62,6 +62,41 @@ fn text_of(out: ReplOutput) -> String { } } +#[tokio::test] +async fn supplied_and_stored_model_fallback_obey_explicit_character_budget() { + let _guard = llm::callback_test_guard().await; + let (store, token, text) = stored("bounded-fallback"); + let _requests = recording(Err("fixture model failure".into())); + let model = model("bounded-fallback"); + for budget in [0, 16, 32] { + let op = ReplOp::Summarize { + hint: None, + scope: None, + unit: ScopeUnit::Lines, + max_chars: Some(budget), + }; + let supplied = text_of( + run_on_text_with_model(&text, &op, &lim(), Some(&model)) + .await + .unwrap(), + ); + let stored = text_of( + run_op_with_model(&store, &token, &op, &lim(), Some(&model)) + .await + .unwrap(), + ); + assert!( + supplied.chars().count() <= budget, + "supplied fallback exceeded {budget}: {supplied}" + ); + assert!( + stored.chars().count() <= budget, + "stored fallback exceeded {budget}: {stored}" + ); + } + llm::configure_callback(None); +} + #[tokio::test] async fn summarize_with_a_model_makes_exactly_one_call() { let _guard = llm::callback_test_guard().await; @@ -204,3 +239,35 @@ async fn the_summarize_tool_uses_the_model_it_was_given() { assert_eq!(seen.lock().unwrap().len(), 1); llm::configure_callback(None); } + +#[tokio::test] +async fn a_model_summary_honors_max_chars_for_supplied_content() { + let _guard = llm::callback_test_guard().await; + recording(Ok(Some("résumé ".repeat(100)))); + let (_, _, text) = stored("model-artifact-cap"); + let mut op = summarize(None, None); + if let ReplOp::Summarize { max_chars, .. } = &mut op { + *max_chars = Some(17); + } + let out = run_on_text_with_model(&text, &op, &lim(), Some(&model("model-artifact-cap"))) + .await + .unwrap(); + assert_eq!(text_of(out).chars().count(), 17); + llm::configure_callback(None); +} + +#[tokio::test(start_paused = true)] +async fn a_supplied_content_timeout_does_not_invent_a_recovery_handle() { + let _guard = llm::callback_test_guard().await; + llm::configure_callback(Some(Arc::new(|_| Box::pin(std::future::pending())))); + let (_, _, text) = stored("model-artifact-timeout"); + let mut slow = model("model-artifact-timeout"); + slow.options.llm_summary_timeout_ms = 50; + let out = run_on_text_with_model(&text, &summarize(None, None), &lim(), Some(&slow)) + .await + .unwrap(); + let out = text_of(out); + assert!(out.contains("retry this query"), "{out}"); + assert!(!out.contains("same handle"), "{out}"); + llm::configure_callback(None); +} diff --git a/src/repl/mod_tests.rs b/src/repl/mod_tests.rs index ef477d7d..3cc01832 100644 --- a/src/repl/mod_tests.rs +++ b/src/repl/mod_tests.rs @@ -536,10 +536,8 @@ fn jq_allows_blocked_names_used_as_data() { r#".items[] | select(.type == "debug") | .n"#, "{env: .env}", ] { - assert!( - run_on_text(json, &find(q, FindMode::Jq), &lim()).is_ok(), - "{q}" - ); + let result = run_on_text(json, &find(q, FindMode::Jq), &lim()); + assert!(result.is_ok(), "{q}: {result:?}"); } for q in ["env", "$ENV.HOME", r#""\(env)""#, ".items | input"] { assert!( @@ -619,3 +617,27 @@ fn handle_footer_names_a_slice_read_first_and_still_round_trips() { assert!(footer.contains("-n 120,200p")); assert_eq!(crate::cache::parse_markers(&footer), vec!["abc123"]); } + +#[test] +fn grep_context_limits_intermediate_line_results_for_dense_content() { + let text = "before\n".repeat(5_000) + "needle\n" + &"after\n".repeat(5_000); + let limits = ReplLimits { + max_lines: 32, + ..lim() + }; + let (hits, truncated) = ops::grep(&text, "needle", true, false, usize::MAX, &limits).unwrap(); + assert!( + hits.len() <= 32, + "context expansion produced {} allocated hits", + hits.len() + ); + assert!(truncated > 0); + assert!( + hits.iter() + .any(|hit| hit.line == 5_001 && hit.text == "needle") + ); + assert!( + hits.iter() + .all(|hit| hit.line >= 4_969 && hit.line <= 5_033) + ); +} diff --git a/src/repl/ops.rs b/src/repl/ops.rs index 55a5a346..daf8bacd 100644 --- a/src/repl/ops.rs +++ b/src/repl/ops.rs @@ -44,33 +44,64 @@ pub fn grep( pattern.to_string() }; let re = build_regex(&source, ignore_case, limits)?; - let lines: Vec<&str> = text.lines().collect(); - let mut keep = vec![false; lines.len()]; + // Bound context before selecting ranges, and allocate only the returned + // lines. A caller-supplied context must not materialize the entire input. + let context = context.min(limits.max_lines); + let mut ranges: Vec<(usize, usize)> = Vec::new(); + let mut matching_lines = Vec::new(); let mut matched = 0usize; let mut truncated = 0usize; - for (i, line) in lines.iter().enumerate() { + for (i, line) in text.lines().enumerate() { if re.is_match(line) { if matched >= limits.max_hits { truncated += 1; continue; } matched += 1; + matching_lines.push(i); let lo = i.saturating_sub(context); - let hi = i.saturating_add(context).min(lines.len() - 1); - for slot in &mut keep[lo..=hi] { - *slot = true; + let hi = i.saturating_add(context); + if let Some(last) = ranges.last_mut() + && lo <= last.1.saturating_add(1) + { + last.1 = last.1.max(hi); + } else { + ranges.push((lo, hi)); } } } - let hits = lines - .iter() - .enumerate() - .filter(|(i, _)| keep[*i]) - .map(|(i, l)| Hit { + let mut hits = Vec::new(); + let mut range = 0; + let matching_lines = &matching_lines[..matching_lines.len().min(limits.max_lines)]; + let mut remaining_matches = matching_lines.len(); + for (i, line) in text.lines().enumerate() { + while range < ranges.len() && i > ranges[range].1 { + range += 1; + } + if range == ranges.len() { + break; + } + if i < ranges[range].0 { + continue; + } + let matching = matching_lines.binary_search(&i).is_ok(); + if matching { + remaining_matches -= 1; + } + // Reserve slots for matching lines before filling them with context. + if hits.len() + remaining_matches >= limits.max_lines && !matching { + truncated += 1; + continue; + } + if hits.len() >= limits.max_lines { + truncated += 1; + continue; + } + hits.push(Hit { line: i + 1, - text: clip(l, limits.max_line_chars), - }) - .collect(); + text: clip(line, limits.max_line_chars), + }); + } Ok((hits, truncated)) } diff --git a/src/repl/scope.rs b/src/repl/scope.rs index 46517908..bdfa8b28 100644 --- a/src/repl/scope.rs +++ b/src/repl/scope.rs @@ -2,18 +2,10 @@ //! //! A host uses it to narrow what an op looks at, by lines (default) or characters. -use serde::{Deserialize, Serialize}; +pub use tinyjuice_bus::repl::ScopeUnit; use super::types::ReplError; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ScopeUnit { - #[default] - Lines, - Chars, -} - fn bad(msg: &str) -> ReplError { ReplError::InvalidPattern(format!("scope: {msg}")) } diff --git a/src/repl/tools.rs b/src/repl/tools.rs index 1015ec73..f0738052 100644 --- a/src/repl/tools.rs +++ b/src/repl/tools.rs @@ -6,18 +6,15 @@ use std::sync::Arc; use async_trait::async_trait; -use serde_json::{Value, json}; +use serde_json::Value; +use tinyjuice_bus::tools::{ReplToolDeclaration, repl_tool_declarations}; use tinytools::{PermissionLevel, Tool, ToolResult}; use super::{ModelSummary, ReplError, ReplLimits, ReplOp, run_op_with_model}; use crate::cache::store::CcrStore; struct ReplTool { - name: &'static str, - op: &'static str, - description: &'static str, - extra: Value, - required: &'static [&'static str], + declaration: ReplToolDeclaration, store: Arc, limits: ReplLimits, /// Set for `juice_summarize` when the host lends it a model. @@ -27,21 +24,15 @@ struct ReplTool { #[async_trait] impl Tool for ReplTool { fn name(&self) -> &str { - self.name + &self.declaration.name } fn description(&self) -> &str { - self.description + &self.declaration.description } fn parameters_schema(&self) -> Value { - let mut props = json!({ "handle": { "type": "string", "description": "the handle named in the output's footer" } }); - if let (Some(p), Some(e)) = (props.as_object_mut(), self.extra.as_object()) { - p.extend(e.clone()); - } - let mut required = vec!["handle"]; - required.extend_from_slice(self.required); - json!({ "type": "object", "properties": props, "required": required }) + self.declaration.parameters.clone() } async fn execute(&self, args: Value) -> anyhow::Result { @@ -52,7 +43,7 @@ impl Tool for ReplTool { let Some(obj) = tagged.as_object_mut() else { return Ok(ToolResult::error("arguments must be an object")); }; - obj.insert("op".into(), Value::String(self.op.into())); + obj.insert("op".into(), Value::String(self.declaration.op.clone())); let op: ReplOp = match serde_json::from_value(tagged) { Ok(op) => op, Err(e) => return Ok(ToolResult::error(format!("invalid arguments: {e}"))), @@ -103,57 +94,18 @@ pub fn repl_tools_with_model( limits: ReplLimits, model: Option, ) -> Vec> { - let t = |name, op, description, extra: Value, required| -> Box { - let model = (op == "summarize").then(|| model.clone()).flatten(); - Box::new(ReplTool { - name, - op, - description, - extra, - required, - store: store.clone(), - limits, - model, + repl_tool_declarations() + .into_iter() + .map(|declaration| { + let model = (declaration.op == "summarize") + .then(|| model.clone()) + .flatten(); + Box::new(ReplTool { + declaration, + store: store.clone(), + limits, + model, + }) as Box }) - }; - vec![ - t( - "juice_find", - "find", - "Read or search part of a stored tool output by its handle, without loading all of it. mode `sed` reads exact lines (query `-n 120,200p`); `grep`/`regex` search (with `context` lines; regex returns capture groups); `text` substring; `rank` BM25; `awk` (`-F, '$2>5{print $1}'`); `jq` for JSON. Prefer this to juice_retrieve when you need only part of an output.", - json!({ - "query": { "type": "string" }, - "mode": { "type": "string", "enum": ["text", "grep", "regex", "rank", "sed", "awk", "jq"] }, - "ignore_case": { "type": "boolean" }, - "context": { "type": "integer" }, - "top_k": { "type": "integer" }, - "scope": { "type": "string", "description": "python slice, e.g. [:-100]" }, - "unit": { "type": "string", "enum": ["lines", "chars"] } - }), - &["query"], - ), - t( - "juice_extract", - "extract", - "HTML or Markdown outputs only: list their links or headings.", - json!({ - "what": { "type": "string", "enum": ["links", "headings"] }, - "scope": { "type": "string" }, - "unit": { "type": "string", "enum": ["lines", "chars"] } - }), - &["what"], - ), - t( - "juice_summarize", - "summarize", - "Model-free overview of a stored output: size, outline or JSON shape, then head and tail; with `hint`, the parts most relevant to it. Use it to find where to read with juice_find.", - json!({ - "hint": { "type": "string", "description": "what you need from it" }, - "max_chars": { "type": "integer" }, - "scope": { "type": "string" }, - "unit": { "type": "string", "enum": ["lines", "chars"] } - }), - &[], - ), - ] + .collect() } diff --git a/src/repl/types.rs b/src/repl/types.rs index 512df166..c82c73b8 100644 --- a/src/repl/types.rs +++ b/src/repl/types.rs @@ -1,177 +1,3 @@ -//! Shared data for REPL-style output inspection. +//! REPL vocabulary shared with hosts through the transport-free contract. -use serde::{Deserialize, Serialize}; - -pub use super::scope::ScopeUnit; - -/// Caps applied to every op so a query can never return the whole payload. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplLimits { - pub max_hits: usize, - /// Cap for line-producing modes that select ranges (`sed`, `awk`). - pub max_lines: usize, - pub max_output_chars: usize, - pub max_line_chars: usize, - pub regex_size_limit: usize, -} - -impl Default for ReplLimits { - fn default() -> Self { - Self { - max_hits: 50, - max_lines: 400, - max_output_chars: 8_000, - max_line_chars: 240, - regex_size_limit: 1 << 20, - } - } -} - -/// A located line. `line` is 1-based. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Hit { - pub line: usize, - pub text: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RegexMatch { - pub line: usize, - pub text: String, - pub captures: Vec>, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct SearchHit { - pub line_start: usize, - pub line_end: usize, - pub score: f32, - pub text: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Heading { - pub level: u8, - pub text: String, - pub line: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Link { - pub text: String, - pub href: String, -} - -/// How `find` reads its query. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FindMode { - /// Case-insensitive substring. - #[default] - Text, - /// Regex per line (optionally case-insensitive, with context lines). - Grep, - /// Regex per line, returning capture groups. - Regex, - /// BM25-ranked windows of lines. - Rank, - /// sed subset: `-n 10,20p`, `/re/d`, `s/a/b/g`. - Sed, - /// awk subset: `-F, '$3 > 5 { print $1, $NF }'`. - Awk, - /// jq filter over JSON output: `.items[] | select(.size > 10) | .name`. - Jq, -} - -/// What `extract` pulls out of HTML or Markdown. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ExtractKind { - Links, - Headings, -} - -/// An op over a stored original. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "op", rename_all = "snake_case")] -pub enum ReplOp { - Find { - query: String, - #[serde(default)] - mode: FindMode, - #[serde(default)] - ignore_case: bool, - #[serde(default)] - context: usize, - #[serde(default)] - top_k: Option, - /// Python-style slice to search within, e.g. `[:-100]`. - #[serde(default)] - scope: Option, - #[serde(default)] - unit: ScopeUnit, - }, - Extract { - what: ExtractKind, - #[serde(default)] - scope: Option, - #[serde(default)] - unit: ScopeUnit, - }, - Summarize { - #[serde(default)] - max_chars: Option, - /// What the caller cares about; ranks which parts of the scope to keep. - #[serde(default)] - hint: Option, - #[serde(default)] - scope: Option, - #[serde(default)] - unit: ScopeUnit, - }, -} - -/// Result of an op. `truncated` counts hits dropped by a cap. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum ReplOutput { - Lines { - hits: Vec, - truncated: usize, - }, - Matches { - matches: Vec, - truncated: usize, - }, - Search { - hits: Vec, - truncated: usize, - }, - Links { - links: Vec, - truncated: usize, - }, - Headings { - headings: Vec, - truncated: usize, - }, - Values { - values: Vec, - truncated: usize, - }, - Text { - text: String, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum ReplError { - #[error("handle not found or expired; re-run the original tool")] - HandleNotFound, - #[error("invalid pattern: {0}")] - InvalidPattern(String), - #[error("empty query")] - EmptyQuery, - #[error("{0} support is not compiled in")] - Unsupported(&'static str), -} +pub use tinyjuice_bus::repl::*; diff --git a/src/summarize/mod.rs b/src/summarize/mod.rs index 6434b386..b5fe03b0 100644 --- a/src/summarize/mod.rs +++ b/src/summarize/mod.rs @@ -40,7 +40,7 @@ use crate::types::{CompressOptions, LlmSummaryMode}; pub use on_demand::{OnDemandSummary, sample_for_budget, summarize_on_demand}; /// The extraction contract the summary is written against. -pub const SYSTEM_PROMPT: &str = include_str!("prompt.md"); +pub use tinyjuice_bus::summary::SYSTEM_PROMPT; /// The [`GenerateRequest::purpose`] this stage sends. pub const PURPOSE: &str = "tool_output_summary"; @@ -65,55 +65,7 @@ pub struct SummaryInput<'a> { pub scope: Option<&'a str>, } -/// Why a payload that qualified for a summary did not get one. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UnavailableReason { - /// Larger than `llm_summary_max_input_tokens`. - PayloadTooLarge, - /// The breaker is open for this scope. - Disabled, - /// The model call failed or its reply was empty or not smaller. - Failed, - /// The model call outlasted `llm_summary_timeout_ms`. - TimedOut, -} - -impl UnavailableReason { - /// The model-facing notice for this reason. - /// - /// A host prefixes it to the tool result *after* its own caps have run, so - /// a cap cannot cut it. Every variant ends with the same bare instruction — - /// do not re-run the tool for a summary — because the reasonable response - /// to an unsummarized dump is otherwise to call the tool again, which reads - /// to a user as a hang. It carries no justifying clause: each one tried - /// ("it will return the same result", "the full output is already here") - /// was a claim this code cannot make for every tool. - #[must_use] - pub fn notice(self) -> &'static str { - match self { - Self::PayloadTooLarge => concat!( - "[summarization unavailable — this output exceeds the summarizer's ", - "size cap, so the tool output follows and may be truncated. ", - "Do not re-run the tool for a summary.]" - ), - Self::Disabled => concat!( - "[summarization unavailable — it is switched off for this session ", - "after repeated failures, so the tool output follows. ", - "Do not re-run the tool for a summary.]" - ), - Self::TimedOut => concat!( - "[summarization timed out — the tool output follows. If a recovery handle ", - "appears in its footer, inspect the stored output with the juice_* tools ", - "using that handle. Do not re-run the tool for a summary.]" - ), - Self::Failed => concat!( - "[summarization unavailable — the summarizer did not return a usable ", - "summary for this result, so the tool output follows. ", - "Do not re-run the tool for a summary.]" - ), - } - } -} +pub use tinyjuice_bus::summary::UnavailableReason; /// What one summary attempt concluded. #[derive(Debug, Clone, PartialEq, Eq)]