Tool calling - #6
Conversation
WalkthroughAdds a pluggable Toolbox for tool registration/invocation, integrates tools into sequence open and event handling, refactors GenStream to carry Result items, adds example programs and utilities demonstrating tool usage, updates Cargo.toml with dev-dependencies, and removes the Python Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant App as Application
participant Seq as Sequence
participant Toolbox
participant Model as ModelSocket
User->>App: register tools (add_tool)
App->>Toolbox: add_tool(Weather, Calculator)
App->>Seq: open(model, toolbox)
Seq->>Model: SeqOpenReq (tools_enabled=true)
Model-->>Seq: SeqOpenRes
Seq->>Seq: append(tool_def_prompt)
User->>Seq: append_message(user_query)
User->>Seq: generate()
Seq->>Model: generation request
Model-->>Seq: SeqToolCall event
Seq->>Toolbox: call_tools(SeqToolCall[])
Toolbox-->>Seq: ToolResult[]
Seq->>Model: SeqCommand::ToolReturn (with results)
Model-->>Seq: GenChunk(s)
Seq-->>App: GenStream emits Result<GenChunk, ModelSocketError>
App->>App: print_stream(stream)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Areas needing extra attention:
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/python.rs (1)
88-107: Pythonopentext_signature advertises removedtools_enabledparameterThe
text_signaturestill includestools_enabled=False, but the actual function signature removed this parameter:#[pyo3( text_signature = "($self, model, /, *, tools_enabled=False, tool_prompt=None, skip_prelude=False)", signature = (model, tool_prompt=None, skip_prelude=None) )] pub fn open( &self, model: &str, // tools_enabled: Option<bool>, tool_prompt: Option<&str>, skip_prelude: Option<bool>, ) -> PyResult<PyBlockingSeq> {Update
text_signatureto match and remove commented-outtools_enabledcode:- text_signature = "($self, model, /, *, tools_enabled=False, tool_prompt=None, skip_prelude=False)", + text_signature = "($self, model, /, *, tool_prompt=None, skip_prelude=False)", signature = (model, tool_prompt=None, skip_prelude=None) )] pub fn open( &self, model: &str, - // tools_enabled: Option<bool>, tool_prompt: Option<&str>, skip_prelude: Option<bool>, ) -> PyResult<PyBlockingSeq> { let client = self.inner.clone(); let seq = Python::attach(|py| { block_on(py, async move { let mut opts = OpenOpts::default(); - // opts.tools_enabled = tools_enabled.unwrap_or(false); opts.tool_prompt = tool_prompt.map(|s| s.to_string()); opts.skip_prelude = skip_prelude.unwrap_or(false);
🧹 Nitpick comments (11)
examples/utils/mod.rs (1)
6-35: Streaming + error handling look correct
print_streamcorrectly handles theResult<GenChunk, ModelSocketError>stream, respects thehidden/colorsflags, and propagates errors viaanyhow::Result. Per-event flushing is appropriate for a demo; if this ever gets reused in high‑volume contexts, consider buffering or less frequent flushes.Cargo.toml (1)
12-26: Confirm makingclienta default feature is intendedSetting:
[features] default = ["client"]means downstream crates will now pull in the async client stack (tokio, tungstenite, etc.) unless they opt out of default features. If the goal is to keep
modelsocketlightweight as a library dependency, you may want to leavedefault = []and ask users to enableclientexplicitly. Otherwise, this change is fine but is a notable public behavior shift.Also applies to: 37-37, 50-57
src/client/mod.rs (1)
2-8: Toolbox integration and error surface look solid, with a small edge-case to consider
- Exporting
pub mod tools;and threadingToolboxthroughOpenOptsandopen()intoSeq::newis clean and keeps tool support opt‑in at the call site.ModelSocketError::SeqClosedandOther(#[from] anyhow::Error)give you a clear way to signal local shutdown and wrap toolbox/meval/etc. errors.One edge case: if appending the
tool_def_promptas a system message fails:if let Some(tool_def_prompt) = tool_def_prompt { seq.append(tool_def_prompt, AppendOpts::system()).await?; }
open()will return an error after:
- the sequence has already been created, and
- it has been registered in
self.seqs.In practice the server will likely close the sequence on the same underlying failure, and
on_seq_closedwill clean it up, but you might want to either:
- treat the tool-definition append as best-effort (log but don’t fail
open()), or- explicitly remove the seq from
self.seqsbefore returningErrhere.The new
GenOpts/AppendOptshelpers (assistant(),user(),system()) are straightforward and make call sites much nicer.Also applies to: 19-50, 177-231, 321-335
src/client/seq.rs (2)
62-87: Tool call handling is correct; consider remote-close behavior
on_tool_callcorrectly:
- no-ops (with a debug log) if no toolbox is configured,
- delegates to
toolbox.call_tools(tool_calls).await,- sends a
SeqCommand::ToolReturnwith the collected results and defaultgen_opts,- and, on error, logs and tries to close the sequence.
That’s a solid first pass for tool integration.
Separately,
on_close(used when the server sendsMSEvent::SeqClosed) still just clearscmds/gen_streams:let mut cmds = self.cmds.lock().await; cmds.clear(); let mut gen_streams = self.gen_streams.lock().await; gen_streams.clear();so callers waiting on an in‑flight
append/generatedon’t receive aSeqClosederror when the server initiates the close. You’ve already introducedModelSocketError::SeqClosedand are using it inclose(), so you might want to mirror that here by sendingErr(ModelSocketError::SeqClosed)to any remaining senders before clearing the maps.Also applies to: 159-195
274-343: Good use ofSeqClosedinclose(); no obvious race issuesThe new
close()implementation:
- drains all outstanding
cmdsandgen_streams, sendingErr(ModelSocketError::SeqClosed)to each,- then re‑inserts a fresh
cid/sender pair for the close command,- sends
SeqCommand::Closeand waits for the confirmation via that channel.This avoids leaving waiting callers hanging and is structured to avoid holding the
cmdslock while doing network I/O. The TODO about marking the sequence as closed to prevent future commands is a reasonable follow‑up, but the current behavior is sound.src/client/tools.rs (6)
23-34: Consider handling duplicate tool names inadd_tool
add_toolwill silently overwrite any existing tool with the samedefinition.name. That may be surprising if multiple modules register tools and accidentally collide on names. Consider either:
- returning a
Result/Option<Box<dyn Tool>>to signal replacement, or- logging a warning when an entry is replaced.
Also, the
+ 'staticbound onTis redundant given theTooltrait already has'staticin its bounds.
36-48: Double‑checkcall_toolserror and execution semantics
call_toolsexecutes tools sequentially and short‑circuits on the first error (?on each call). That’s perfectly valid, but it means:
- later tools are not called if an earlier one fails,
- callers get only the first error, not a per‑tool result set with mixed successes/failures.
If the intended contract is “fire a batch and always get back one result per requested call,” you might instead want to collect per‑tool
Results or include error info inToolResult. If short‑circuiting is desired, documenting that behavior in the public API would help.
50-59: Improve diagnostics when tools are missingThe
"tool {} not found"error anddebug!(tool = name, "tool call")log are useful, but you might further improve debuggability by:
- including the list of available tool names in the error or log (especially useful when a name is slightly off),
- or at least distinguishing “lookup failure” logs from successful call logs.
Not required, but worth considering if tool registration is dynamic or user‑driven.
61-67: Avoid panic intool_def_promptand consider deterministic orderingTwo minor points here:
serde_json::to_string_pretty(&tool.parameters).unwrap()will panic on serialization failure. Given the types are under your control this should “never” fail, but panics from library code in a client API can still be surprising. Consider:
- returning
Result<String>fromtool_def_promptand propagating the error, or- using
.expect("failed to serialize tool parameters")for a clearer panic message if you truly consider it impossible.
tool_def_prompt(&self)iteratesself.tools.values()from aHashMap, so the ordering of tool definitions in the final prompt is non‑deterministic. If reproducible prompts matter (e.g., for tests or predictable UX), switching toBTreeMapor sorting by name beforejoinwould give stable ordering.Also applies to: 70-77
79-82: Tool trait bounds are strong; may want a more ergonomic Future typeRequiring
Tool: Debug + Send + Sync + 'staticis reasonable given the toolbox is shared across threads. Thecallsignature, however, effectively requires implementors to return a'staticFuture(because of the trait‑objectdyn Future), which prevents capturing&selfdirectly in anasyncblock.If you want to make implementors’ lives easier while still avoiding
async_trait, consider a boxed future alias with an explicit lifetime, e.g.:pub type ToolFuture<'a> = Pin<Box<dyn Future<Output = Result<String>> + Send + 'a>>; pub trait Tool: fmt::Debug + Send + Sync + 'static { fn definition(&self) -> ToolDefinition; fn call(&self, args: &str) -> ToolFuture<'_>; }This allows capturing
&selfsafely insidecall.
132-181: Good serialization test; consider adding a prompt/toolbox test laterThe
tool_definition_serializes_expected_parameterstest validates the JSON shape (type, required, property description, and enum), which is exactly where regressions are likely. As a possible follow‑up, you could add:
- a small test around
tool_def_promptto lock down formatting, and/or- a smoke test for
Toolbox::call_toolsusing a dummy tool.Not necessary for this PR, but would increase confidence as the toolbox evolves.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
Cargo.toml(3 hunks)examples/simple.rs(1 hunks)examples/tool_call.rs(1 hunks)examples/utils/mod.rs(1 hunks)src/client/mod.rs(6 hunks)src/client/seq.rs(12 hunks)src/client/tools.rs(1 hunks)src/python.rs(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
examples/utils/mod.rs (2)
src/python.rs (1)
modelsocket(413-419)src/client/seq.rs (1)
text(359-368)
examples/simple.rs (2)
src/client/mod.rs (5)
connect(83-86)user(268-273)user(308-312)assistant(261-266)assistant(302-306)examples/utils/mod.rs (1)
print_stream(6-36)
src/client/mod.rs (2)
src/client/tools.rs (5)
tools(18-18)tools(62-65)tool_def_prompt(61-67)tool_def_prompt(70-77)new(24-28)src/client/seq.rs (1)
new(32-48)
examples/tool_call.rs (4)
src/client/tools.rs (8)
tools(18-18)tools(62-65)fmt(16-20)default(102-108)new(24-28)definition(80-80)call(50-59)call(81-81)src/client/mod.rs (2)
connect(83-86)new(60-81)src/client/seq.rs (1)
new(32-48)examples/utils/mod.rs (1)
print_stream(6-36)
src/client/seq.rs (1)
src/protocol.rs (2)
cid(94-106)seq_id(108-120)
src/python.rs (1)
src/client/mod.rs (1)
open(177-231)
src/client/tools.rs (2)
src/client/seq.rs (1)
new(32-48)examples/tool_call.rs (5)
definition(66-85)definition(96-114)call(87-89)call(116-131)serde_json(119-119)
🔇 Additional comments (6)
src/python.rs (1)
319-344: GenStream iteration and error mapping for Python look correctThe updated
__next__correctly:
- uses
stream.next().awaitonGenStream<Item = Result<GenChunk, ModelSocketError>>,- maps any
Err(ModelSocketError)into aPyErrviamap_err,- and preserves the stream between iterations by storing it back on
self.This keeps Python streaming aligned with the new Rust
GenStreamsemantics.src/client/seq.rs (1)
20-48: GenStreamResultwrapping and sequence lifecycle look consistent
- Storing
gen_streamsasSender<Result<GenChunk, ModelSocketError>>and updatingGenStreamto exposeItem = Result<GenChunk, ModelSocketError>is consistent end‑to‑end:
on_textsendsOk(GenChunk),generatewires up the mpsc channel correctly,GenStream::text/text_and_tokensunwrap withchunk?and filter onhidden.- Forking passes
self.toolbox.clone()into the childSeq::new, so tools remain available afterfork().closenow notifies all outstanding commands and generation streams withErr(ModelSocketError::SeqClosed)before issuing the close command, which gives callers a clear signal instead of hanging.This all looks correct and matches the new error surface.
Also applies to: 139-157, 247-272, 346-383
src/client/tools.rs (4)
11-13: Toolbox core structure looks solidStoring tools as
HashMap<String, Box<dyn Tool>>keyed by their definition name is a straightforward and flexible design, and it composes well with theTooltrait object.
15-21: NiceDebugimpl focusing on tool namesThe custom
Debugimplementation that only exposes the tool keys (names) is a good balance between debuggability and not dumping full tool internals.
84-109: Tool metadata structs line up well with JSON Schema‑style usage
ToolDefinition,ToolParameters, and theDefaultimpl forToolParametersmap cleanly to an object‑typed parameter schema, and the serde attributes (rename = "type",skip_serializing_if) look correct for the intended JSON layout. This should work nicely with theserde_json::from_valuepatterns used in the examples.
111-121: Property and schema‑type design is flexible and matches typical tool schemas
ToolPropertywith optionaldescriptionandenum(viaallowed_values) plusToolSchemaType’ssnake_caseserialization should cover most tool parameter shapes you’ll need, while staying simple. This mirrors common LLM tool schema conventions well.
| #[derive(Parser, Debug)] | ||
| struct Args { | ||
| #[clap(short, long, default_value = "wss://models.mixlayer.ai/ws")] | ||
| url: String, | ||
| #[clap(short, long, env = "MODELSOCKET_API_KEY")] | ||
| api_key: String, | ||
| #[clap(long, long, default_value_t = false)] | ||
| hidden: bool, | ||
| #[clap(long, long, default_value_t = true)] | ||
| colors: bool, | ||
| } |
There was a problem hiding this comment.
CLI hidden/colors flags are ignored
You parse hidden and colors on Args but always call:
utils::print_stream(stream, true, true).await?;This ignores the user’s choices. Consider:
- utils::print_stream(stream, true, true).await?;
+ utils::print_stream(stream, args.hidden, args.colors).await?;so the CLI options actually affect output.
Also applies to: 32-41
🤖 Prompt for AI Agents
In examples/simple.rs around lines 7-17 (and also apply same fix at lines
~32-41): the CLI boolean flags are defined with duplicate attribute tokens and
their parsed values are never used because print_stream is called with hardcoded
true/true. Fix by removing the duplicated 'long' attributes (keep a single
#[clap(long, default_value_t = false)] for hidden and #[clap(long,
default_value_t = true)] for colors) so clap parses them correctly, and change
the print_stream calls to pass the parsed args.hidden and args.colors instead of
hardcoded true, true; apply the same replacement for the other call site at
lines 32-41.
There was a problem hiding this comment.
Actionable comments posted: 0
♻️ Duplicate comments (2)
examples/simple.rs (1)
13-16: Remove duplicatedlongin clap attributes
hiddenandcolorsuse#[clap(long, long, ...)]; the secondlongis redundant and may cause the derive macro to error. Keep a singlelong:- #[clap(long, long, default_value_t = false)] + #[clap(long, default_value_t = false)] - #[clap(long, long, default_value_t = true)] + #[clap(long, default_value_t = true)]examples/tool_call.rs (1)
20-23: Fix duplicatedlongin clap attributesSame as in
examples/simple.rs,hiddenandcolorsuse#[clap(long, long, ...)]; drop the extralong:- #[clap(long, long, default_value_t = false)] + #[clap(long, default_value_t = false)] - #[clap(long, long, default_value_t = true)] + #[clap(long, default_value_t = true)]
🧹 Nitpick comments (2)
Cargo.toml (1)
3-57: Confirm defaultclientfeature and tokio setup are intentionalMaking
clientthe default feature and keepingtokiooptional while also adding a dev-onlytokiois a meaningful API/dependency change for crate consumers (they now pull in client + async stack by default). Please confirm this is the intended public behavior and that you don’t want a lighter default profile.examples/tool_call.rs (1)
95-105: Markexpressionas required in CalculatorTool schema
WeatherTooldeclares"required": ["location"], butCalculatorTooldoesn’t markexpressionas required, even thoughcallwill error if it’s missing. Consider:let parameters = json!({ "type": "object", "properties": { "expression": { "type": "string", "description": "A mathematical expression to evaluate (e.g. 1 + 1)" } }, + "required": ["expression"] });to keep the schema consistent and reduce tool-call failures.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (3)
Cargo.toml(4 hunks)examples/simple.rs(1 hunks)examples/tool_call.rs(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
examples/simple.rs (2)
src/client/mod.rs (5)
connect(83-86)user(268-273)user(308-312)assistant(261-266)assistant(302-306)examples/utils/mod.rs (1)
print_stream(6-36)
examples/tool_call.rs (3)
src/client/tools.rs (7)
tools(18-18)tools(62-65)default(102-108)new(24-28)definition(80-80)call(50-59)call(81-81)src/client/seq.rs (1)
new(32-48)examples/utils/mod.rs (1)
print_stream(6-36)
🔇 Additional comments (2)
examples/simple.rs (1)
19-41: Example flow and CLI usage look solidConnection, sequence open, append, generate, and stream printing are wired cleanly, and
print_streamnow respectshidden/colorsfrom CLI.examples/tool_call.rs (1)
26-59: Toolbox wiring and tool implementations look goodToolbox creation, tool registration, sequence open with
OpenOpts, and the end-to-end tool-call example (weather + calculator) are clear and aligned with the client/tooling API.
Summary by CodeRabbit
New Features
Breaking Changes