Skip to content

Commit aed3748

Browse files
authored
Feat/gemini thought signature (#1)
* feat: parts thought signature * feat: gemini adapter thought signature support - Add ContentPart::ThoughtSignature with accessors and helpers. - Introduce InterStreamEvent::ThoughtSignatureChunk and ChatStreamEvent::ThoughtSignatureChunk. - Capture thought signatures into StreamEnd.captured_content when enabled. - Update printer to display ThoughtSignatureChunk events (non-captured). - Gemini adapter: parse thoughtSignature (fallback: thought) and include in outgoing messages. - Gemini streamer: surface thought as ThoughtSignatureChunk and record in captured_thought_signatures. - Preserve thoughtSignature in assistant history before tool calls when looping tools. - Example c10-tooluse-streaming.rs: switch to gemini-3-pro-preview, log thoughtSignature chunks, and prepend captured thoughts to assistant message. * chore: impl thought signature * chore: no API change
1 parent b0bf4da commit aed3748

21 files changed

Lines changed: 510 additions & 81 deletions

examples/c10-tooluse-streaming.rs

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@ use genai::Client;
33
use genai::chat::printer::{PrintChatStreamOptions, print_chat_stream};
44
use genai::chat::{ChatMessage, ChatOptions, ChatRequest, Tool, ToolResponse};
55
use genai::chat::{ChatStreamEvent, ToolCall};
6+
use genai::resolver::AuthData;
67
use serde_json::json;
78
use tracing_subscriber::EnvFilter;
89

910
// const MODEL: &str = "gemini-2.0-flash";
10-
const MODEL: &str = "deepseek-chat";
11+
// const MODEL: &str = "deepseek-chat";
12+
const MODEL: &str = "gemini-3-pro-preview";
1113

1214
#[tokio::main]
1315
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -18,6 +20,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
1820

1921
let client = Client::default();
2022

23+
println!("--- Model: {MODEL}");
24+
2125
// 1. Define a tool for getting weather information
2226
let weather_tool = Tool::new("get_weather")
2327
.with_description("Get the current weather for a location")
@@ -53,6 +57,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5357
let mut chat_stream = client.exec_chat_stream(MODEL, chat_req.clone(), Some(&chat_options)).await?;
5458

5559
let mut tool_calls: Vec<ToolCall> = [].to_vec();
60+
let mut captured_thoughts: Option<Vec<String>> = None;
61+
5662
// print_chat_stream(chat_res, Some(&print_options)).await?;
5763
println!("--- Streaming response with tool calls");
5864
while let Some(result) = chat_stream.stream.next().await {
@@ -63,25 +69,53 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6369
ChatStreamEvent::Chunk(chunk) => {
6470
print!("{}", chunk.content);
6571
}
66-
ChatStreamEvent::ToolCallChunk(tool_chunk) => {
67-
println!(
68-
"\nTool Call: {} with args: {}",
69-
tool_chunk.tool_call.fn_name, tool_chunk.tool_call.fn_arguments
70-
);
72+
ChatStreamEvent::ToolCallChunk(chunk) => {
73+
println!(" ToolCallChunk: {:?}", chunk.tool_call);
7174
}
7275
ChatStreamEvent::ReasoningChunk(chunk) => {
73-
println!("\nReasoning: {}", chunk.content);
76+
println!(" ReasoningChunk: {:?}", chunk.content);
77+
}
78+
ChatStreamEvent::ThoughtSignatureChunk(chunk) => {
79+
println!(" ThoughtSignatureChunk: {:?}", chunk.content);
7480
}
7581
ChatStreamEvent::End(end) => {
7682
println!("\nStream ended");
7783

7884
// Check if we captured any tool calls
79-
if let Some(captured_tool_calls) = end.captured_into_tool_calls() {
80-
println!("\nCaptured Tool Calls:");
81-
tool_calls = captured_tool_calls.clone();
82-
for tool_call in captured_tool_calls {
83-
println!("- Function: {}", tool_call.fn_name);
84-
println!(" Arguments: {}", tool_call.fn_arguments);
85+
// Note: captured_into_tool_calls consumes self, so we can't use end afterwards.
86+
// We should access captured_content directly or use references if possible,
87+
// but StreamEnd getters often consume or clone.
88+
// Let's access captured_content directly since we need both tool calls and thoughts.
89+
90+
if let Some(content) = end.captured_content {
91+
// Let's refactor to avoid ownership issues.
92+
// We have `content` (MessageContent).
93+
// We want `tool_calls` (Vec<ToolCall>) and `thoughts` (Vec<String>).
94+
95+
// We can iterate and split.
96+
let parts = content.into_parts();
97+
let mut extracted_tool_calls = Vec::new();
98+
let mut extracted_thoughts = Vec::new();
99+
100+
for part in parts {
101+
match part {
102+
genai::chat::ContentPart::ToolCall(tc) => extracted_tool_calls.push(tc),
103+
genai::chat::ContentPart::ThoughtSignature(t) => extracted_thoughts.push(t),
104+
_ => {}
105+
}
106+
}
107+
108+
if !extracted_tool_calls.is_empty() {
109+
println!("\nCaptured Tool Calls:");
110+
for tool_call in &extracted_tool_calls {
111+
println!("- Function: {}", tool_call.fn_name);
112+
println!(" Arguments: {}", tool_call.fn_arguments);
113+
}
114+
tool_calls = extracted_tool_calls;
115+
}
116+
117+
if !extracted_thoughts.is_empty() {
118+
captured_thoughts = Some(extracted_thoughts);
85119
}
86120
}
87121
}
@@ -107,7 +141,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
107141
);
108142

109143
// Add both the tool calls and response to chat history
110-
let chat_req = chat_req.append_message(tool_calls).append_message(tool_response);
144+
// Note: For Gemini 3, we MUST include the thoughtSignature in the history if it was generated.
145+
let mut assistant_msg = ChatMessage::from(tool_calls);
146+
if let Some(thoughts) = captured_thoughts {
147+
// We need to insert the thought at the beginning.
148+
// MessageContent wraps Vec<ContentPart>, but doesn't expose insert.
149+
// We can convert to Vec, insert, and convert back.
150+
let mut parts = assistant_msg.content.into_parts();
151+
for thought in thoughts.into_iter().rev() {
152+
parts.insert(0, genai::chat::ContentPart::ThoughtSignature(thought));
153+
}
154+
assistant_msg.content = genai::chat::MessageContent::from_parts(parts);
155+
}
156+
let chat_req = chat_req.append_message(assistant_msg).append_message(tool_response);
111157

112158
// Get final streaming response
113159
let chat_options = ChatOptions::default();

src/adapter/adapters/anthropic/adapter_impl.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ impl Adapter for AnthropicAdapter {
252252
call_id,
253253
fn_name,
254254
fn_arguments,
255+
thought_signatures: None,
255256
};
256257

257258
let part = ContentPart::ToolCall(tool_call);
@@ -451,6 +452,7 @@ impl AnthropicAdapter {
451452
"tool_use_id": tool_response.call_id,
452453
}));
453454
}
455+
ContentPart::ThoughtSignature(_) => {}
454456
}
455457
}
456458
let values = apply_cache_control_to_parts(is_cache_control, values);
@@ -483,6 +485,7 @@ impl AnthropicAdapter {
483485
// Unsupported for assistant role in Anthropic message content
484486
ContentPart::Binary(_) => {}
485487
ContentPart::ToolResponse(_) => {}
488+
ContentPart::ThoughtSignature(_) => {}
486489
}
487490
}
488491

