-
Notifications
You must be signed in to change notification settings - Fork 21
feat(discover): pluggable ToolRanker with BM25 fallback and compare mode #185
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6e79f0a
a5f4a24
7a06c3b
3635b1f
31ab308
ae48b2c
475ee2c
48f9189
3966e3b
e5c2411
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -284,16 +284,22 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> { | |
| if !self.policy.discovery.enabled { | ||
| return crate::tool::discover::DeferredCatalog::default(); | ||
| } | ||
| let mut schemas = self | ||
| let mut entries = self | ||
| .tools | ||
| .deferred_schemas() | ||
| .deferred_schemas_with_families() | ||
| .into_iter() | ||
| .filter(|schema| host_allows(&schema.name)) | ||
| .filter(|(schema, _)| host_allows(&schema.name)) | ||
| .collect::<Vec<_>>(); | ||
| if let Some(preparation) = &self.policy.tool_schemas { | ||
| schemas = crate::tool::prepare_tool_schemas(&schemas, preparation); | ||
| let families: Vec<Option<String>> = | ||
| entries.iter().map(|(_, family)| family.clone()).collect(); | ||
| let schemas: Vec<_> = entries.into_iter().map(|(schema, _)| schema).collect(); | ||
| entries = crate::tool::prepare_tool_schemas(&schemas, preparation) | ||
| .into_iter() | ||
| .zip(families) | ||
| .collect(); | ||
| } | ||
| crate::tool::discover::DeferredCatalog::build(schemas) | ||
| crate::tool::discover::DeferredCatalog::build_with_families(entries) | ||
| } | ||
|
|
||
| /// Resolves the discovery bridge for one call, when it is one. | ||
|
|
@@ -303,7 +309,7 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> { | |
| /// admission continues with it, and `None` untouched for any other name. | ||
| /// A malformed `tool_call` payload is answered with a tool error rather | ||
| /// than passed on, so the model can correct it. | ||
| fn answer_discovery_bridge( | ||
| async fn answer_discovery_bridge( | ||
| &self, | ||
| ctx: &RunContext<Ctx>, | ||
| status: &mut HarnessRunStatus, | ||
|
|
@@ -334,11 +340,13 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> { | |
| return Ok(None); | ||
| } | ||
| if call.name == TOOL_SEARCH_NAME { | ||
| let (result, matched) = crate::tool::discover::answer_tool_search( | ||
| let answer = crate::tool::discover::answer_tool_search( | ||
| &catalog, | ||
| &self.policy.discovery, | ||
| &call.arguments, | ||
| ); | ||
| ) | ||
| .await; | ||
| let ranking = answer.ranking; | ||
| // `query` is model-supplied tool-call content, same privacy | ||
| // class as a normal tool call's arguments, so it honors the same | ||
| // `RunPolicy::capture.tool_io` gate (default `false`, payload | ||
|
|
@@ -355,10 +363,18 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> { | |
| } else { | ||
| String::new() | ||
| }, | ||
| matched, | ||
| matched: answer.matched, | ||
| ranker: ranking | ||
| .as_ref() | ||
| .map(|r| r.ranker.to_string()) | ||
| .unwrap_or_default(), | ||
| top_confidence: ranking.as_ref().and_then(|r| r.top_confidence), | ||
| fallback: ranking.as_ref().and_then(|r| r.fallback.clone()), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| shadow_matched: ranking.as_ref().and_then(|r| r.shadow_names.clone()), | ||
| latency_ms: ranking.as_ref().map_or(0, |r| r.latency_ms), | ||
| }); | ||
| status.set_last_event(record.id); | ||
| return Ok(Some(ResolvedToolCall::Answered(result))); | ||
| return Ok(Some(ResolvedToolCall::Answered(answer.result))); | ||
| } | ||
| match crate::tool::discover::unwrap_tool_call(&call.arguments) { | ||
| Ok((name, arguments)) => { | ||
|
|
@@ -517,7 +533,7 @@ impl<State: Send + Sync, Ctx: Send + Sync> AgentHarness<State, Ctx> { | |
| // a call the provider could not parse is left for the recovery below. | ||
| if call.invalid.is_none() | ||
| && self.tools.dispatch(&call.name).is_none() | ||
| && let Some(answered) = self.answer_discovery_bridge(ctx, status, call)? | ||
| && let Some(answered) = self.answer_discovery_bridge(ctx, status, call).await? | ||
| { | ||
| return Ok(answered); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -139,6 +139,24 @@ pub enum AgentEvent { | |
| query: String, | ||
| /// Number of deferred tools returned. | ||
| matched: usize, | ||
| /// Which ranker's answer was served: `"bm25"`, or the host ranker's | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update every ToolSearch constructor for the new fields These fields are required when constructing the [RULE] breaking-enum-constructor · |
||
| /// [`tinytools::ToolRanker::kind`]. Empty when the query was rejected | ||
| /// before ranking. | ||
| #[serde(default)] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update every ToolSearch event constructor Adding fields to a struct-like enum variant changes the required fields for every [RULE] compile-break · |
||
| ranker: String, | ||
| /// The best hit's calibrated confidence, when the ranker gave one. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| top_confidence: Option<f64>, | ||
| /// Why the host ranker was not served, when one was active. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| fallback: Option<String>, | ||
| /// The BM25 ranking, when the policy asked to compare it against the | ||
| /// served one. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| shadow_matched: Option<Vec<String>>, | ||
| /// Wall time of the ranking, in milliseconds. | ||
| #[serde(default)] | ||
| latency_ms: u64, | ||
| }, | ||
|
|
||
| /// The model invoked a deferred tool through the intrinsic `tool_call` | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,10 +11,10 @@ | |
|
|
||
| use serde_json::{Value, json}; | ||
| use tinyinference_llm::tool::{ToolFormat, ToolSchema}; | ||
| use tinytools::ToolResult; | ||
| use tinytools::{RankContext, ToolResult}; | ||
|
|
||
| use super::manifest::render_manifest; | ||
| use super::types::{DeferredCatalog, ToolDiscoveryPolicy}; | ||
| use super::types::{DeferredCatalog, RankedSearch, ToolDiscoveryPolicy}; | ||
|
|
||
| /// Name of the intrinsic search bridge. | ||
| pub const TOOL_SEARCH_NAME: &str = "tool_search"; | ||
|
|
@@ -96,26 +96,42 @@ fn tool_call_schema() -> ToolSchema { | |
| } | ||
| } | ||
|
|
||
| /// Answers a `tool_search` call against the run's catalogue, returning the | ||
| /// result to hand the model and how many tools it named. | ||
| #[must_use] | ||
| pub fn answer_tool_search( | ||
| /// What a `tool_search` produced: the result to hand the model plus the | ||
| /// facts the loop reports in its `ToolSearched` event. | ||
| #[derive(Debug)] | ||
| pub struct SearchAnswer { | ||
| /// The tool result the model sees. | ||
| pub result: ToolResult, | ||
| /// How many tools it named. | ||
| pub matched: usize, | ||
| /// Which ranker's answer was served, and how it went. `None` when the | ||
| /// query was rejected before ranking. | ||
| pub ranking: Option<RankedSearch>, | ||
| } | ||
|
|
||
| /// Answers a `tool_search` call against the run's catalogue. | ||
| /// | ||
| /// Ranks as `policy` says — the host ranker when one is active, BM25 | ||
| /// otherwise or on failure — and returns the full schema of every hit so | ||
| /// the model can call it. | ||
| pub async fn answer_tool_search( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update callers for the asynchronous SearchAnswer API This changes Additional
|
||
| catalog: &DeferredCatalog, | ||
| policy: &ToolDiscoveryPolicy, | ||
| arguments: &Value, | ||
| ) -> (ToolResult, usize) { | ||
| ) -> SearchAnswer { | ||
| let query = arguments | ||
| .get("query") | ||
| .and_then(Value::as_str) | ||
| .unwrap_or_default() | ||
| .trim(); | ||
| if query.is_empty() { | ||
| return ( | ||
| ToolResult::error(format!( | ||
| return SearchAnswer { | ||
| result: ToolResult::error(format!( | ||
| "`{TOOL_SEARCH_NAME}` needs a `query` describing what you want to do." | ||
| )), | ||
| 0, | ||
| ); | ||
| matched: 0, | ||
| ranking: None, | ||
| }; | ||
| } | ||
| let (default_limit, max_limit) = policy.effective_limits(); | ||
| let limit = arguments | ||
|
|
@@ -125,16 +141,24 @@ pub fn answer_tool_search( | |
| usize::try_from(n).unwrap_or(usize::MAX).clamp(1, max_limit) | ||
| }); | ||
|
|
||
| let matches = catalog.search(query, limit); | ||
| let ranking = catalog | ||
| .rank(policy, query, &RankContext::empty(), limit) | ||
| .await; | ||
| let matches: Vec<&ToolSchema> = ranking | ||
| .names | ||
| .iter() | ||
| .filter_map(|name| catalog.get(name)) | ||
| .collect(); | ||
| if matches.is_empty() { | ||
| return ( | ||
| ToolResult::success(format!( | ||
| return SearchAnswer { | ||
| result: ToolResult::success(format!( | ||
| "No deferred tool matches \"{query}\". {} tool(s) are searchable; everything \ | ||
| else you can use is already in your tool list.", | ||
| catalog.len() | ||
| )), | ||
| 0, | ||
| ); | ||
| matched: 0, | ||
| ranking: Some(ranking), | ||
| }; | ||
| } | ||
| let payload: Vec<Value> = matches | ||
| .iter() | ||
|
|
@@ -148,13 +172,14 @@ pub fn answer_tool_search( | |
| .collect(); | ||
| let rendered = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "[]".to_string()); | ||
| let matched = payload.len(); | ||
| ( | ||
| ToolResult::success(format!( | ||
| SearchAnswer { | ||
| result: ToolResult::success(format!( | ||
| "{matched} match(es). Invoke one with `{TOOL_CALL_NAME}` {{\"name\", \"arguments\"}} \ | ||
| or by its own name, using the parameters shown.\n{rendered}" | ||
| )), | ||
| matched, | ||
| ) | ||
| ranking: Some(ranking), | ||
| } | ||
| } | ||
|
|
||
| /// Unwraps a `tool_call` payload into the real `(name, arguments)` pair. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an optional hosted ranker performs network I/O and stalls, this await is outside
with_call_budget; the only deadline check happened before admission began. Consequently, a run with a configured wall-clock limit can remain stuck intool_searchindefinitely instead of returningTinyAgentsError::Timeout, so the ranking future should be raced against the run's remaining budget like model, tool, authorization, and screening calls.Useful? React with 👍 / 👎.