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
10 changes: 10 additions & 0 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ tracing = { version = "0.1", default-features = false }
# `Tool::execute` returns `anyhow::Result` because a tool body calls arbitrary
# host code and has no useful closed error set of its own.
anyhow = "1"
# Unit tests for the async trait defaults drive a real executor.
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
# Unit tests for async traits drive a real executor.
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

# Lints apply to every member that opts in with `[lints] workspace = true`, and
# to every target of that member. CI runs clippy with `-D warnings`, so anything
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,15 @@ compiles neither the harness nor the host.
| `context` | `ToolRunContext` — the narrow seam onto a live run |
| `workspace` | `WorkspaceDescriptor`, `SandboxMode` — the root a tool may touch, and how strictly it is sandboxed |
| `naming` | `humanize_tool_name`, `context_detail_from_args` — rendering a call for a human |
| `rank` | `ToolRanker`, `RankCandidate`, `RankHit`, `Bm25Ranker` — ranking a catalogue of tools against an intent, with the lexical ranker built in |

The workspace also contains `tinytools-agent`, a separate crate for
model-facing tool-call parsing, dialects, catalogue/result rendering, and
transcript replay. It builds on `ToolSpec` without adding agent-loop or provider
dependencies to the base `tinytools` vocabulary crate.
dependencies to the base `tinytools` vocabulary crate. `tinytools-jev` is a
second sibling: a `ToolRanker` backed by TypeSafe's Jev decision model through
`tinyjevclient`, kept out of the vocabulary crate because it carries an HTTP
transport.
Comment on lines +74 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Correct the stale tinyjevclient dependency description

This description no longer matches the added crate: its manifest has no tinyjevclient or HTTP dependency, and JevRanker::new instead requires a host-provided JevEvaluator. Update this section to describe that boundary so consumers are not incorrectly told that selecting this crate brings in a specific client and transport.

AGENTS.md reference: AGENTS.md:L204-L205

Useful? React with 👍 / 👎.


## What is deliberately not here

Expand Down
26 changes: 26 additions & 0 deletions crates/tinytools-jev/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
[package]
publish = false
name = "tinytools-jev"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "A tinytools ToolRanker backed by TypeSafe's Jev decision model: retrieve a shortlist lexically, let Jev decide."
documentation = "https://docs.rs/tinytools-jev"
readme = "README.md"

[dependencies]
async-trait = { workspace = true }
tinytools = { path = "../tinytools", version = "0.3.0" }
Comment thread
senamakel marked this conversation as resolved.
tracing = { workspace = true, optional = true }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the no-op tracing feature

The new tracing dependency is never referenced anywhere in tinytools-jev, so enabling the public tracing feature only adds a dependency without changing behavior. Remove the dependency and feature until instrumentation exists, or wire and document the intended events; leaving a no-op feature misleads consumers and needlessly expands their graph.

AGENTS.md reference: AGENTS.md:L150-L158

Useful? React with 👍 / 👎.


[dev-dependencies]
tokio = { workspace = true }

[features]
default = []
tracing = ["dep:tracing"]

[lints]
workspace = true
48 changes: 48 additions & 0 deletions crates/tinytools-jev/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# tinytools-jev

A `tinytools::ToolRanker` backed by a host-provided Jev evaluator. The host
owns its HTTP client, credentials, retry policy, and deadline.

## Retrieve, then decide

Jev answers a `Choice` question with a calibrated probability for every
option in ~150 ms, accepts at most 255 options, and loses accuracy as the
option list fills with entries unrelated to the request. So `JevRanker`
never shows it a whole catalogue:

1. A retriever (`Bm25Ranker` unless the host supplies one) narrows the
catalogue to `retrieval_k` candidates (20 by default). Skipped when the
catalogue already fits. When the retriever finds nothing and the catalogue
fits one Choice, Jev sees all of it — a lexical miss on a paraphrase is
exactly the case a decision model is for.
2. One request: a `Choice` over the shortlist plus a `none` option, and a
`Noul` asking whether the request needs a tool at all.
3. Hits are the options by probability, `none` removed, anything below
`min_probability` dropped. Each hit's `confidence` is its probability;
`rank_detailed` also returns the Choice confidence, the `needs_tool`
probability, the `none` probability, tokens, latency and attempts.