src/adapter/adapters/anthropic/streamer.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ impl futures::Stream for AnthropicStreamer {
136136
call_id: id,
137137
fn_name: name,
138138
fn_arguments: serde_json::from_str(&input)?,
139+
thought_signatures: None,
139140
};
140141

141142
// Add to the captured_tool_calls if chat options say so
@@ -182,6 +183,7 @@ impl futures::Stream for AnthropicStreamer {
182183
captured_text_content: self.captured_data.content.take(),
183184
captured_reasoning_content: self.captured_data.reasoning_content.take(),
184185
captured_tool_calls: self.captured_data.tool_calls.take(),
186+
captured_thought_signatures: None,
185187
};
186188

187189
// TODO: Need to capture the data as needed

src/adapter/adapters/cohere/streamer.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ impl futures::Stream for CohereStreamer {
108108
captured_text_content: self.captured_data.content.take(),
109109
captured_reasoning_content: self.captured_data.reasoning_content.take(),
110110
captured_tool_calls: self.captured_data.tool_calls.take(),
111+
captured_thought_signatures: None,
111112
};
112113

113114
InterStreamEvent::End(inter_stream_end)

src/adapter/adapters/gemini/adapter_impl.rs

Lines changed: 104 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -208,15 +208,41 @@ impl Adapter for GeminiAdapter {
208208
usage,
209209
} = gemini_response;
210210

