Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crates/tinyjuice-bus/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 20 additions & 2 deletions crates/tinyjuice-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, HtmlError>` so input-size
rejections remain distinct from transport failures.
5 changes: 4 additions & 1 deletion crates/tinyjuice-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
//! re-exports the shared values from here rather than defining a second copy.

pub mod names;
pub mod repl;
Comment thread
senamakel marked this conversation as resolved.
pub mod summary;
pub mod tools;
pub mod types;
pub mod version;
pub mod wire;
Expand All @@ -29,4 +32,4 @@ pub use wire::{

#[cfg(test)]
#[path = "lib_tests.rs"]
mod test;
mod tests;
81 changes: 76 additions & 5 deletions crates/tinyjuice-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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::<Vec<_>>(),
["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");
}
}
9 changes: 9 additions & 0 deletions crates/tinyjuice-bus/src/names.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand All @@ -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,
];
187 changes: 187 additions & 0 deletions crates/tinyjuice-bus/src/repl.rs
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
senamakel marked this conversation as resolved.
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<Option<String>>,
}

#[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 {
Comment thread
senamakel marked this conversation as resolved.
Comment thread
senamakel marked this conversation as resolved.
Find {
query: String,
#[serde(default)]
mode: FindMode,
#[serde(default)]
ignore_case: bool,
#[serde(default)]
context: usize,
#[serde(default)]
top_k: Option<usize>,
/// Python-style slice to search within, e.g. `[:-100]`.
#[serde(default)]
scope: Option<String>,
#[serde(default)]
unit: ScopeUnit,
},
Extract {
what: ExtractKind,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
unit: ScopeUnit,
},
Summarize {
#[serde(default)]
max_chars: Option<usize>,
/// What the caller cares about; ranks which parts of the scope to keep.
#[serde(default)]
hint: Option<String>,
#[serde(default)]
scope: Option<String>,
#[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<Hit>,
truncated: usize,
},
Matches {
matches: Vec<RegexMatch>,
truncated: usize,
},
Search {
hits: Vec<SearchHit>,
truncated: usize,
},
Links {
links: Vec<Link>,
truncated: usize,
},
Headings {
headings: Vec<Heading>,
truncated: usize,
},
Values {
values: Vec<String>,
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),
}
Loading
Loading