Every evaluator failure is a `RankError` the caller falls back from.

## Limits that shape the design

| Limit | Value | Consequence |
|------------------------------|--------|-----------------------------------------------|
| Options per Choice | 255 | `retrieval_k` is clamped to it |
| Summary shown per option | 240 ch | clipped, with the family named after |
| Context per request | 64k | `RankContext` stays to a few recent turns |
| Pricing (jev-1.13) | $0.042 / M input, output free | a search is ~1–2k tokens |

## Wording

Jev reads literally. The instructions name the user's `request` and ask which
tool accomplishes it "by what each tool does, not by shared words"; each
option is `name: first sentence (from family)`; the state carries the request
and at most the caller's few recent turns.

## Building a ranker

Implement `JevEvaluator` in the host by translating `JevRequest` into the
client's wire request and translating its answer into `JevDecision`. Pass that
implementation to `JevRanker::new`. This keeps transport and runtime choices
at the host boundary where their policy belongs.
214 changes: 214 additions & 0 deletions crates/tinytools-jev/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
//! Dependency-free Jev-backed tool ranking.
//!
//! A host supplies [`JevEvaluator`], retaining ownership of transport,
//! authentication, retry, and deadline policy.
Comment on lines +1 to +4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add the required crate-level usage example

The new crate root stops after a four-line description and provides no runnable example showing how to implement JevEvaluator and construct JevRanker, nor a complete explanation of what remains host-owned. Add a compiled crate-level example and explicit boundary description so the primary public workflow is checked by doctests as required for every crate root.

AGENTS.md reference: AGENTS.md:L197-L201

Useful? React with 👍 / 👎.


#[cfg(test)]
mod test;
mod types;
pub use types::{JevDecision, JevEvaluator, JevOption, JevRankerConfig, JevRanking, JevRequest};

use std::{
collections::{BTreeMap, BTreeSet},
sync::Arc,
time::Duration,
};
use tinytools::{RankCandidate, RankContext, RankError, RankHit, ToolRanker};
const NONE_OPTION: &str = "none";
const MAX_SUMMARY_CHARS: usize = 240;