211-
// FIXME: Needs to take the content list
212-
let mut content: MessageContent = MessageContent::default();
211+
let mut thoughts: Vec<String> = Vec::new();
212+
let mut texts: Vec<String> = Vec::new();
213+
let mut tool_calls: Vec<ToolCall> = Vec::new();
214+
213215
for g_item in gemini_content {
214216
match g_item {
215-
GeminiChatContent::Text(text) => content.push(text),
216-
GeminiChatContent::ToolCall(tool_call) => content.push(tool_call),
217+
GeminiChatContent::Text(text) => texts.push(text),
218+
GeminiChatContent::ToolCall(tool_call) => tool_calls.push(tool_call),
219+
GeminiChatContent::ThoughtSignature(thought) => thoughts.push(thought),
220+
}
221+
}
222+
223+
let thought_signatures_for_call = (!thoughts.is_empty() && !tool_calls.is_empty()).then(|| thoughts.clone());
224+
let mut parts: Vec<ContentPart> = thoughts.into_iter().map(ContentPart::ThoughtSignature).collect();
225+
226+
if let Some(signatures) = thought_signatures_for_call {
227+
if let Some(first_call) = tool_calls.first_mut() {
228+
first_call.thought_signatures = Some(signatures);
217229
}
218230
}
219231

232+
if !texts.is_empty() {
233+
let total_len: usize = texts.iter().map(|t| t.len()).sum();
234+
let mut combined_text = String::with_capacity(total_len);
235+
for text in texts {
236+
combined_text.push_str(&text);
237+
}
238+
if !combined_text.is_empty() {
239+
parts.push(ContentPart::Text(combined_text));
240+
}
241+
}
242+
243+
parts.extend(tool_calls.into_iter().map(ContentPart::ToolCall));
244+
let content = MessageContent::from_parts(parts);
245+
220246
Ok(ChatResponse {
221247
content,
222248
reasoning_content: None,
@@ -293,13 +319,36 @@ impl GeminiAdapter {
293319
};
294320

295321
for mut part in parts {
322+
// -- Capture eventual thought signature
323+
{
324+
if let Some(thought) = part
325+
.x_take::<Value>("thoughtSignature")
326+
.ok()
327+
.and_then(|v| if let Value::String(v) = v { Some(v) } else { None })
328+
{
329+
content.push(GeminiChatContent::ThoughtSignature(thought));
330+
}
331+
// Note: sometime the thought is in "thought" (undocumented, but observed in some cases or older models?)
332+
// But for Gemini 3 it is thoughtSignature. Keeping this just in case or for backward compat if it was used.
333+
// Actually, let's stick to thoughtSignature as per docs, but if we see "thought" we might want to capture it too.
334+
// Let's check for "thought" if "thoughtSignature" was not found.
335+
else if let Some(thought) = part
336+
.x_take::<Value>("thought")
337+
.ok()
338+
.and_then(|v| if let Value::String(v) = v { Some(v) } else { None })
339+
{
340+
content.push(GeminiChatContent::ThoughtSignature(thought));
341+
}
342+
}
343+
296344
// -- Capture eventual function call
297345
if let Ok(fn_call_value) = part.x_take::<Value>("functionCall") {
298346
let tool_call = ToolCall {
299347
// NOTE: Gemini does not have call_id so, use name
300348
call_id: fn_call_value.x_get("name").unwrap_or("".to_string()), // TODO: Handle this, gemini does not return the call_id
301349
fn_name: fn_call_value.x_get("name").unwrap_or("".to_string()),
302350
fn_arguments: fn_call_value.x_get("args").unwrap_or(Value::Null),
351+
thought_signatures: None,
303352
};
304353
content.push(GeminiChatContent::ToolCall(tool_call))
305354
}
@@ -458,29 +507,66 @@ impl GeminiAdapter {
458507
}
459508
}));
460509
}
510+
ContentPart::ThoughtSignature(thought) => {
511+
parts_values.push(json!({
512+
"thoughtSignature": thought
513+
}));
514+
}
461515
}
462516
}
463517

