Skip to content
Open
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
2 changes: 1 addition & 1 deletion crates/jp_cli/src/cmd/query/turn_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ pub(super) async fn run_turn_loop(
// buffer first so buffered text appears before the
// "Calling tool" line (fixes Issue 1).
if let Event::Part {
part: EventPart::ToolCall(ToolCallPart::Start { id, name }),
part: EventPart::ToolCall(ToolCallPart::Start { id, name, .. }),
..
} = &event
{
Expand Down
8 changes: 8 additions & 0 deletions crates/jp_llm/src/event.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
use std::sync::Arc;

use jp_conversation::{ConversationStream, OverlayAction, OverlayMatcher, OverlayPatch};
use serde_json::{Map, Value};

use crate::tool::decoding::ArgumentDecoding;

/// Represents a completed event from the LLM.
///
/// In the context of [`crate::Provider::chat_completion_stream`], individual
Expand Down Expand Up @@ -110,6 +114,9 @@ pub enum ToolCallPart {

/// Name of the tool to execute.
name: String,

/// Request-local decoding instructions, consumed before persistence.
decoding: Option<Arc<ArgumentDecoding>>,
},

/// A raw JSON chunk of tool call arguments.
Expand Down Expand Up @@ -281,6 +288,7 @@ impl Event {
part: EventPart::ToolCall(ToolCallPart::Start {
id: id.into(),
name: name.into(),
decoding: None,
}),
metadata: Map::new(),
}
Expand Down
25 changes: 21 additions & 4 deletions crates/jp_llm/src/event_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@
//! arrives.
//! The Flush after the last chunk marks the tool call as complete.

use std::collections::{HashMap, hash_map::Entry};
use std::{
collections::{HashMap, hash_map::Entry},
sync::Arc,
};

use jp_conversation::{
ConversationEvent,
Expand All @@ -40,7 +43,10 @@ use jp_conversation::{
use serde_json::{Map, Value};
use tracing::warn;

use crate::event::{Event, EventPart, ToolCallPart};
use crate::{
event::{Event, EventPart, ToolCallPart},
tool::decoding::ArgumentDecoding,
};

/// Extract the structured JSON payload from a completed list of stream events.
///
Expand Down Expand Up @@ -163,14 +169,16 @@ impl EventBuilder {
Entry::Occupied(mut e) => e.get_mut().merge_tool_call_part(tool_call_part),
Entry::Vacant(e) => {
let buffer = match tool_call_part {
ToolCallPart::Start { id, name } => IndexBuffer::ToolCall {
ToolCallPart::Start { id, name, decoding } => IndexBuffer::ToolCall {
id,
name,
decoding,
arguments_json: String::new(),
},
ToolCallPart::ArgumentChunk(json) => IndexBuffer::ToolCall {
id: String::new(),
name: String::new(),
decoding: None,
arguments_json: json,
},
};
Expand Down Expand Up @@ -217,15 +225,19 @@ impl EventBuilder {
id,
name,
arguments_json,
decoding,
} => {
let arguments = if arguments_json.trim().is_empty() {
let mut arguments = if arguments_json.trim().is_empty() {
serde_json::Map::new()
} else {
serde_json::from_str(&arguments_json).unwrap_or_else(|e| {
warn!("Failed to parse tool call arguments JSON: {e}");
serde_json::Map::new()
})
};
if let Some(decoding) = decoding {
decoding.apply(&mut arguments);
}
ConversationEvent::now(ToolCallRequest {
id,
name,
Expand Down Expand Up @@ -361,6 +373,8 @@ enum IndexBuffer {
name: String,
/// Raw JSON arguments accumulated from chunks.
arguments_json: String,
/// Decoding chosen by the first non-empty tool name.
decoding: Option<Arc<ArgumentDecoding>>,
},
/// Accumulates streamed JSON chunks for a structured response.
///
Expand All @@ -380,6 +394,7 @@ impl IndexBuffer {
id,
name,
arguments_json,
decoding,
} = self
else {
warn!(
Expand All @@ -393,12 +408,14 @@ impl IndexBuffer {
ToolCallPart::Start {
id: incoming_id,
name: incoming_name,
decoding: incoming_decoding,
} => {
if id.is_empty() && !incoming_id.is_empty() {
*id = incoming_id;
}
if name.is_empty() && !incoming_name.is_empty() {
*name = incoming_name;
*decoding = incoming_decoding;
}
}
ToolCallPart::ArgumentChunk(json) => {
Expand Down
6 changes: 6 additions & 0 deletions crates/jp_llm/src/event_builder_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ fn test_handles_tool_call() {
EventPart::ToolCall(ToolCallPart::Start {
id: "call_1".into(),
name: "test_tool".into(),
decoding: None,
}),
Map::new(),
);
Expand All @@ -68,6 +69,7 @@ fn test_incomplete_tool_calls_skips_unnamed_buffers() {
EventPart::ToolCall(ToolCallPart::Start {
id: "call_1".into(),
name: "fs_modify_file".into(),
decoding: None,
}),
Map::new(),
);
Expand Down Expand Up @@ -95,6 +97,7 @@ fn test_merges_multi_part_tool_call() {
EventPart::ToolCall(ToolCallPart::Start {
id: "call_42".into(),
name: "fs_create_file".into(),
decoding: None,
}),
Map::new(),
);
Expand Down Expand Up @@ -128,6 +131,7 @@ fn test_multi_part_tool_call_first_write_wins_for_id_and_name() {
EventPart::ToolCall(ToolCallPart::Start {
id: "first_id".into(),
name: "first_name".into(),
decoding: None,
}),
Map::new(),
);
Expand All @@ -138,6 +142,7 @@ fn test_multi_part_tool_call_first_write_wins_for_id_and_name() {
EventPart::ToolCall(ToolCallPart::Start {
id: "second_id".into(),
name: "second_name".into(),
decoding: None,
}),
Map::new(),
);
Expand Down Expand Up @@ -437,6 +442,7 @@ fn drain_drops_incomplete_tool_call_but_keeps_partial_text() {
EventPart::ToolCall(ToolCallPart::Start {
id: "toolu_incomplete".into(),
name: "some_tool".into(),
decoding: None,
}),
Map::new(),
);
Expand Down
4 changes: 4 additions & 0 deletions crates/jp_llm/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,10 @@ pub(crate) fn trace_to_tmpfile(prefix: &str, value: &impl serde::Serialize) -> S
}
}

#[cfg(test)]
#[path = "provider/tool_decoding_tests.rs"]
mod tool_decoding_tests;

#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;
Expand Down
47 changes: 29 additions & 18 deletions crates/jp_llm/src/provider/llamacpp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use tracing::{debug, trace, warn};

use super::{
EventStream, ModelDetails,
openai::parameters_with_strict_mode,
openai::parameters_with_decoding,
openai_compat::{merge_consecutive_assistant_messages, parse_chunk},
};
use crate::{
Expand All @@ -33,7 +33,7 @@ use crate::{
provider::Provider,
query::ChatQuery,
stream::{aggregator::reasoning::ReasoningExtractor, with_tool_call_keepalive},
tool::ToolDefinition,
tool::{ToolDefinition, decoding::ArgumentDecoders},
};

static PROVIDER: ProviderId = ProviderId::Llamacpp;
Expand Down Expand Up @@ -93,7 +93,7 @@ impl Provider for Llamacpp {
"Starting Llamacpp chat completion stream."
);

let (body, is_structured) = create_request(model, query)?;
let (body, is_structured, decoders) = create_request(model, query)?;

trace!(
body = serde_json::to_string(&body).unwrap_or_default(),
Expand All @@ -113,10 +113,10 @@ impl Provider for Llamacpp {
// silently re-issuing the request.
es.set_retry_policy(Box::new(Never));

Ok(with_tool_call_keepalive(
Ok(decoders.attach(with_tool_call_keepalive(
assemble_event_stream(es, is_structured),
TOOL_CALL_KEEPALIVE_INTERVAL,
))
)))
}
}

Expand Down Expand Up @@ -414,16 +414,20 @@ impl Llamacpp {
model: &ModelDetails,
query: ChatQuery,
) -> Result<serde_json::Value, Error> {
let (request, _) = create_request(model, query)?;
let (request, ..) = create_request(model, query)?;
Ok(request)
}
}

/// Build the JSON request body for the llama.cpp `/v1/chat/completions`
/// endpoint.
///
/// Returns `(body, is_structured)`.
fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool), Error> {
/// Returns the request, structured-output flag, and tool argument decoding
/// plans.
fn create_request(
model: &ModelDetails,
query: ChatQuery,
) -> Result<(Value, bool, ArgumentDecoders), Error> {
let ChatQuery {
thread,
tools,
Expand Down Expand Up @@ -483,7 +487,7 @@ fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool
}

messages.extend(convert_events(parts.events));
let converted_tools = convert_tools(tools, &tool_choice);
let (converted_tools, decoders) = convert_tools(tools, &tool_choice);
let tool_choice_val = convert_tool_choice(&tool_choice);

trace!(
Expand Down Expand Up @@ -543,7 +547,7 @@ fn create_request(model: &ModelDetails, query: ChatQuery) -> Result<(Value, bool
});
}

Ok((body, is_structured))
Ok((body, is_structured, decoders))
}

/// Convert system prompt parts into a list of JSON message values.
Expand Down Expand Up @@ -608,25 +612,32 @@ fn convert_events(events: ConversationStream) -> Vec<Value> {
/// If [`ToolChoice::Function`] is set, only include the named tool. llama.cpp
/// doesn't support calling a specific tool by name, but it supports `required`
/// mode, so we limit the tool list instead.
fn convert_tools(tools: Vec<ToolDefinition>, tool_choice: &ToolChoice) -> Vec<Value> {
tools
fn convert_tools(
tools: Vec<ToolDefinition>,
tool_choice: &ToolChoice,
) -> (Vec<Value>, ArgumentDecoders) {
let mut decoders = ArgumentDecoders::default();
let tools = tools
.into_iter()
.filter(|tool| match tool_choice {
ToolChoice::Function(req) => &tool.name == req,
_ => true,
})
.map(|tool| {
let (parameters, decoding) = parameters_with_decoding(&tool.parameters, true);
decoders.insert(&tool.name, decoding);
json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.docs.schema_description().unwrap_or_default(),
"parameters": parameters_with_strict_mode(&tool.parameters, true),
"parameters": parameters,
"strict": true,
},
})
})
.filter(|tool| match tool_choice {
ToolChoice::Function(req) => tool["function"]["name"].as_str() == Some(req.as_str()),
_ => true,
})
.collect()
.collect();
(tools, decoders)
}

fn convert_tool_choice(choice: &ToolChoice) -> &str {
Expand Down
2 changes: 1 addition & 1 deletion crates/jp_llm/src/provider/llamacpp_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ fn request_body(
reasoning: Option<jp_config::model::parameters::PartialReasoningConfig>,
) -> serde_json::Value {
let details = ModelDetails::empty((PROVIDER, "Qwen3.5-9B-GGUF").try_into().unwrap());
let (request, _) = create_request(&details, reasoning_query(reasoning)).unwrap();
let (request, ..) = create_request(&details, reasoning_query(reasoning)).unwrap();

request
}
Expand Down
Loading
Loading