/// Ranks tools using a host-provided evaluator.
#[derive(Debug, Clone)]
pub struct JevRanker {
evaluator: Arc<dyn JevEvaluator>,
config: JevRankerConfig,
}
impl JevRanker {
/// Stable ranker kind.
pub const KIND: &'static str = "jev";
/// Creates a ranker.
#[must_use]
pub fn new(evaluator: Arc<dyn JevEvaluator>, config: JevRankerConfig) -> Self {
Self { evaluator, config }
}
/// Returns the active configuration.
#[must_use]
pub fn config(&self) -> &JevRankerConfig {
&self.config
}
/// Ranks and returns all decision metadata.
///
/// # Errors
/// Returns invalid-input errors and forwards evaluator/retriever failures.
pub async fn rank_detailed(
&self,
intent: &str,
context: &RankContext,
candidates: &[RankCandidate],
limit: usize,
) -> Result<JevRanking, RankError> {
let intent = intent.trim();
if intent.is_empty() {
return Err(RankError::invalid_input("intent is empty"));
}
if candidates.is_empty() || limit == 0 {
return Ok(JevRanking::empty());
}
let shortlist = self.shortlist(intent, context, candidates).await?;
if shortlist.is_empty() {
return Ok(JevRanking::empty());
}
let request = self.build_request(intent, context, &shortlist)?;
let started = std::time::Instant::now();
let decision = self.evaluator.evaluate(&request).await?;
let mut ranking = decode(&decision, &shortlist, self.config.min_probability);
ranking.hits.truncate(limit);
ranking.latency = started.elapsed();
Ok(ranking)
}
async fn shortlist<'a>(
&self,
intent: &str,
context: &RankContext,
candidates: &'a [RankCandidate],
) -> Result<Vec<&'a RankCandidate>, RankError> {
validate_candidates(candidates)?;
let k = self.config.retrieval_k.min(JevRankerConfig::MAX_CANDIDATES);
if candidates.len() <= k {
return Ok(candidates.iter().collect());
}
let hits = self
.config
.retriever
.rank(intent, context, candidates, k)
.await?;
let by_key: BTreeMap<&str, &RankCandidate> =
candidates.iter().map(|c| (c.key.as_str(), c)).collect();
let shortlist: Vec<_> = hits
.iter()
.filter_map(|h| by_key.get(h.key.as_str()).copied())
.take(JevRankerConfig::MAX_CANDIDATES)
.collect();
if shortlist.is_empty() && candidates.len() <= JevRankerConfig::MAX_CANDIDATES {
return Ok(candidates.iter().collect());
}
Ok(shortlist)
}
fn build_request(
&self,
intent: &str,
context: &RankContext,
shortlist: &[&RankCandidate],
) -> Result<JevRequest, RankError> {
if shortlist.len() > JevRankerConfig::MAX_CANDIDATES {
return Err(RankError::invalid_input("too many shortlisted candidates"));
}
let mut options: Vec<_> = shortlist
.iter()
.map(|c| JevOption {
key: c.key.clone(),
description: option_text(c),
})
.collect();
options.push(JevOption {
key: NONE_OPTION.into(),
description: "No listed tool accomplishes the request.".into(),
});
Ok(JevRequest {
intent: intent.into(),
recent_turns: context.recent_turns.clone(),
options,
model: self.config.model.clone(),
})
}
}
#[async_trait::async_trait]
impl ToolRanker for JevRanker {
fn kind(&self) -> &'static str {
Self::KIND
}
async fn rank(
&self,
intent: &str,
context: &RankContext,
candidates: &[RankCandidate],
limit: usize,
) -> Result<Vec<RankHit>, RankError> {
self.rank_detailed(intent, context, candidates, limit)
.await
.map(|r| r.hits)
}
}
impl JevRanking {
fn empty() -> Self {
Self {
hits: vec![],
choice_confidence: 0.0,
needs_tool: None,
none_probability: 0.0,
shortlisted: 0,
input_tokens: None,
latency: Duration::ZERO,
attempts: 0,
}
}
}
fn validate_candidates(candidates: &[RankCandidate]) -> Result<(), RankError> {
let mut keys = BTreeSet::new();
for c in candidates {
if c.key == NONE_OPTION {
return Err(RankError::invalid_input("candidate key `none` is reserved"));
}
if !keys.insert(c.key.as_str()) {
return Err(RankError::invalid_input("duplicate candidate key"));
}
}
Ok(())
}
fn option_text(candidate: &RankCandidate) -> String {
let mut summary: String = candidate.summary.chars().take(MAX_SUMMARY_CHARS).collect();
if candidate.summary.chars().count() > MAX_SUMMARY_CHARS {
summary.push('…');
}
candidate.family.as_ref().map_or(summary.clone(), |family| {
format!("{summary} (from {family})")
})
}
fn decode(decision: &JevDecision, shortlist: &[&RankCandidate], floor: f64) -> JevRanking {
let none = decision
.probabilities
.get(NONE_OPTION)
.copied()
.unwrap_or(0.0);
let best = shortlist
.iter()
.filter_map(|c| decision.probabilities.get(&c.key).copied())
.fold(0.0_f64, f64::max);
let abstained = none >= best || decision.needs_tool.is_some_and(|p| p < 0.5);
let mut hits: Vec<_> = if abstained {
vec![]
} else {
shortlist
.iter()
.filter_map(|c| {
let p = *decision.probabilities.get(&c.key)?;
(p >= floor).then(|| RankHit {
key: c.key.clone(),
score: p,
confidence: Some(p),
Comment on lines +194 to +198

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject invalid evaluator probabilities before producing hits

When a host evaluator returns an out-of-range value such as 1.2 or infinity, this path exposes it directly as RankHit::confidence, violating the documented 0.0..=1.0 contract and potentially defeating downstream confidence gates. Validate every probability-bearing field in JevDecision as finite and within range, returning RankError for a malformed decision rather than emitting invalid ranking metadata.

Useful? React with 👍 / 👎.

})
})
.collect()
};
hits.sort_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.key.cmp(&b.key)));
JevRanking {
hits,
choice_confidence: decision.choice_confidence,
needs_tool: decision.needs_tool,
none_probability: none,
shortlisted: shortlist.len(),
input_tokens: decision.input_tokens,
latency: Duration::ZERO,
attempts: decision.attempts,
}
}
Loading
Loading