464518
contents.push(json!({"role": "user", "parts": parts_values}));
465519
}
466520
ChatRole::Assistant => {
467521
let mut parts_values: Vec<Value> = Vec::new();
522+
let mut pending_thought: Option<String> = None;
468523
for part in msg.content {
469524
match part {
470-
ContentPart::Text(text) => parts_values.push(json!({"text": text})),
525+
ContentPart::Text(text) => {
526+
if let Some(thought) = pending_thought.take() {
527+
parts_values.push(json!({"thoughtSignature": thought}));
528+
}
529+
parts_values.push(json!({"text": text}));
530+
}
471531
ContentPart::ToolCall(tool_call) => {
472-
parts_values.push(json!({
473-
"functionCall": {
532+
let mut part_obj = serde_json::Map::new();
533+
part_obj.insert(
534+
"functionCall".to_string(),
535+
json!({
474536
"name": tool_call.fn_name,
475537
"args": tool_call.fn_arguments,
476-
}
477-
}));
538+
}),
539+
);
540+
541+
if let Some(thought) = pending_thought.take() {
542+
// Inject thoughtSignature alongside functionCall in the same Part object
543+
part_obj.insert("thoughtSignature".to_string(), json!(thought));
544+
}
545+
546+
parts_values.push(Value::Object(part_obj));
547+
}
548+
ContentPart::ThoughtSignature(thought) => {
549+
if let Some(prev_thought) = pending_thought.take() {
550+
parts_values.push(json!({"thoughtSignature": prev_thought}));
551+
}
552+
pending_thought = Some(thought);
478553
}
479554
// Ignore unsupported parts for Assistant role
480-
ContentPart::Binary(_) => {}
481-
ContentPart::ToolResponse(_) => {}
555+
ContentPart::Binary(_) => {
556+
if let Some(thought) = pending_thought.take() {
557+
parts_values.push(json!({"thoughtSignature": thought}));
558+
}
559+
}
560+
ContentPart::ToolResponse(_) => {
561+
if let Some(thought) = pending_thought.take() {
562+
parts_values.push(json!({"thoughtSignature": thought}));
563+
}
564+
}
482565
}
483566
}
567+
if let Some(thought) = pending_thought {
568+
parts_values.push(json!({"thoughtSignature": thought}));
569+
}
484570
if !parts_values.is_empty() {
485571
contents.push(json!({"role": "model", "parts": parts_values}));
486572
}
@@ -508,10 +594,15 @@ impl GeminiAdapter {
508594
}
509595
}));
510596
}
597+
ContentPart::ThoughtSignature(thought) => {
598+
parts_values.push(json!({
599+
"thoughtSignature": thought
600+
}));
601+
}
511602
_ => {
512603
return Err(Error::MessageContentTypeNotSupported {
513604
model_iden: model_iden.clone(),
514-
cause: "ChatRole::Tool can only contain ToolCall or ToolResponse content parts",
605+
cause: "ChatRole::Tool can only contain ToolCall, ToolResponse, or Thought content parts",
515606
});
516607
}
517608
}
@@ -580,6 +671,7 @@ pub(super) struct GeminiChatResponse {
580671
pub(super) enum GeminiChatContent {
581672
Text(String),
582673
ToolCall(ToolCall),
674+
ThoughtSignature(String),
583675
}
584676

585677
struct GeminiChatRequestParts {

0 commit comments

Comments
 (0)